From 2fb05d7d0c9bfbeb4dc2d385d73ae6ef7327e4c8 Mon Sep 17 00:00:00 2001 From: alishoja88 Date: Sat, 25 Jul 2026 10:43:39 -0300 Subject: [PATCH] fix(query-devtools): isolate state across multiple mounted instances Selection, panel width, offline-mocking, and cache-subscription state lived in module-level signals/maps in Devtools.tsx, so every mounted Devtools instance (e.g. two panels bound to two different QueryClients) shared the same state. Selecting a query, resizing, or notifying one client's cache leaked into every other mounted panel. Move this state into a per-instance DevtoolsUiProvider/context so each mount is fully isolated. Fixes #9681 Co-authored-by: Cursor --- .changeset/isolate-devtools-instances.md | 5 + packages/query-devtools/src/Devtools.tsx | 51 +++--- .../query-devtools/src/DevtoolsComponent.tsx | 19 ++- .../src/DevtoolsPanelComponent.tsx | 41 +++-- .../src/__tests__/Devtools.test.tsx | 22 ++- .../__tests__/MultiInstanceIsolation.test.tsx | 155 ++++++++++++++++++ .../src/contexts/DevtoolsUiContext.tsx | 91 ++++++++++ packages/query-devtools/src/contexts/index.ts | 1 + 8 files changed, 333 insertions(+), 52 deletions(-) create mode 100644 .changeset/isolate-devtools-instances.md create mode 100644 packages/query-devtools/src/__tests__/MultiInstanceIsolation.test.tsx create mode 100644 packages/query-devtools/src/contexts/DevtoolsUiContext.tsx diff --git a/.changeset/isolate-devtools-instances.md b/.changeset/isolate-devtools-instances.md new file mode 100644 index 00000000000..ba8487caf5b --- /dev/null +++ b/.changeset/isolate-devtools-instances.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-devtools": patch +--- + +Fix multiple mounted Devtools instances (e.g. two panels pointing at two different `QueryClient`s) sharing selection, panel width, offline-mocking, and cache-subscription state through module-level signals and maps. Each Devtools instance now owns its own isolated UI/subscription state, so selecting a query, resizing, or mocking offline behavior in one panel no longer affects any other mounted panel. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index daff934932f..d5531fd187a 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 { + useDevtoolsUiContext, + usePiPWindow, + useQueryDevtoolsContext, + useTheme, +} from './contexts' import { BUTTON_POSITION, DEFAULT_HEIGHT, @@ -98,21 +103,13 @@ 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 theme = useTheme() + const { setOffline } = useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -283,6 +280,7 @@ const PiPPanel: Component<{ }> = (props) => { const pip = usePiPWindow() const theme = useTheme() + const { panelWidth, setPanelWidth } = useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -352,6 +350,7 @@ export const ParentPanel: Component<{ children: JSX.Element }> = (props) => { const theme = useTheme() + const { panelWidth, setPanelWidth } = useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -409,6 +408,8 @@ export const ParentPanel: Component<{ const DraggablePanel: Component = (props) => { const theme = useTheme() + const { panelWidth, setPanelWidth, setSelectedQueryHash } = + useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -676,6 +677,14 @@ export const ContentView: Component = (props) => { setupMutationCacheSubscription() let containerRef!: HTMLDivElement const theme = useTheme() + const { + panelWidth, + selectedQueryHash, + setSelectedQueryHash, + selectedMutationId, + setSelectedMutationId, + offline, + } = useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -1373,6 +1382,7 @@ export const ContentView: Component = (props) => { const QueryRow: Component<{ query: Query }> = (props) => { const theme = useTheme() + const { selectedQueryHash, setSelectedQueryHash } = useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -1482,6 +1492,7 @@ const QueryRow: Component<{ query: Query }> = (props) => { const MutationRow: Component<{ mutation: Mutation }> = (props) => { const theme = useTheme() + const { selectedMutationId, setSelectedMutationId } = useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -1723,6 +1734,7 @@ const MutationStatusCount: Component = () => { const QueryStatus: Component = (props) => { const theme = useTheme() + const { selectedQueryHash, panelWidth } = useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -1838,6 +1850,7 @@ const QueryStatus: Component = (props) => { const QueryDetails = () => { const theme = useTheme() + const { selectedQueryHash, setSelectedQueryHash } = useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -2383,6 +2396,7 @@ const QueryDetails = () => { const MutationDetails = () => { const theme = useTheme() + const { selectedMutationId } = useDevtoolsUiContext() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) : goober.css @@ -2569,15 +2583,8 @@ const MutationDetails = () => { ) } -const queryCacheMap = new Map< - (q: Accessor) => any, - { - setter: Setter - shouldUpdate: (event: QueryCacheNotifyEvent) => boolean - } ->() - const setupQueryCacheSubscription = () => { + const { queryCacheMap } = useDevtoolsUiContext() const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getQueryCache() @@ -2605,6 +2612,7 @@ const createSubscribeToQueryCacheBatcher = ( equalityCheck: boolean = true, shouldUpdate: (event: QueryCacheNotifyEvent) => boolean = () => true, ) => { + const { queryCacheMap } = useDevtoolsUiContext() const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getQueryCache() @@ -2631,12 +2639,8 @@ const createSubscribeToQueryCacheBatcher = ( return value } -const mutationCacheMap = new Map< - (q: Accessor) => any, - Setter ->() - const setupMutationCacheSubscription = () => { + const { mutationCacheMap } = useDevtoolsUiContext() const mutationCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getMutationCache() @@ -2662,6 +2666,7 @@ const createSubscribeToMutationCacheBatcher = ( callback: (queryCache: Accessor) => Exclude, equalityCheck: boolean = true, ) => { + const { mutationCacheMap } = useDevtoolsUiContext() 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..2c197d4026d 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 { + DevtoolsUiProvider, + 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..68a2ae0c284 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 { + DevtoolsUiProvider, + 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 5ed22c71816..4e7cd78d693 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -3,7 +3,12 @@ import { QueryClient, QueryObserver, onlineManager } from '@tanstack/query-core' import { fireEvent, render } from '@solidjs/testing-library' import { createLocalStorage } from '@solid-primitives/storage' import { Devtools } from '../Devtools' -import { PiPProvider, QueryDevtoolsContext, ThemeContext } from '../contexts' +import { + DevtoolsUiProvider, + PiPProvider, + QueryDevtoolsContext, + ThemeContext, +} from '../contexts' import type { QueryDevtoolsProps } from '../contexts' // `solid-transition-group` internally imports from @@ -118,11 +123,16 @@ describe('Devtools', () => { ...overrides, }} > - - 'dark'}> - - - + + + 'dark'}> + + + + ) }) diff --git a/packages/query-devtools/src/__tests__/MultiInstanceIsolation.test.tsx b/packages/query-devtools/src/__tests__/MultiInstanceIsolation.test.tsx new file mode 100644 index 00000000000..f345c8522d0 --- /dev/null +++ b/packages/query-devtools/src/__tests__/MultiInstanceIsolation.test.tsx @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, onlineManager } from '@tanstack/query-core' +import { fireEvent, render, within } from '@solidjs/testing-library' +import DevtoolsComponent from '../DevtoolsComponent' + +// `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, +})) + +// `goober` compiles every `css\`...\`` template literal at mount time, which +// dominates mount cost and produces no value for label/role-based +// assertions, so we replace it with a no-op factory. +vi.mock('goober', () => { + let counter = 0 + const css = Object.assign(() => `tsqd-${++counter}`, { + bind: () => css, + }) + return { css, glob: () => {}, setup: () => {} } +}) + +// Regression tests for https://github.com/TanStack/query/issues/9681 — +// selection, panel width, offline-mocking, and cache-subscription state used +// to live in module-level signals/maps shared by every mounted Devtools +// instance, so actions in one panel leaked into every other panel on the +// page (e.g. two panels pointing at two different `QueryClient`s). +describe('multiple Devtools instances', () => { + const storage: { [key: string]: string } = {} + let queryClientA: QueryClient + let queryClientB: QueryClient + + 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() + }, + ) + queryClientA = new QueryClient() + queryClientB = new QueryClient() + }) + + afterEach(() => { + vi.unstubAllGlobals() + Object.keys(storage).forEach((key) => delete storage[key]) + queryClientA.clear() + queryClientB.clear() + }) + + function renderTwoInstances() { + // Both clients use the same query key on purpose: this is exactly the + // scenario that leaked through the old module-level `queryCacheMap`, + // where a notify from client A's cache could invoke client B's row + // callback with client A's cache accessor. + queryClientA.setQueryData(['shared-key'], { owner: 'A' }) + queryClientB.setQueryData(['shared-key'], { owner: 'B' }) + + return render(() => ( + <> + + + + )) + } + + it('should not open the query details panel in instance B when a row is selected in instance A', () => { + const rendered = renderTwoInstances() + const panels = rendered.getAllByLabelText('Tanstack query devtools') + expect(panels).toHaveLength(2) + const [panelA, panelB] = panels + + fireEvent.click(within(panelA).getByLabelText(/Query key \["shared-key"\]/)) + + expect(within(panelA).getByText('Query Details')).toBeInTheDocument() + expect(within(panelB).queryByText('Query Details')).not.toBeInTheDocument() + }) + + it("should show each instance's own data in its query details, not the other instance's", () => { + const rendered = renderTwoInstances() + const panels = rendered.getAllByLabelText('Tanstack query devtools') + const [panelA, panelB] = panels + + fireEvent.click(within(panelA).getByLabelText(/Query key \["shared-key"\]/)) + fireEvent.click(within(panelB).getByLabelText(/Query key \["shared-key"\]/)) + + // The "Data" explorer field is editable, so string values render as an + // `` rather than plain text. + expect(within(panelA).getByDisplayValue('A')).toBeInTheDocument() + expect(within(panelB).getByDisplayValue('B')).toBeInTheDocument() + }) + + it("should not leak client A's cache updates into client B's row data", () => { + const rendered = renderTwoInstances() + const panels = rendered.getAllByLabelText('Tanstack query devtools') + const [panelA, panelB] = panels + + fireEvent.click(within(panelB).getByLabelText(/Query key \["shared-key"\]/)) + expect(within(panelB).getByDisplayValue('B')).toBeInTheDocument() + + // Notify only client A's cache. Under the old module-level + // `queryCacheMap`, this would also invoke B's row/detail callbacks with + // A's cache accessor, overwriting B's displayed data with A's. + queryClientA.setQueryData(['shared-key'], { owner: 'A-updated' }) + + expect(within(panelB).getByDisplayValue('B')).toBeInTheDocument() + expect( + within(panelB).queryByDisplayValue('A-updated'), + ).not.toBeInTheDocument() + }) +}) diff --git a/packages/query-devtools/src/contexts/DevtoolsUiContext.tsx b/packages/query-devtools/src/contexts/DevtoolsUiContext.tsx new file mode 100644 index 00000000000..d489bb9b99a --- /dev/null +++ b/packages/query-devtools/src/contexts/DevtoolsUiContext.tsx @@ -0,0 +1,91 @@ +import { createContext, createSignal, useContext } from 'solid-js' +import type { Accessor, JSX, Setter } from 'solid-js' +import type { + MutationCache, + QueryCache, + QueryCacheNotifyEvent, +} from '@tanstack/query-core' + +interface DevtoolsUiProviderProps { + children: JSX.Element +} + +interface QueryCacheBatcherEntry { + setter: Setter + shouldUpdate: (event: QueryCacheNotifyEvent) => boolean +} + +export interface DevtoolsUiContextValue { + selectedQueryHash: Accessor + setSelectedQueryHash: Setter + selectedMutationId: Accessor + setSelectedMutationId: Setter + panelWidth: Accessor + setPanelWidth: Setter + offline: Accessor + setOffline: Setter + /** + * Registry of active query-row subscriptions for this Devtools instance. + * Keeping this per-instance (instead of module-level) prevents two mounted + * Devtools panels from cross-notifying each other's rows when their + * underlying query caches differ. + */ + queryCacheMap: Map<(q: Accessor) => any, QueryCacheBatcherEntry> + mutationCacheMap: Map<(q: Accessor) => any, Setter> +} + +const DevtoolsUiContext = createContext( + undefined, +) + +/** + * Owns the UI/selection state and cache-subscription registries for a single + * mounted Devtools instance (button + panel, or standalone panel). + * + * This state used to live in module-level signals/maps in `Devtools.tsx`, + * which meant every `TanstackQueryDevtools` instance mounted on a page + * (e.g. two panels pointing at two different `QueryClient`s) shared the same + * selected query/mutation, panel width, and cache-subscription registry. + * That caused actions in one panel (selecting a query, resizing, etc.) to + * leak into every other panel. See: + * https://github.com/TanStack/query/issues/9681 + */ +export function DevtoolsUiProvider(props: DevtoolsUiProviderProps) { + const [selectedQueryHash, setSelectedQueryHash] = createSignal( + null, + ) + const [selectedMutationId, setSelectedMutationId] = createSignal< + number | null + >(null) + const [panelWidth, setPanelWidth] = createSignal(0) + const [offline, setOffline] = createSignal(false) + + const value: DevtoolsUiContextValue = { + selectedQueryHash, + setSelectedQueryHash, + selectedMutationId, + setSelectedMutationId, + panelWidth, + setPanelWidth, + offline, + setOffline, + queryCacheMap: new Map(), + mutationCacheMap: new Map(), + } + + return ( + + {props.children} + + ) +} + +export function useDevtoolsUiContext() { + const context = useContext(DevtoolsUiContext) + if (!context) { + throw new Error( + 'useDevtoolsUiContext must be used within a DevtoolsUiProvider', + ) + } + return context +} diff --git a/packages/query-devtools/src/contexts/index.ts b/packages/query-devtools/src/contexts/index.ts index b9329a3cd9c..4d9c343b268 100644 --- a/packages/query-devtools/src/contexts/index.ts +++ b/packages/query-devtools/src/contexts/index.ts @@ -1,3 +1,4 @@ +export * from './DevtoolsUiContext' export * from './PiPContext' export * from './QueryDevtoolsContext' export * from './ThemeContext'