From bab9dc5ec88899f9f34c017dcef98b1af4729365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Curley?= Date: Thu, 1 Oct 2026 15:02:31 +0100 Subject: [PATCH 1/5] fix(query-devtools): isolate mounted devtools instances --- .changeset/quiet-panels-stay-independent.md | 5 + packages/query-devtools/src/Devtools.tsx | 119 ++++----- .../query-devtools/src/DevtoolsComponent.tsx | 13 +- .../src/DevtoolsPanelComponent.tsx | 35 +-- .../src/__tests__/Devtools.test.tsx | 245 +++++++++++++++++- .../src/contexts/DevtoolsStateContext.tsx | 74 ++++++ 6 files changed, 402 insertions(+), 89 deletions(-) create mode 100644 .changeset/quiet-panels-stay-independent.md create mode 100644 packages/query-devtools/src/contexts/DevtoolsStateContext.tsx diff --git a/.changeset/quiet-panels-stay-independent.md b/.changeset/quiet-panels-stay-independent.md new file mode 100644 index 00000000000..296d34fd4c5 --- /dev/null +++ b/.changeset/quiet-panels-stay-independent.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-devtools': patch +--- + +Isolate selection, responsive panel state, and cache subscriptions between mounted DevTools instances. Keep subscriptions attached to the current client and prevent one instance's cleanup from affecting another. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index c19022e6312..40aa89cea92 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -17,6 +17,7 @@ import { Key } from '@solid-primitives/keyed' import { createResizeObserver } from '@solid-primitives/resize-observer' import { DropdownMenu, RadioGroup } from '@kobalte/core' import { Portal } from 'solid-js/web' +import { useDevtoolsState } from './contexts/DevtoolsStateContext' import { tokens } from './theme' import { convertRemToPixels, @@ -78,7 +79,7 @@ import type { QueryCacheNotifyEvent, } from '@tanstack/query-core' import type { StorageObject, StorageSetter } from '@solid-primitives/storage' -import type { Accessor, Component, JSX, Setter } from 'solid-js' +import type { Accessor, Component, JSX } from 'solid-js' interface DevtoolsPanelProps { localStore: StorageObject @@ -98,15 +99,6 @@ 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 } @@ -119,19 +111,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() const buttonPosition = createMemo(() => { @@ -285,6 +264,8 @@ export const Devtools: Component = (props) => { const PiPPanel: Component<{ children: JSX.Element }> = (props) => { + const { panelWidth, setPanelWidth } = useDevtoolsState() + const pip = usePiPWindow() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget @@ -355,6 +336,8 @@ 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 +395,8 @@ export const ParentPanel: Component<{ } const DraggablePanel: Component = (props) => { + const { setSelectedQueryHash, panelWidth, setPanelWidth } = useDevtoolsState() + const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -676,6 +661,15 @@ const DraggablePanel: Component = (props) => { } export const ContentView: Component = (props) => { + const { + selectedQueryHash, + setSelectedQueryHash, + selectedMutationId, + setSelectedMutationId, + panelWidth, + offline, + } = useDevtoolsState() + setupQueryCacheSubscription() setupMutationCacheSubscription() let containerRef!: HTMLDivElement @@ -1376,6 +1370,8 @@ 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 +1481,8 @@ 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,8 @@ 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 +1841,8 @@ const QueryStatus: Component = (props) => { } const QueryDetails = () => { + const { selectedQueryHash, setSelectedQueryHash } = useDevtoolsState() + const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -2386,6 +2388,8 @@ const QueryDetails = () => { } const MutationDetails = () => { + const { selectedMutationId } = useDevtoolsState() + const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -2573,35 +2577,25 @@ 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() }) - const unsubscribe = queryCache().subscribe((q) => { - batch(() => { - for (const [callback, value] of queryCacheMap.entries()) { - if (!value.shouldUpdate(q)) continue - value.setter(callback(queryCache)) - } + createEffect(() => { + const cache = queryCache() + const unsubscribe = cache.subscribe((event) => { + batch(() => { + for (const [callback, value] of queryCacheMap.entries()) { + if (!value.shouldUpdate(event)) continue + value.setter(callback(() => cache)) + } + }) }) + onCleanup(unsubscribe) }) - - onCleanup(() => { - queryCacheMap.clear() - unsubscribe() - }) - - return unsubscribe } const createSubscribeToQueryCacheBatcher = ( @@ -2609,6 +2603,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,37 +2630,37 @@ 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() }) - const unsubscribe = mutationCache().subscribe(() => { - for (const [callback, setter] of mutationCacheMap.entries()) { - queueMicrotask(() => { - setter(callback(mutationCache)) - }) - } - }) - - onCleanup(() => { - mutationCacheMap.clear() - unsubscribe() + createEffect(() => { + const cache = mutationCache() + let active = true + const unsubscribe = cache.subscribe(() => { + for (const [callback, setter] of mutationCacheMap.entries()) { + queueMicrotask(() => { + if (active && mutationCacheMap.has(callback)) { + setter(callback(() => cache)) + } + }) + } + }) + onCleanup(() => { + active = false + unsubscribe() + }) }) - - return unsubscribe } 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..ca9d4d367f2 100644 --- a/packages/query-devtools/src/DevtoolsComponent.tsx +++ b/packages/query-devtools/src/DevtoolsComponent.tsx @@ -1,5 +1,6 @@ import { createLocalStorage } from '@solid-primitives/storage' import { createMemo } from 'solid-js' +import { DevtoolsStateProvider } from './contexts/DevtoolsStateContext' import { Devtools } from './Devtools' import { getPreferredColorScheme } from './utils' import { THEME_PREFERENCE } from './constants' @@ -24,11 +25,13 @@ const DevtoolsComponent: DevtoolsComponentType = (props) => { return ( - - - - - + + + + + + + ) } diff --git a/packages/query-devtools/src/DevtoolsPanelComponent.tsx b/packages/query-devtools/src/DevtoolsPanelComponent.tsx index cae641b44ad..ae1c59b736a 100644 --- a/packages/query-devtools/src/DevtoolsPanelComponent.tsx +++ b/packages/query-devtools/src/DevtoolsPanelComponent.tsx @@ -1,5 +1,6 @@ import { createLocalStorage } from '@solid-primitives/storage' import { createMemo } from 'solid-js' +import { DevtoolsStateProvider } from './contexts/DevtoolsStateContext' import { ContentView, ParentPanel } from './Devtools' import { getPreferredColorScheme } from './utils' import { THEME_PREFERENCE } from './constants' @@ -24,22 +25,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..3c9aee3d551 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -1,3 +1,4 @@ +import { createSignal } from 'solid-js' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QueryClient, @@ -7,8 +8,14 @@ import { noop, onlineManager, } from '@tanstack/query-core' -import { fireEvent, render } from '@solidjs/testing-library' +import { fireEvent, render, within } from '@solidjs/testing-library' import { createLocalStorage } from '@solid-primitives/storage' +import DevtoolsComponent from '../DevtoolsComponent' +import { + DevtoolsStateProvider, + useDevtoolsState, +} from '../contexts/DevtoolsStateContext' +import DevtoolsPanelComponent from '../DevtoolsPanelComponent' import { Devtools } from '../Devtools' import { PiPProvider, QueryDevtoolsContext, ThemeContext } from '../contexts' import type { QueryDevtoolsProps } from '../contexts' @@ -37,8 +44,10 @@ describe('Devtools', () => { const storage: { [key: string]: string } = {} let queryClient: QueryClient let previousRootFontSize = '' + let resizeObservers: Array = [] beforeEach(() => { + resizeObservers = [] vi.useFakeTimers() previousRootFontSize = document.documentElement.style.fontSize vi.stubGlobal('localStorage', { @@ -75,6 +84,7 @@ describe('Devtools', () => { callback: ResizeObserverCallback constructor(callback: ResizeObserverCallback) { this.callback = callback + resizeObservers.push(this) } observe = vi.fn((target: Element) => { this.callback( @@ -108,10 +118,15 @@ describe('Devtools', () => { function renderDevtools( overrides: Partial = {}, initialStorage: Record = {}, + onState?: (state: ReturnType) => void, ) { Object.entries(initialStorage).forEach(([key, value]) => { localStorage.setItem(key, value) }) + function StateProbe() { + onState?.(useDevtoolsState()) + return null + } return render(() => { const [localStore, setLocalStore] = createLocalStorage({ prefix: 'TanstackQueryDevtools', @@ -126,16 +141,234 @@ describe('Devtools', () => { ...overrides, }} > - - 'dark'}> - - - + + + + 'dark'}> + + + + ) }) } + it('keeps measured panel widths independent', () => { + let firstState!: ReturnType + let secondState!: ReturnType + const first = renderDevtools({ initialIsOpen: true }, {}, (state) => { + firstState = state + }) + renderDevtools({ initialIsOpen: true }, {}, (state) => { + secondState = state + }) + const panel = first.container.querySelector('aside')! + for (const observer of resizeObservers) { + const observed = vi + .mocked(observer.observe) + .mock.calls.some(([target]) => target === panel) + if (observed) { + ;( + observer as ResizeObserver & { callback: ResizeObserverCallback } + ).callback( + [ + { + target: panel, + contentRect: new DOMRectReadOnly(0, 0, 400, 500), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }, + ], + observer, + ) + } + } + expect(firstState.panelWidth()).toBe(400) + expect(secondState.panelWidth()).toBe(1000) + }) + + describe.each(['full', 'embedded'] as const)( + '%s instance isolation', + (mode) => { + function mount(client = queryClient, manager = onlineManager) { + return mode === 'full' + ? renderDevtools({ + client, + onlineManager: manager, + initialIsOpen: true, + }) + : render(() => ( + + )) + } + it('keeps cache notifications and cleanup independent for the same query key', () => { + const secondClient = new QueryClient() + queryClient.setQueryData(['shared-key'], 'first-data') + secondClient.setQueryData(['shared-key'], 'second-data') + const firstRender = mount() + const secondRender = mount(secondClient) + const first = within(firstRender.container) + const second = within(secondRender.container) + fireEvent.click(first.getByLabelText('Query key ["shared-key"]')) + fireEvent.click(second.getByLabelText('Query key ["shared-key"]')) + + secondClient.setQueryData(['shared-key'], 'updated-second') + expect(first.getByDisplayValue('first-data')).toBeInTheDocument() + expect( + first.queryByDisplayValue('updated-second'), + ).not.toBeInTheDocument() + expect(second.getByDisplayValue('updated-second')).toBeInTheDocument() + + secondRender.unmount() + queryClient.setQueryData(['shared-key'], 'updated-first') + expect(first.getByDisplayValue('updated-first')).toBeInTheDocument() + secondClient.clear() + }) + + it('keeps mutation selection, notifications, and cleanup independent', async () => { + const secondClient = new QueryClient() + const firstMutation = queryClient + .getMutationCache() + .build(queryClient, { + mutationKey: ['mutation-a'], + mutationFn: () => Promise.resolve('first-result'), + }) + const secondMutation = secondClient + .getMutationCache() + .build(secondClient, { + mutationKey: ['mutation-b'], + mutationFn: () => Promise.resolve('second-result'), + }) + const firstRender = mount() + const secondRender = mount(secondClient) + const first = within(firstRender.container) + const second = within(secondRender.container) + fireEvent.click(first.getByText('Mutations')) + fireEvent.click(second.getByText('Mutations')) + await firstMutation.execute({}) + await secondMutation.execute({}) + await vi.advanceTimersByTimeAsync(0) + fireEvent.click(first.getByLabelText(/Mutation submitted at/)) + expect(second.queryByText('Mutation Details')).not.toBeInTheDocument() + fireEvent.click(second.getByLabelText(/Mutation submitted at/)) + expect(first.getByText('Mutation Details')).toBeInTheDocument() + expect(second.getByText('Mutation Details')).toBeInTheDocument() + await secondMutation.execute({}) + await vi.advanceTimersByTimeAsync(0) + expect(firstRender.container).toHaveTextContent('first-result') + expect(firstRender.container).not.toHaveTextContent('second-result') + secondRender.unmount() + await firstMutation.execute({}) + await vi.advanceTimersByTimeAsync(0) + expect(first.getByText('Mutation Details')).toBeInTheDocument() + secondClient.clear() + }) + + it('keeps offline indicators independent for different online managers', () => { + const Manager = + onlineManager.constructor as new () => typeof onlineManager + const firstManager = new Manager() + const secondManager = new Manager() + firstManager.setOnline(false) + const firstRender = mount(queryClient, firstManager) + const secondRender = mount(queryClient, secondManager) + const first = within(firstRender.container) + const second = within(secondRender.container) + expect( + first.getByLabelText('Unset offline mocking behavior'), + ).toHaveAttribute('aria-pressed', 'true') + expect(second.getByLabelText('Mock offline behavior')).toHaveAttribute( + 'aria-pressed', + 'false', + ) + firstManager.setOnline(true) + expect(first.getByLabelText('Mock offline behavior')).toHaveAttribute( + 'aria-pressed', + 'false', + ) + secondRender.unmount() + }) + + it('reflects the supplied online manager in both instances', () => { + const firstRender = mount() + const secondRender = mount() + const first = within(firstRender.container) + const second = within(secondRender.container) + fireEvent.click(first.getByLabelText('Mock offline behavior')) + expect( + first.getByLabelText('Unset offline mocking behavior'), + ).toHaveAttribute('aria-pressed', 'true') + expect( + second.getByLabelText('Unset offline mocking behavior'), + ).toHaveAttribute('aria-pressed', 'true') + secondRender.unmount() + onlineManager.setOnline(true) + expect(first.getByLabelText('Mock offline behavior')).toHaveAttribute( + 'aria-pressed', + 'false', + ) + }) + + it('switches cache subscriptions when the client changes', () => { + const secondClient = new QueryClient() + queryClient.setQueryData(['switch-key'], 'old-client') + secondClient.setQueryData(['switch-key'], 'new-client') + const [client, setClient] = createSignal(queryClient) + const Component = + mode === 'full' ? DevtoolsComponent : DevtoolsPanelComponent + const rendered = render(() => ( + + )) + const panel = within(rendered.container) + fireEvent.click(panel.getByLabelText('Query key ["switch-key"]')) + expect(panel.getByDisplayValue('old-client')).toBeInTheDocument() + setClient(secondClient) + expect(panel.getByDisplayValue('new-client')).toBeInTheDocument() + secondClient.setQueryData(['switch-key'], 'new-update') + expect(panel.getByDisplayValue('new-update')).toBeInTheDocument() + queryClient.setQueryData(['switch-key'], 'old-update') + expect(panel.getByDisplayValue('new-update')).toBeInTheDocument() + rendered.unmount() + secondClient.clear() + }) + + it('keeps query selection independent between clients', () => { + const secondClient = new QueryClient() + queryClient.setQueryData(['isolation-a'], 'a') + secondClient.setQueryData(['isolation-b'], 'b') + const first = within(mount().container) + const secondRender = mount(secondClient) + const second = within(secondRender.container) + + fireEvent.click(first.getByLabelText('Query key ["isolation-a"]')) + expect(first.getByText('Query Details')).toBeInTheDocument() + expect(second.queryByText('Query Details')).not.toBeInTheDocument() + + fireEvent.click(second.getByLabelText('Query key ["isolation-b"]')) + expect(second.getByText('Query Details')).toBeInTheDocument() + expect(first.getByText('Query Details')).toBeInTheDocument() + secondRender.unmount() + secondClient.clear() + }) + }, + ) + describe('initial state', () => { it('should render the open devtools button', () => { const rendered = renderDevtools() diff --git a/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx new file mode 100644 index 00000000000..00f0791c23b --- /dev/null +++ b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx @@ -0,0 +1,74 @@ +import { + createContext, + createEffect, + createSignal, + onCleanup, + useContext, +} from 'solid-js' +import { useQueryDevtoolsContext } from './QueryDevtoolsContext' +import type { + MutationCache, + QueryCache, + QueryCacheNotifyEvent, +} from '@tanstack/query-core' +import type { Accessor, ParentProps, Setter } from 'solid-js' + +function createDevtoolsState() { + const [selectedQueryHash, setSelectedQueryHash] = createSignal( + null, + ) + const [selectedMutationId, setSelectedMutationId] = createSignal< + number | null + >(null) + const [panelWidth, setPanelWidth] = createSignal(0) + const [offline, setOffline] = createSignal(false) + + const queryCacheMap = new Map< + (q: Accessor) => any, + { + setter: Setter + shouldUpdate: (event: QueryCacheNotifyEvent) => boolean + } + >() + const mutationCacheMap = new Map< + (q: Accessor) => any, + Setter + >() + return { + queryCacheMap, + mutationCacheMap, + selectedQueryHash, + setSelectedQueryHash, + selectedMutationId, + setSelectedMutationId, + panelWidth, + setPanelWidth, + offline, + setOffline, + } +} + +const DevtoolsStateContext = + createContext>() + +export function DevtoolsStateProvider(props: ParentProps) { + const state = createDevtoolsState() + const context = useQueryDevtoolsContext() + createEffect(() => { + const manager = context.onlineManager + state.setOffline(!manager.isOnline()) + const unsubscribe = manager.subscribe((online) => state.setOffline(!online)) + onCleanup(unsubscribe) + }) + return ( + + {props.children} + + ) +} + +export function useDevtoolsState() { + const state = useContext(DevtoolsStateContext) + if (!state) throw new Error('Missing DevtoolsStateProvider') + return state +} From de862d95d203b0f774383db6298840f3201efd3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Curley?= Date: Thu, 1 Oct 2026 15:52:42 +0100 Subject: [PATCH 2/5] refactor(query-devtools): encapsulate instance cache subscriptions --- packages/query-devtools/src/Devtools.tsx | 47 +++++++------------ .../query-devtools/src/DevtoolsComponent.tsx | 13 +++-- .../src/DevtoolsPanelComponent.tsx | 35 +++++++------- .../src/__tests__/Devtools.test.tsx | 24 ++++++---- .../createCacheSubscriptionRegistry.test.ts | 38 +++++++++++++++ .../src/contexts/DevtoolsStateContext.tsx | 20 +------- .../contexts/DevtoolsSubscriptionsContext.tsx | 39 +++++++++++++++ .../src/createCacheSubscriptionRegistry.ts | 40 ++++++++++++++++ 8 files changed, 178 insertions(+), 78 deletions(-) create mode 100644 packages/query-devtools/src/__tests__/createCacheSubscriptionRegistry.test.ts create mode 100644 packages/query-devtools/src/contexts/DevtoolsSubscriptionsContext.tsx create mode 100644 packages/query-devtools/src/createCacheSubscriptionRegistry.ts diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 40aa89cea92..ed92704253f 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -17,6 +17,7 @@ import { Key } from '@solid-primitives/keyed' import { createResizeObserver } from '@solid-primitives/resize-observer' import { DropdownMenu, RadioGroup } from '@kobalte/core' import { Portal } from 'solid-js/web' +import { useDevtoolsSubscriptions } from './contexts/DevtoolsSubscriptionsContext' import { useDevtoolsState } from './contexts/DevtoolsStateContext' import { tokens } from './theme' import { @@ -2578,7 +2579,7 @@ const MutationDetails = () => { } const setupQueryCacheSubscription = () => { - const { queryCacheMap } = useDevtoolsState() + const { queryCacheSubscriptions } = useDevtoolsSubscriptions() const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getQueryCache() @@ -2588,10 +2589,7 @@ const setupQueryCacheSubscription = () => { const cache = queryCache() const unsubscribe = cache.subscribe((event) => { batch(() => { - for (const [callback, value] of queryCacheMap.entries()) { - if (!value.shouldUpdate(event)) continue - value.setter(callback(() => cache)) - } + queryCacheSubscriptions.notify(() => cache, event) }) }) onCleanup(unsubscribe) @@ -2603,7 +2601,7 @@ const createSubscribeToQueryCacheBatcher = ( equalityCheck: boolean = true, shouldUpdate: (event: QueryCacheNotifyEvent) => boolean = () => true, ) => { - const { queryCacheMap } = useDevtoolsState() + const { queryCacheSubscriptions } = useDevtoolsSubscriptions() const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getQueryCache() @@ -2618,20 +2616,13 @@ const createSubscribeToQueryCacheBatcher = ( setValue(callback(queryCache)) }) - queryCacheMap.set(callback, { - setter: setValue, - shouldUpdate: shouldUpdate, - }) - - onCleanup(() => { - queryCacheMap.delete(callback) - }) + queryCacheSubscriptions.register(callback, setValue, shouldUpdate) return value } const setupMutationCacheSubscription = () => { - const { mutationCacheMap } = useDevtoolsState() + const { mutationCacheSubscriptions } = useDevtoolsSubscriptions() const mutationCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getMutationCache() @@ -2640,14 +2631,16 @@ const setupMutationCacheSubscription = () => { createEffect(() => { const cache = mutationCache() let active = true - const unsubscribe = cache.subscribe(() => { - for (const [callback, setter] of mutationCacheMap.entries()) { - queueMicrotask(() => { - if (active && mutationCacheMap.has(callback)) { - setter(callback(() => cache)) - } - }) - } + const unsubscribe = cache.subscribe((event) => { + mutationCacheSubscriptions.notify( + () => cache, + event, + (update) => { + queueMicrotask(() => { + if (active) update() + }) + }, + ) }) onCleanup(() => { active = false @@ -2660,7 +2653,7 @@ const createSubscribeToMutationCacheBatcher = ( callback: (queryCache: Accessor) => Exclude, equalityCheck: boolean = true, ) => { - const { mutationCacheMap } = useDevtoolsState() + const { mutationCacheSubscriptions } = useDevtoolsSubscriptions() const mutationCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getMutationCache() @@ -2675,11 +2668,7 @@ const createSubscribeToMutationCacheBatcher = ( setValue(callback(mutationCache)) }) - mutationCacheMap.set(callback, setValue) - - onCleanup(() => { - mutationCacheMap.delete(callback) - }) + mutationCacheSubscriptions.register(callback, setValue) return value } diff --git a/packages/query-devtools/src/DevtoolsComponent.tsx b/packages/query-devtools/src/DevtoolsComponent.tsx index ca9d4d367f2..194bbfd5d1c 100644 --- a/packages/query-devtools/src/DevtoolsComponent.tsx +++ b/packages/query-devtools/src/DevtoolsComponent.tsx @@ -1,5 +1,6 @@ import { createLocalStorage } from '@solid-primitives/storage' import { createMemo } from 'solid-js' +import { DevtoolsSubscriptionsProvider } from './contexts/DevtoolsSubscriptionsContext' import { DevtoolsStateProvider } from './contexts/DevtoolsStateContext' import { Devtools } from './Devtools' import { getPreferredColorScheme } from './utils' @@ -26,11 +27,13 @@ const DevtoolsComponent: DevtoolsComponentType = (props) => { return ( - - - - - + + + + + + + ) diff --git a/packages/query-devtools/src/DevtoolsPanelComponent.tsx b/packages/query-devtools/src/DevtoolsPanelComponent.tsx index ae1c59b736a..600fdf4096b 100644 --- a/packages/query-devtools/src/DevtoolsPanelComponent.tsx +++ b/packages/query-devtools/src/DevtoolsPanelComponent.tsx @@ -1,5 +1,6 @@ import { createLocalStorage } from '@solid-primitives/storage' import { createMemo } from 'solid-js' +import { DevtoolsSubscriptionsProvider } from './contexts/DevtoolsSubscriptionsContext' import { DevtoolsStateProvider } from './contexts/DevtoolsStateContext' import { ContentView, ParentPanel } from './Devtools' import { getPreferredColorScheme } from './utils' @@ -26,22 +27,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 3c9aee3d551..ac076ce4971 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -10,6 +10,7 @@ import { } from '@tanstack/query-core' import { fireEvent, render, within } from '@solidjs/testing-library' import { createLocalStorage } from '@solid-primitives/storage' +import { DevtoolsSubscriptionsProvider } from '../contexts/DevtoolsSubscriptionsContext' import DevtoolsComponent from '../DevtoolsComponent' import { DevtoolsStateProvider, @@ -142,15 +143,20 @@ describe('Devtools', () => { }} > - - - 'dark'}> - - - + + + + 'dark'}> + + + + ) diff --git a/packages/query-devtools/src/__tests__/createCacheSubscriptionRegistry.test.ts b/packages/query-devtools/src/__tests__/createCacheSubscriptionRegistry.test.ts new file mode 100644 index 00000000000..f87045684c2 --- /dev/null +++ b/packages/query-devtools/src/__tests__/createCacheSubscriptionRegistry.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from 'vitest' +import { createRoot, createSignal } from 'solid-js' +import { createCacheSubscriptionRegistry } from '../createCacheSubscriptionRegistry' + +it('skips disposed subscriptions without dropping other queued updates', () => { + const registry = createCacheSubscriptionRegistry() + const queued: Array<() => void> = [] + const [firstValue, setFirstValue] = createSignal(0) + const [secondValue, setSecondValue] = createSignal(0) + const firstDispose = createRoot((dispose) => { + registry.register((cache) => cache(), setFirstValue) + return dispose + }) + const secondDispose = createRoot((dispose) => { + registry.register((cache) => cache(), setSecondValue) + return dispose + }) + + try { + registry.notify( + () => 1, + undefined, + (update) => queued.push(update), + ) + firstDispose() + queued.forEach((update) => update()) + + expect(firstValue()).toBe(0) + expect(secondValue()).toBe(1) + + registry.notify(() => 2, undefined) + expect(firstValue()).toBe(0) + expect(secondValue()).toBe(2) + } finally { + firstDispose() + secondDispose() + } +}) diff --git a/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx index 00f0791c23b..68b0e9c573b 100644 --- a/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx +++ b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx @@ -6,12 +6,7 @@ import { useContext, } from 'solid-js' import { useQueryDevtoolsContext } from './QueryDevtoolsContext' -import type { - MutationCache, - QueryCache, - QueryCacheNotifyEvent, -} from '@tanstack/query-core' -import type { Accessor, ParentProps, Setter } from 'solid-js' +import type { ParentProps } from 'solid-js' function createDevtoolsState() { const [selectedQueryHash, setSelectedQueryHash] = createSignal( @@ -23,20 +18,7 @@ function createDevtoolsState() { const [panelWidth, setPanelWidth] = createSignal(0) const [offline, setOffline] = createSignal(false) - const queryCacheMap = new Map< - (q: Accessor) => any, - { - setter: Setter - shouldUpdate: (event: QueryCacheNotifyEvent) => boolean - } - >() - const mutationCacheMap = new Map< - (q: Accessor) => any, - Setter - >() return { - queryCacheMap, - mutationCacheMap, selectedQueryHash, setSelectedQueryHash, selectedMutationId, diff --git a/packages/query-devtools/src/contexts/DevtoolsSubscriptionsContext.tsx b/packages/query-devtools/src/contexts/DevtoolsSubscriptionsContext.tsx new file mode 100644 index 00000000000..392e22e1aa1 --- /dev/null +++ b/packages/query-devtools/src/contexts/DevtoolsSubscriptionsContext.tsx @@ -0,0 +1,39 @@ +import { createContext, useContext } from 'solid-js' +import { createCacheSubscriptionRegistry } from '../createCacheSubscriptionRegistry' +import type { + MutationCache, + MutationCacheNotifyEvent, + QueryCache, + QueryCacheNotifyEvent, +} from '@tanstack/query-core' +import type { ParentProps } from 'solid-js' + +function createDevtoolsSubscriptions() { + const queryCacheSubscriptions = createCacheSubscriptionRegistry< + QueryCache, + QueryCacheNotifyEvent + >() + const mutationCacheSubscriptions = createCacheSubscriptionRegistry< + MutationCache, + MutationCacheNotifyEvent + >() + return { queryCacheSubscriptions, mutationCacheSubscriptions } +} + +const DevtoolsSubscriptionsContext = + createContext>() + +export function DevtoolsSubscriptionsProvider(props: ParentProps) { + const subscriptions = createDevtoolsSubscriptions() + return ( + + {props.children} + + ) +} + +export function useDevtoolsSubscriptions() { + const subscriptions = useContext(DevtoolsSubscriptionsContext) + if (!subscriptions) throw new Error('Missing DevtoolsSubscriptionsProvider') + return subscriptions +} diff --git a/packages/query-devtools/src/createCacheSubscriptionRegistry.ts b/packages/query-devtools/src/createCacheSubscriptionRegistry.ts new file mode 100644 index 00000000000..8ad6b7b1248 --- /dev/null +++ b/packages/query-devtools/src/createCacheSubscriptionRegistry.ts @@ -0,0 +1,40 @@ +import { onCleanup } from 'solid-js' +import type { Accessor, Setter } from 'solid-js' + +export function createCacheSubscriptionRegistry() { + const subscriptions = new Map< + symbol, + { + shouldUpdate: (event: TEvent) => boolean + update: (cache: Accessor) => void + } + >() + + function register( + callback: (cache: Accessor) => Exclude, + setter: Setter, + shouldUpdate: (event: TEvent) => boolean = () => true, + ) { + const id = Symbol() + subscriptions.set(id, { + shouldUpdate, + update: (cache) => setter(callback(cache)), + }) + onCleanup(() => subscriptions.delete(id)) + } + + function notify( + cache: Accessor, + event: TEvent, + schedule: (update: () => void) => void = (update) => update(), + ) { + for (const [id, subscription] of subscriptions) { + if (!subscription.shouldUpdate(event)) continue + schedule(() => { + if (subscriptions.has(id)) subscription.update(cache) + }) + } + } + + return { register, notify } +} From 1b7b8fbc2d803367a097d708f23e7c6b83840cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Curley?= Date: Thu, 1 Oct 2026 16:41:57 +0100 Subject: [PATCH 3/5] refactor(query-devtools): inline provider state with explicit interfaces --- .../src/contexts/DevtoolsStateContext.tsx | 26 ++++++++++------- .../contexts/DevtoolsSubscriptionsContext.tsx | 28 +++++++++++++------ 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx index 68b0e9c573b..2613c5560f7 100644 --- a/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx +++ b/packages/query-devtools/src/contexts/DevtoolsStateContext.tsx @@ -6,9 +6,22 @@ import { useContext, } from 'solid-js' import { useQueryDevtoolsContext } from './QueryDevtoolsContext' -import type { ParentProps } from 'solid-js' +import type { Accessor, ParentProps, Setter } from 'solid-js' -function createDevtoolsState() { +interface DevtoolsState { + selectedQueryHash: Accessor + setSelectedQueryHash: Setter + selectedMutationId: Accessor + setSelectedMutationId: Setter + panelWidth: Accessor + setPanelWidth: Setter + offline: Accessor + setOffline: Setter +} + +const DevtoolsStateContext = createContext() + +export function DevtoolsStateProvider(props: ParentProps) { const [selectedQueryHash, setSelectedQueryHash] = createSignal( null, ) @@ -18,7 +31,7 @@ function createDevtoolsState() { const [panelWidth, setPanelWidth] = createSignal(0) const [offline, setOffline] = createSignal(false) - return { + const state: DevtoolsState = { selectedQueryHash, setSelectedQueryHash, selectedMutationId, @@ -28,13 +41,6 @@ function createDevtoolsState() { offline, setOffline, } -} - -const DevtoolsStateContext = - createContext>() - -export function DevtoolsStateProvider(props: ParentProps) { - const state = createDevtoolsState() const context = useQueryDevtoolsContext() createEffect(() => { const manager = context.onlineManager diff --git a/packages/query-devtools/src/contexts/DevtoolsSubscriptionsContext.tsx b/packages/query-devtools/src/contexts/DevtoolsSubscriptionsContext.tsx index 392e22e1aa1..d34243fae1f 100644 --- a/packages/query-devtools/src/contexts/DevtoolsSubscriptionsContext.tsx +++ b/packages/query-devtools/src/contexts/DevtoolsSubscriptionsContext.tsx @@ -8,7 +8,21 @@ import type { } from '@tanstack/query-core' import type { ParentProps } from 'solid-js' -function createDevtoolsSubscriptions() { +interface DevtoolsSubscriptions { + queryCacheSubscriptions: ReturnType< + typeof createCacheSubscriptionRegistry + > + mutationCacheSubscriptions: ReturnType< + typeof createCacheSubscriptionRegistry< + MutationCache, + MutationCacheNotifyEvent + > + > +} + +const DevtoolsSubscriptionsContext = createContext() + +export function DevtoolsSubscriptionsProvider(props: ParentProps) { const queryCacheSubscriptions = createCacheSubscriptionRegistry< QueryCache, QueryCacheNotifyEvent @@ -17,14 +31,10 @@ function createDevtoolsSubscriptions() { MutationCache, MutationCacheNotifyEvent >() - return { queryCacheSubscriptions, mutationCacheSubscriptions } -} - -const DevtoolsSubscriptionsContext = - createContext>() - -export function DevtoolsSubscriptionsProvider(props: ParentProps) { - const subscriptions = createDevtoolsSubscriptions() + const subscriptions: DevtoolsSubscriptions = { + queryCacheSubscriptions, + mutationCacheSubscriptions, + } return ( {props.children} From 8c4354c2b783d8233dc6ac4577bcf33de95dbed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Curley?= Date: Thu, 1 Oct 2026 17:16:47 +0100 Subject: [PATCH 4/5] refactor(query-devtools): separate contexts and providers --- packages/query-devtools/src/Devtools.tsx | 12 ++-- .../query-devtools/src/DevtoolsComponent.tsx | 24 ++++--- .../src/DevtoolsPanelComponent.tsx | 43 +++++++------ .../src/__tests__/Devtools.test.tsx | 39 ++++++------ .../src/contexts/DevtoolsOfflineContext.ts | 10 +++ .../src/contexts/DevtoolsStateContext.ts | 10 +++ .../src/contexts/DevtoolsStateContext.tsx | 62 ------------------- .../contexts/DevtoolsSubscriptionsContext.ts | 11 ++++ packages/query-devtools/src/contexts/types.ts | 38 +++++++++++- .../src/providers/DevtoolsOfflineProvider.tsx | 31 ++++++++++ .../src/providers/DevtoolsStateProvider.tsx | 28 +++++++++ .../DevtoolsSubscriptionsProvider.tsx} | 23 +------ 12 files changed, 191 insertions(+), 140 deletions(-) create mode 100644 packages/query-devtools/src/contexts/DevtoolsOfflineContext.ts create mode 100644 packages/query-devtools/src/contexts/DevtoolsStateContext.ts delete mode 100644 packages/query-devtools/src/contexts/DevtoolsStateContext.tsx create mode 100644 packages/query-devtools/src/contexts/DevtoolsSubscriptionsContext.ts create mode 100644 packages/query-devtools/src/providers/DevtoolsOfflineProvider.tsx create mode 100644 packages/query-devtools/src/providers/DevtoolsStateProvider.tsx rename packages/query-devtools/src/{contexts/DevtoolsSubscriptionsContext.tsx => providers/DevtoolsSubscriptionsProvider.tsx} (56%) diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index ed92704253f..76e82a0b108 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -17,6 +17,7 @@ import { Key } from '@solid-primitives/keyed' import { createResizeObserver } from '@solid-primitives/resize-observer' import { DropdownMenu, RadioGroup } from '@kobalte/core' import { Portal } from 'solid-js/web' +import { useDevtoolsOffline } from './contexts/DevtoolsOfflineContext' import { useDevtoolsSubscriptions } from './contexts/DevtoolsSubscriptionsContext' import { useDevtoolsState } from './contexts/DevtoolsStateContext' import { tokens } from './theme' @@ -668,9 +669,10 @@ export const ContentView: Component = (props) => { selectedMutationId, setSelectedMutationId, panelWidth, - offline, } = useDevtoolsState() + const { offline, toggleOffline } = useDevtoolsOffline() + setupQueryCacheSubscription() setupMutationCacheSubscription() let containerRef!: HTMLDivElement @@ -705,10 +707,6 @@ export const ContentView: Component = (props) => { () => mutationSortFns[mutationSort() as string], ) - const onlineManager = createMemo( - () => useQueryDevtoolsContext().onlineManager, - ) - const query_cache = createMemo(() => { return useQueryDevtoolsContext().client.getQueryCache() }) @@ -1030,9 +1028,7 @@ export const ContentView: Component = (props) => {