From c1032ba3b39ade94f40f19b108e9bcc9d9e42682 Mon Sep 17 00:00:00 2001 From: Aakash Thapa Date: Thu, 1 Oct 2026 02:33:20 -0500 Subject: [PATCH 1/4] fix(query-devtools): isolate devtools state per mounted instance --- packages/query-devtools/src/Devtools.tsx | 34 +++++++++++++++++------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index c19022e631..9692cce043 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, setOffline } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -286,6 +283,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 +353,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 +411,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 +676,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 +1384,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 +1494,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 +1736,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 +1852,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 +2399,7 @@ const QueryDetails = () => { } const MutationDetails = () => { + const { selectedMutationId } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) From 7c151c256558aa2ef4e5f033ab74f41d1c52a104 Mon Sep 17 00:00:00 2001 From: Aakash Thapa Date: Thu, 1 Oct 2026 02:33:35 -0500 Subject: [PATCH 2/4] fix(query-devtools): isolate devtools state per mounted instance --- .changeset/tidy-pandas-switch.md | 5 + .../query-devtools/src/DevtoolsComponent.tsx | 19 ++- .../src/DevtoolsPanelComponent.tsx | 41 ++--- .../src/__tests__/Devtools.test.tsx | 22 ++- .../src/__tests__/DevtoolsIsolation.test.tsx | 140 ++++++++++++++++++ .../src/contexts/DevtoolsStateContext.tsx | 58 ++++++++ packages/query-devtools/src/contexts/index.ts | 1 + 7 files changed, 257 insertions(+), 29 deletions(-) create mode 100644 .changeset/tidy-pandas-switch.md create mode 100644 packages/query-devtools/src/__tests__/DevtoolsIsolation.test.tsx create mode 100644 packages/query-devtools/src/contexts/DevtoolsStateContext.tsx diff --git a/.changeset/tidy-pandas-switch.md b/.changeset/tidy-pandas-switch.md new file mode 100644 index 0000000000..c4a9da52e7 --- /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. diff --git a/packages/query-devtools/src/DevtoolsComponent.tsx b/packages/query-devtools/src/DevtoolsComponent.tsx index 069d79c87a..4b8c7217fe 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 cae641b44a..a6ef3a0b0d 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 d53c074dae..827b76f78e 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 0000000000..ed779b3a69 --- /dev/null +++ b/packages/query-devtools/src/__tests__/DevtoolsIsolation.test.tsx @@ -0,0 +1,140 @@ +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 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') + } + + 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() + }) +}) diff --git a/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx new file mode 100644 index 0000000000..26c6ffeb00 --- /dev/null +++ b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx @@ -0,0 +1,58 @@ +import { createContext, createSignal, useContext } from 'solid-js' +import type { Accessor, JSX, Setter } from 'solid-js' + +export interface DevtoolsState { + selectedQueryHash: Accessor + setSelectedQueryHash: Setter + selectedMutationId: Accessor + setSelectedMutationId: Setter + panelWidth: Accessor + setPanelWidth: Setter + offline: Accessor + setOffline: 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 [selectedQueryHash, setSelectedQueryHash] = createSignal( + null, + ) + const [selectedMutationId, setSelectedMutationId] = createSignal< + number | null + >(null) + const [panelWidth, setPanelWidth] = createSignal(0) + const [offline, setOffline] = createSignal(false) + + return ( + + {props.children} + + ) +} diff --git a/packages/query-devtools/src/contexts/index.ts b/packages/query-devtools/src/contexts/index.ts index b9329a3cd9..a1b7cb5d38 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' From 5f3629bec9b6b78c4b7b2578e00d987e538e6ab7 Mon Sep 17 00:00:00 2001 From: Aakash Thapa Date: Thu, 1 Oct 2026 03:02:47 -0500 Subject: [PATCH 3/4] fix(query-devtools): scope cache subscriptions per instance (1/2) --- packages/query-devtools/src/Devtools.tsx | 31 ++++-------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 9692cce043..bcf492fa04 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -108,7 +108,7 @@ export type DevtoolsComponentType = Component & { } export const Devtools: Component = (props) => { - const { offline, setOffline } = useDevtoolsState() + const { offline } = useDevtoolsState() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -116,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() @@ -2587,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() @@ -2623,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() @@ -2649,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() @@ -2680,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() From 6d58d54604d7ce4b37c3c74bc56addb2b5729b40 Mon Sep 17 00:00:00 2001 From: Aakash Thapa Date: Thu, 1 Oct 2026 03:05:27 -0500 Subject: [PATCH 4/4] fix(query-devtools): scope cache subscriptions per instance (2/2) --- .changeset/tidy-pandas-switch.md | 2 +- .../src/__tests__/DevtoolsIsolation.test.tsx | 74 +++++++++++++++++++ .../src/contexts/DevtoolsStateContext.tsx | 51 ++++++++++++- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/.changeset/tidy-pandas-switch.md b/.changeset/tidy-pandas-switch.md index c4a9da52e7..2f2c47b18d 100644 --- a/.changeset/tidy-pandas-switch.md +++ b/.changeset/tidy-pandas-switch.md @@ -2,4 +2,4 @@ '@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. +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/__tests__/DevtoolsIsolation.test.tsx b/packages/query-devtools/src/__tests__/DevtoolsIsolation.test.tsx index ed779b3a69..b512d12284 100644 --- a/packages/query-devtools/src/__tests__/DevtoolsIsolation.test.tsx +++ b/packages/query-devtools/src/__tests__/DevtoolsIsolation.test.tsx @@ -2,6 +2,7 @@ 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 @@ -100,6 +101,15 @@ describe('Devtools instance isolation', () => { 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() @@ -137,4 +147,68 @@ describe('Devtools instance isolation', () => { 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 index 26c6ffeb00..f66389c143 100644 --- a/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx +++ b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx @@ -1,5 +1,18 @@ -import { createContext, createSignal, useContext } from 'solid-js' +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 @@ -10,6 +23,21 @@ export interface DevtoolsState { 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) @@ -30,6 +58,9 @@ export function DevtoolsStateProvider(props: { children: JSX.Element }) { // 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, ) @@ -37,7 +68,21 @@ export function DevtoolsStateProvider(props: { children: JSX.Element }) { number | null >(null) const [panelWidth, setPanelWidth] = createSignal(0) - const [offline, setOffline] = createSignal(false) + 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}