diff --git a/.changeset/heavy-carpets-divide.md b/.changeset/heavy-carpets-divide.md new file mode 100644 index 00000000000..90b7e578103 --- /dev/null +++ b/.changeset/heavy-carpets-divide.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-devtools": patch +--- + +Fix devtools UI state (selected query/mutation, panel width, offline mock toggle, and query/mutation cache subscriptions) leaking between independently mounted devtools instances. Selecting a query, resizing a panel, or mocking offline behavior in one devtools instance no longer affects another instance mounted on a different `QueryClient`. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index daff934932f..9a787bd509c 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -51,7 +51,13 @@ import { XCircle, } from './icons' import Explorer from './Explorer' -import { usePiPWindow, useQueryDevtoolsContext, useTheme } from './contexts' +import { + DevtoolsInstanceProvider, + useDevtoolsInstanceContext, + usePiPWindow, + useQueryDevtoolsContext, + useTheme, +} from './contexts' import { BUTTON_POSITION, DEFAULT_HEIGHT, @@ -78,7 +84,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,20 +104,20 @@ 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) => { + return ( + + + + ) +} + +const DevtoolsImpl: Component = (props) => { + const { setOffline } = useDevtoolsInstanceContext() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -281,6 +287,7 @@ export const Devtools: Component = (props) => { const PiPPanel: Component<{ children: JSX.Element }> = (props) => { + const { panelWidth, setPanelWidth } = useDevtoolsInstanceContext() const pip = usePiPWindow() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget @@ -351,6 +358,7 @@ const PiPPanel: Component<{ export const ParentPanel: Component<{ children: JSX.Element }> = (props) => { + const { panelWidth, setPanelWidth } = useDevtoolsInstanceContext() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -408,6 +416,8 @@ export const ParentPanel: Component<{ } const DraggablePanel: Component = (props) => { + const { panelWidth, setPanelWidth, setSelectedQueryHash } = + useDevtoolsInstanceContext() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -672,6 +682,14 @@ const DraggablePanel: Component = (props) => { } export const ContentView: Component = (props) => { + const { + panelWidth, + offline, + selectedQueryHash, + selectedMutationId, + setSelectedQueryHash, + setSelectedMutationId, + } = useDevtoolsInstanceContext() setupQueryCacheSubscription() setupMutationCacheSubscription() let containerRef!: HTMLDivElement @@ -1372,6 +1390,8 @@ export const ContentView: Component = (props) => { } const QueryRow: Component<{ query: Query }> = (props) => { + const { selectedQueryHash, setSelectedQueryHash } = + useDevtoolsInstanceContext() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -1481,6 +1501,8 @@ const QueryRow: Component<{ query: Query }> = (props) => { } const MutationRow: Component<{ mutation: Mutation }> = (props) => { + const { selectedMutationId, setSelectedMutationId } = + useDevtoolsInstanceContext() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -1722,6 +1744,7 @@ const MutationStatusCount: Component = () => { } const QueryStatus: Component = (props) => { + const { panelWidth, selectedQueryHash } = useDevtoolsInstanceContext() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -1837,6 +1860,8 @@ const QueryStatus: Component = (props) => { } const QueryDetails = () => { + const { selectedQueryHash, setSelectedQueryHash } = + useDevtoolsInstanceContext() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -2382,6 +2407,7 @@ const QueryDetails = () => { } const MutationDetails = () => { + const { selectedMutationId } = useDevtoolsInstanceContext() const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) @@ -2569,15 +2595,8 @@ const MutationDetails = () => { ) } -const queryCacheMap = new Map< - (q: Accessor) => any, - { - setter: Setter - shouldUpdate: (event: QueryCacheNotifyEvent) => boolean - } ->() - const setupQueryCacheSubscription = () => { + const { queryCacheMap } = useDevtoolsInstanceContext() const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getQueryCache() @@ -2605,6 +2624,7 @@ const createSubscribeToQueryCacheBatcher = ( equalityCheck: boolean = true, shouldUpdate: (event: QueryCacheNotifyEvent) => boolean = () => true, ) => { + const { queryCacheMap } = useDevtoolsInstanceContext() const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getQueryCache() @@ -2631,12 +2651,8 @@ const createSubscribeToQueryCacheBatcher = ( return value } -const mutationCacheMap = new Map< - (q: Accessor) => any, - Setter ->() - const setupMutationCacheSubscription = () => { + const { mutationCacheMap } = useDevtoolsInstanceContext() const mutationCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getMutationCache() @@ -2662,6 +2678,7 @@ const createSubscribeToMutationCacheBatcher = ( callback: (queryCache: Accessor) => Exclude, equalityCheck: boolean = true, ) => { + const { mutationCacheMap } = useDevtoolsInstanceContext() const mutationCache = createMemo(() => { const client = useQueryDevtoolsContext().client return client.getMutationCache() diff --git a/packages/query-devtools/src/DevtoolsPanelComponent.tsx b/packages/query-devtools/src/DevtoolsPanelComponent.tsx index cae641b44ad..c06459cd49f 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 { + DevtoolsInstanceProvider, + PiPProvider, + QueryDevtoolsContext, + ThemeContext, +} from './contexts' import type { Theme } from './contexts' import type { DevtoolsComponentType } from './Devtools' @@ -30,14 +35,16 @@ const DevtoolsPanelComponent: DevtoolsComponentType = (props) => { setLocalStore={setLocalStore} > - - - + + + + + diff --git a/packages/query-devtools/src/__tests__/DevtoolsInstanceIsolation.test.tsx b/packages/query-devtools/src/__tests__/DevtoolsInstanceIsolation.test.tsx new file mode 100644 index 00000000000..a433be82245 --- /dev/null +++ b/packages/query-devtools/src/__tests__/DevtoolsInstanceIsolation.test.tsx @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, 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 type { QueryDevtoolsProps } from '../contexts' + +// Same stubs as `Devtools.test.tsx` — see comments there for rationale. +vi.mock('solid-transition-group', () => ({ + TransitionGroup: (props: { children: unknown }) => props.children, +})) + +vi.mock('goober', () => { + let counter = 0 + const css = Object.assign(() => `tsqd-${++counter}`, { + bind: () => css, + }) + return { css, glob: () => {}, setup: () => {} } +}) + +describe('Devtools instance isolation', () => { + let previousRootFontSize = '' + + beforeEach(() => { + previousRootFontSize = document.documentElement.style.fontSize + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + }) + 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 { + callback: ResizeObserverCallback + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + observe = vi.fn((target: Element) => { + this.callback( + [ + { + target, + contentRect: { width: 1000, height: 500 } as DOMRectReadOnly, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ) + }) + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + document.documentElement.style.fontSize = '16px' + }) + + afterEach(() => { + vi.unstubAllGlobals() + onlineManager.setOnline(true) + document.documentElement.style.fontSize = previousRootFontSize + }) + + // `@tanstack/query-core` only exports the default `onlineManager` + // singleton, not the `OnlineManager` class, so a minimal stand-in with the + // same shape is used to give each instance its own online manager. + function createFakeOnlineManager(): typeof onlineManager { + let online = true + const listeners = new Set<(online: boolean) => void>() + return { + subscribe: (listener: (online: boolean) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + setOnline: (next: boolean) => { + online = next + listeners.forEach((listener) => listener(online)) + }, + isOnline: () => online, + } as typeof onlineManager + } + + function renderDevtools( + queryClient: QueryClient, + overrides: Partial = {}, + ) { + return render(() => { + const [localStore, setLocalStore] = createLocalStorage({ + prefix: 'TanstackQueryDevtools', + }) + return ( + + + 'dark'}> + + + + + ) + }) + } + + it('should not highlight the matching row in instance B when the same query key is selected in instance A', () => { + // Both clients cache a query under the same key, so the two panels render + // a row with the same `queryHash` — this is what makes a shared + // `selectedQueryHash` signal visibly leak between instances. + const clientA = new QueryClient() + const clientB = new QueryClient() + clientA.setQueryData(['shared-key'], { from: 'a' }) + clientB.setQueryData(['shared-key'], { from: 'b' }) + + const a = renderDevtools(clientA, { initialIsOpen: true }) + const b = renderDevtools(clientB, { initialIsOpen: true }) + + const bRow = b.getByLabelText(/Query key \["shared-key"\]/) + const classNameBefore = bRow.className + + fireEvent.click(a.getByLabelText(/Query key \["shared-key"\]/)) + + expect(a.getByText('Query Details')).toBeInTheDocument() + // Selecting the row in A must not add the "selected" style to B's row + // for the same query key. + expect(bRow.className).toBe(classNameBefore) + expect(b.queryByText('Query Details')).not.toBeInTheDocument() + + clientA.clear() + clientB.clear() + }) + + it('should not clear the selected query of instance B when instance A is dragged below its minimum height', () => { + const clientA = new QueryClient() + const clientB = new QueryClient() + clientA.setQueryData(['a-query'], { from: 'a' }) + clientB.setQueryData(['b-query'], { from: 'b' }) + + const a = renderDevtools(clientA, { position: 'bottom', initialIsOpen: true }) + const b = renderDevtools(clientB, { position: 'bottom', initialIsOpen: true }) + + fireEvent.click(b.getByLabelText(/Query key \["b-query"\]/)) + expect(b.getByText('Query Details')).toBeInTheDocument() + + const handle = a.getByLabelText('Resize devtools panel') + const panel = handle.parentElement + expect(panel).toBeInstanceOf(HTMLElement) + vi.spyOn(panel!, 'getBoundingClientRect').mockReturnValue({ + height: 60, + width: 0, + x: 0, + y: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + }) + + // Shrinking instance A's panel below the minimum height clears A's own + // selection — it must not clear instance B's independently selected query. + fireEvent.mouseDown(handle, { clientX: 0, clientY: 100 }) + fireEvent(document, new MouseEvent('mousemove', { clientX: 0, clientY: 200 })) + fireEvent(document, new MouseEvent('mouseup')) + + expect(b.getByText('Query Details')).toBeInTheDocument() + + clientA.clear() + clientB.clear() + }) + + it('should not toggle the offline mock button of instance B when instance A mocks offline', () => { + const clientA = new QueryClient() + const clientB = new QueryClient() + // Each instance gets its own `onlineManager`, matching how a real app + // would isolate two independent clients — the devtools' own "mock + // offline" UI state must follow the same isolation. + const a = renderDevtools(clientA, { + initialIsOpen: true, + onlineManager: createFakeOnlineManager(), + }) + const b = renderDevtools(clientB, { + initialIsOpen: true, + onlineManager: createFakeOnlineManager(), + }) + + fireEvent.click(a.getByLabelText('Mock offline behavior')) + + expect( + a.getByLabelText('Unset offline mocking behavior'), + ).toBeInTheDocument() + // Instance B never had its offline mock toggled, so its button must still + // read the "not mocked" label. + expect(b.getByLabelText('Mock offline behavior')).toBeInTheDocument() + + clientA.clear() + clientB.clear() + }) +}) diff --git a/packages/query-devtools/src/contexts/DevtoolsInstanceContext.tsx b/packages/query-devtools/src/contexts/DevtoolsInstanceContext.tsx new file mode 100644 index 00000000000..55594ee890f --- /dev/null +++ b/packages/query-devtools/src/contexts/DevtoolsInstanceContext.tsx @@ -0,0 +1,70 @@ +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 QueryCacheSubscriber { + setter: Setter + shouldUpdate: (event: QueryCacheNotifyEvent) => boolean +} + +export interface DevtoolsInstanceContextType { + selectedQueryHash: Accessor + setSelectedQueryHash: Setter + selectedMutationId: Accessor + setSelectedMutationId: Setter + panelWidth: Accessor + setPanelWidth: Setter + offline: Accessor + setOffline: Setter + queryCacheMap: Map<(q: Accessor) => any, QueryCacheSubscriber> + mutationCacheMap: Map<(q: Accessor) => any, Setter> +} + +const DevtoolsInstanceContext = createContext() + +/** + * Every rendered devtools root (the floating panel and the standalone panel) + * mounts one of these, so that UI state which used to live in module-level + * signals — selection, panel width, the offline mock toggle, and the query + * / mutation cache subscription maps — doesn't leak between independently + * mounted devtools instances. + */ +export const DevtoolsInstanceProvider = (props: { children: JSX.Element }) => { + const [selectedQueryHash, setSelectedQueryHash] = createSignal< + string | null + >(null) + const [selectedMutationId, setSelectedMutationId] = createSignal< + number | null + >(null) + const [panelWidth, setPanelWidth] = createSignal(0) + const [offline, setOffline] = createSignal(false) + + const value: DevtoolsInstanceContextType = { + selectedQueryHash, + setSelectedQueryHash, + selectedMutationId, + setSelectedMutationId, + panelWidth, + setPanelWidth, + offline, + setOffline, + queryCacheMap: new Map(), + mutationCacheMap: new Map(), + } + + return ( + + {props.children} + + ) +} + +export function useDevtoolsInstanceContext() { + const context = useContext(DevtoolsInstanceContext) + if (!context) { + throw new Error( + 'useDevtoolsInstanceContext must be used within a DevtoolsInstanceProvider', + ) + } + return context +} diff --git a/packages/query-devtools/src/contexts/index.ts b/packages/query-devtools/src/contexts/index.ts index b9329a3cd9c..70978e6be0d 100644 --- a/packages/query-devtools/src/contexts/index.ts +++ b/packages/query-devtools/src/contexts/index.ts @@ -1,3 +1,4 @@ +export * from './DevtoolsInstanceContext' export * from './PiPContext' export * from './QueryDevtoolsContext' export * from './ThemeContext'