diff --git a/.changeset/tidy-pandas-switch.md b/.changeset/tidy-pandas-switch.md new file mode 100644 index 00000000000..2f2c47b18db --- /dev/null +++ b/.changeset/tidy-pandas-switch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-devtools': patch +--- + +Fix devtools state leaking between multiple mounted devtools instances. The selected query/mutation, panel width, and offline-indicator signals were created once at module scope, so interacting with one panel (e.g. selecting a query) affected every other panel on the page. They are now created per devtools instance via a new `DevtoolsStateProvider` context. The query/mutation cache subscription registries are scoped per instance as well, so cache updates for one client's cache no longer invoke other panels' callbacks and unmounting one panel no longer clears the others' subscriptions. The online/offline subscription also moved into the provider so panel-only instances reflect their configured `onlineManager`. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index c19022e6312..bcf492fa045 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -51,7 +51,12 @@ import { XCircle, } from './icons' import Explorer from './Explorer' -import { usePiPWindow, useQueryDevtoolsContext, useTheme } from './contexts' +import { + useDevtoolsState, + usePiPWindow, + useQueryDevtoolsContext, + useTheme, +} from './contexts' import { BUTTON_POSITION, DEFAULT_HEIGHT, @@ -98,20 +103,12 @@ interface QueryStatusProps { count: number } -const [selectedQueryHash, setSelectedQueryHash] = createSignal( - null, -) -const [selectedMutationId, setSelectedMutationId] = createSignal( - null, -) -const [panelWidth, setPanelWidth] = createSignal(0) -const [offline, setOffline] = createSignal(false) - export type DevtoolsComponentType = Component & { shadowDOMTarget?: ShadowRoot } export const Devtools: Component = (props) => { + const { offline } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -119,18 +116,6 @@ export const Devtools: Component = (props) => { const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) - const onlineManager = createMemo( - () => useQueryDevtoolsContext().onlineManager, - ) - onMount(() => { - const unsubscribe = onlineManager().subscribe((online) => { - setOffline(!online) - }) - - onCleanup(() => { - unsubscribe() - }) - }) const pip = usePiPWindow() @@ -286,6 +271,7 @@ const PiPPanel: Component<{ children: JSX.Element }> = (props) => { const pip = usePiPWindow() + const { panelWidth, setPanelWidth } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -355,6 +341,7 @@ const PiPPanel: Component<{ export const ParentPanel: Component<{ children: JSX.Element }> = (props) => { + const { panelWidth, setPanelWidth } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -412,6 +399,7 @@ export const ParentPanel: Component<{ } const DraggablePanel: Component = (props) => { + const { panelWidth, setPanelWidth, setSelectedQueryHash } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -676,6 +664,14 @@ const DraggablePanel: Component = (props) => { } export const ContentView: Component = (props) => { + const { + selectedQueryHash, + setSelectedQueryHash, + selectedMutationId, + setSelectedMutationId, + panelWidth, + offline, + } = useDevtoolsState() setupQueryCacheSubscription() setupMutationCacheSubscription() let containerRef!: HTMLDivElement @@ -1376,6 +1372,7 @@ export const ContentView: Component = (props) => { } const QueryRow: Component<{ query: Query }> = (props) => { + const { selectedQueryHash, setSelectedQueryHash } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -1485,6 +1482,7 @@ const QueryRow: Component<{ query: Query }> = (props) => { } const MutationRow: Component<{ mutation: Mutation }> = (props) => { + const { selectedMutationId, setSelectedMutationId } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -1726,6 +1724,7 @@ const MutationStatusCount: Component = () => { } const QueryStatus: Component = (props) => { + const { selectedQueryHash, panelWidth } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -1841,6 +1840,8 @@ const QueryStatus: Component = (props) => { } const QueryDetails = () => { + const { selectedQueryHash, setSelectedQueryHash, panelWidth } = + useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -2386,6 +2387,7 @@ const QueryDetails = () => { } const MutationDetails = () => { + const { selectedMutationId } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -2573,15 +2575,8 @@ const MutationDetails = () => { ) } -const queryCacheMap = new Map< - (q: Accessor) => any, - { - setter: Setter - shouldUpdate: (event: QueryCacheNotifyEvent) => boolean - } ->() - const setupQueryCacheSubscription = () => { + const { queryCacheMap } = useDevtoolsState() const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getQueryCache() @@ -2609,6 +2604,7 @@ const createSubscribeToQueryCacheBatcher = ( equalityCheck: boolean = true, shouldUpdate: (event: QueryCacheNotifyEvent) => boolean = () => true, ) => { + const { queryCacheMap } = useDevtoolsState() const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getQueryCache() @@ -2635,12 +2631,8 @@ const createSubscribeToQueryCacheBatcher = ( return value } -const mutationCacheMap = new Map< - (q: Accessor) => any, - Setter ->() - const setupMutationCacheSubscription = () => { + const { mutationCacheMap } = useDevtoolsState() const mutationCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getMutationCache() @@ -2666,6 +2658,7 @@ const createSubscribeToMutationCacheBatcher = ( callback: (queryCache: Accessor) => Exclude, equalityCheck: boolean = true, ) => { + const { mutationCacheMap } = useDevtoolsState() const mutationCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getMutationCache() diff --git a/packages/query-devtools/src/DevtoolsComponent.tsx b/packages/query-devtools/src/DevtoolsComponent.tsx index 069d79c87a2..4b8c7217fe3 100644 --- a/packages/query-devtools/src/DevtoolsComponent.tsx +++ b/packages/query-devtools/src/DevtoolsComponent.tsx @@ -3,7 +3,12 @@ import { createMemo } from 'solid-js' import { Devtools } from './Devtools' import { getPreferredColorScheme } from './utils' import { THEME_PREFERENCE } from './constants' -import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts' +import { + DevtoolsStateProvider, + PiPProvider, + QueryDevtoolsContext, + ThemeContext, +} from './contexts' import type { Theme } from './contexts' import type { DevtoolsComponentType } from './Devtools' @@ -24,11 +29,13 @@ const DevtoolsComponent: DevtoolsComponentType = (props) => { return ( - - - - - + + + + + + + ) } diff --git a/packages/query-devtools/src/DevtoolsPanelComponent.tsx b/packages/query-devtools/src/DevtoolsPanelComponent.tsx index cae641b44ad..a6ef3a0b0da 100644 --- a/packages/query-devtools/src/DevtoolsPanelComponent.tsx +++ b/packages/query-devtools/src/DevtoolsPanelComponent.tsx @@ -3,7 +3,12 @@ import { createMemo } from 'solid-js' import { ContentView, ParentPanel } from './Devtools' import { getPreferredColorScheme } from './utils' import { THEME_PREFERENCE } from './constants' -import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts' +import { + DevtoolsStateProvider, + PiPProvider, + QueryDevtoolsContext, + ThemeContext, +} from './contexts' import type { Theme } from './contexts' import type { DevtoolsComponentType } from './Devtools' @@ -24,22 +29,24 @@ const DevtoolsPanelComponent: DevtoolsComponentType = (props) => { return ( - - - - - - - + + + + + + + + + ) } diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index d53c074dae0..827b76f78e4 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -10,7 +10,12 @@ import { import { fireEvent, render } from '@solidjs/testing-library' import { createLocalStorage } from '@solid-primitives/storage' import { Devtools } from '../Devtools' -import { PiPProvider, QueryDevtoolsContext, ThemeContext } from '../contexts' +import { + DevtoolsStateProvider, + PiPProvider, + QueryDevtoolsContext, + ThemeContext, +} from '../contexts' import type { QueryDevtoolsProps } from '../contexts' // `solid-transition-group` internally imports from @@ -126,11 +131,16 @@ describe('Devtools', () => { ...overrides, }} > - - 'dark'}> - - - + + + 'dark'}> + + + + ) }) diff --git a/packages/query-devtools/src/__tests__/DevtoolsIsolation.test.tsx b/packages/query-devtools/src/__tests__/DevtoolsIsolation.test.tsx new file mode 100644 index 00000000000..b512d122841 --- /dev/null +++ b/packages/query-devtools/src/__tests__/DevtoolsIsolation.test.tsx @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, onlineManager } from '@tanstack/query-core' +import { fireEvent, render } from '@solidjs/testing-library' +import DevtoolsComponent from '../DevtoolsComponent' +import DevtoolsPanelComponent from '../DevtoolsPanelComponent' +import type { render as renderType } from '@solidjs/testing-library' + +// `solid-transition-group` internally imports from +// `@solid-primitives/transition-group`, whose `exports` field points at +// `src/index.ts` (not published) under a `@solid-primitives/source` condition +// that Vite can't fall through, so we stub it with a transparent pass-through. +vi.mock('solid-transition-group', () => ({ + TransitionGroup: (props: { children: unknown }) => props.children, +})) + +describe('Devtools instance isolation', () => { + const storage: { [key: string]: string } = {} + let clientA: QueryClient + let clientB: QueryClient + let renderedA: ReturnType | undefined + let renderedB: ReturnType | undefined + + beforeEach(() => { + vi.stubGlobal('localStorage', { + getItem: (key: string) => + Object.prototype.hasOwnProperty.call(storage, key) + ? storage[key] + : null, + setItem: (key: string, value: string) => { + storage[key] = value + }, + removeItem: (key: string) => { + delete storage[key] + }, + clear: () => { + Object.keys(storage).forEach((key) => delete storage[key]) + }, + }) + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ) + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + clientA = new QueryClient() + clientB = new QueryClient() + // Both clients hold a query with the same key, mirroring the issue's + // repro where each panel has its own QueryClient with similar queries. + clientA.setQueryData(['shared'], { owner: 'a' }) + clientB.setQueryData(['shared'], { owner: 'b' }) + }) + + afterEach(() => { + renderedA?.unmount() + renderedB?.unmount() + renderedA = undefined + renderedB = undefined + vi.unstubAllGlobals() + Object.keys(storage).forEach((key) => delete storage[key]) + clientA.clear() + clientB.clear() + }) + + function renderPair() { + renderedA = render(() => ( + + )) + renderedB = render(() => ( + + )) + } + + function detailsOf(rendered: ReturnType): Element | null { + return rendered.container.querySelector('.tsqd-query-details-container') + } + + // The Data Explorer renders string values as editable inputs, so the + // displayed data value is read from the input rather than textContent. + function dataOwnerValue( + rendered: ReturnType, + ): string | undefined { + const input = detailsOf(rendered)?.querySelector('input') + return input ? (input as HTMLInputElement).value : undefined + } + + it('selecting a query in one panel does not affect the other panel', () => { + renderPair() + + // Both panels render their own query list + expect( + renderedA!.getByLabelText(/Query key \["shared"\]/), + ).toBeInTheDocument() + expect( + renderedB!.getByLabelText(/Query key \["shared"\]/), + ).toBeInTheDocument() + + // Select the query in panel A + fireEvent.click(renderedA!.getByLabelText(/Query key \["shared"\]/)) + + // Panel A shows the query details... + expect(detailsOf(renderedA!)).toBeInTheDocument() + // ...but panel B must stay unaffected (https://github.com/TanStack/query/issues/9681) + expect(detailsOf(renderedB!)).not.toBeInTheDocument() + }) + + it('deselecting in one panel does not clear the selection of the other panel', () => { + renderPair() + + fireEvent.click(renderedA!.getByLabelText(/Query key \["shared"\]/)) + fireEvent.click(renderedB!.getByLabelText(/Query key \["shared"\]/)) + + // Both panels show their own details + expect(detailsOf(renderedA!)).toBeInTheDocument() + expect(detailsOf(renderedB!)).toBeInTheDocument() + + // Deselect in panel A by clicking the row again + fireEvent.click(renderedA!.getByLabelText(/Query key \["shared"\]/)) + + // Panel A hides its details, panel B keeps its own selection + expect(detailsOf(renderedA!)).not.toBeInTheDocument() + expect(detailsOf(renderedB!)).toBeInTheDocument() + }) + + it('cache updates in one client do not leak into the other panel', () => { + renderPair() + + // Both panels show details for their own client's `["shared"]` query + fireEvent.click(renderedA!.getByLabelText(/Query key \["shared"\]/)) + fireEvent.click(renderedB!.getByLabelText(/Query key \["shared"\]/)) + expect(dataOwnerValue(renderedA!)).toBe('a') + expect(dataOwnerValue(renderedB!)).toBe('b') + + // Update the query in client A only + clientA.setQueryData(['shared'], { owner: 'a-updated' }) + + // Panel A reflects the update... + expect(dataOwnerValue(renderedA!)).toBe('a-updated') + // ...but panel B must keep showing its own client's data + expect(dataOwnerValue(renderedB!)).toBe('b') + }) + + it('unmounting one panel does not break cache updates in the other panel', () => { + renderPair() + + fireEvent.click(renderedB!.getByLabelText(/Query key \["shared"\]/)) + expect(detailsOf(renderedB!)).toBeInTheDocument() + + // Unmount panel A entirely; its cleanup must not wipe panel B's + // cache-subscription registrations + renderedA!.unmount() + renderedA = undefined + + clientB.setQueryData(['shared'], { owner: 'b-updated' }) + expect(dataOwnerValue(renderedB!)).toBe('b-updated') + }) + + it('a panel-only instance reflects the onlineManager status', () => { + // DevtoolsPanelComponent renders ContentView without Devtools, so the + // offline indicator must be driven by the provider-level subscription + const renderedPanel = render(() => ( + + )) + try { + const offlineButton = () => + renderedPanel.container.querySelector( + '.tsqd-action-mock-offline-behavior', + ) + + expect(offlineButton()!.getAttribute('aria-pressed')).toBe('false') + + onlineManager.setOnline(false) + expect(offlineButton()!.getAttribute('aria-pressed')).toBe('true') + + onlineManager.setOnline(true) + expect(offlineButton()!.getAttribute('aria-pressed')).toBe('false') + } finally { + onlineManager.setOnline(true) + renderedPanel.unmount() + } + }) +}) diff --git a/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx new file mode 100644 index 00000000000..f66389c1434 --- /dev/null +++ b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx @@ -0,0 +1,105 @@ +import { + createContext, + createEffect, + createMemo, + createSignal, + onCleanup, + useContext, +} from 'solid-js' +import type { Accessor, JSX, Setter } from 'solid-js' +import type { + MutationCache, + QueryCache, + QueryCacheNotifyEvent, +} from '@tanstack/query-core' +import { useQueryDevtoolsContext } from './QueryDevtoolsContext' + +export interface DevtoolsState { + selectedQueryHash: Accessor + setSelectedQueryHash: Setter + selectedMutationId: Accessor + setSelectedMutationId: Setter + panelWidth: Accessor + setPanelWidth: Setter + offline: Accessor + setOffline: Setter + /** + * Registries of cache-subscription callbacks for this devtools instance. + * Previously these lived at module scope, so a cache notification for one + * client's cache invoked every mounted panel's callbacks with the wrong + * cache, and unmounting one panel cleared the other panels' registrations. + * See https://github.com/TanStack/query/issues/9681 + */ + queryCacheMap: Map< + (queryCache: Accessor) => any, + { + setter: Setter + shouldUpdate: (event: QueryCacheNotifyEvent) => boolean + } + > + mutationCacheMap: Map<(mutationCache: Accessor) => any, Setter> +} + +const DevtoolsStateContext = createContext(undefined) + +export function useDevtoolsState(): DevtoolsState { + const state = useContext(DevtoolsStateContext) + if (!state) { + throw new Error( + 'useDevtoolsState must be used within a DevtoolsStateProvider', + ) + } + return state +} + +export function DevtoolsStateProvider(props: { children: JSX.Element }) { + // This state is intentionally created inside the provider component so that + // every mounted devtools instance gets its own isolated copy. Previously + // these signals lived at module scope, which meant that interacting with + // one devtools panel (e.g. selecting a query) leaked into every other + // panel on the page. See https://github.com/TanStack/query/issues/9681 + const onlineManager = createMemo( + () => useQueryDevtoolsContext().onlineManager, + ) + const [selectedQueryHash, setSelectedQueryHash] = createSignal( + null, + ) + const [selectedMutationId, setSelectedMutationId] = createSignal< + number | null + >(null) + const [panelWidth, setPanelWidth] = createSignal(0) + const [offline, setOffline] = createSignal(!onlineManager().isOnline()) + const queryCacheMap: DevtoolsState['queryCacheMap'] = new Map() + const mutationCacheMap: DevtoolsState['mutationCacheMap'] = new Map() + + // The online/offline subscription lives here (rather than in `Devtools`) + // so that panel-only instances, which render `ContentView` without + // `Devtools`, reflect their configured onlineManager too. + createEffect(() => { + const manager = onlineManager() + setOffline(!manager.isOnline()) + const unsubscribe = manager.subscribe((online) => { + setOffline(!online) + }) + onCleanup(unsubscribe) + }) + + return ( + + {props.children} + + ) +} diff --git a/packages/query-devtools/src/contexts/index.ts b/packages/query-devtools/src/contexts/index.ts index b9329a3cd9c..a1b7cb5d384 100644 --- a/packages/query-devtools/src/contexts/index.ts +++ b/packages/query-devtools/src/contexts/index.ts @@ -1,3 +1,4 @@ export * from './PiPContext' export * from './QueryDevtoolsContext' export * from './ThemeContext' +export * from './DevtoolsStateContext'