Skip to content
5 changes: 5 additions & 0 deletions .changeset/true-loops-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-devtools': patch
---

Multiple Devtool instances sharing same state causing isolation issues
95 changes: 66 additions & 29 deletions packages/query-devtools/src/Devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ import {
XCircle,
} from './icons'
import Explorer from './Explorer'
import { usePiPWindow, useQueryDevtoolsContext, useTheme } from './contexts'
import {
DevtoolsStateContext,
useDevtoolsState,
usePiPWindow,
useQueryDevtoolsContext,
useTheme,
} from './contexts'
import {
BUTTON_POSITION,
DEFAULT_HEIGHT,
Expand All @@ -68,6 +74,8 @@ import {
import type {
DevtoolsErrorType,
DevtoolsPosition,
MutationCacheMap,
QueryCacheMap,
QueryDevtoolsProps,
} from './contexts'
import type {
Expand All @@ -78,7 +86,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<string>
Expand All @@ -98,20 +106,22 @@ interface QueryStatusProps {
count: number
}

const [selectedQueryHash, setSelectedQueryHash] = createSignal<string | null>(
null,
)
const [selectedMutationId, setSelectedMutationId] = createSignal<number | null>(
null,
)
const [panelWidth, setPanelWidth] = createSignal(0)
const [offline, setOffline] = createSignal(false)

export type DevtoolsComponentType = Component<QueryDevtoolsProps> & {
shadowDOMTarget?: ShadowRoot
}

export const Devtools: Component<DevtoolsPanelProps> = (props) => {
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 queryCacheMap: QueryCacheMap = new Map()
const mutationCacheMap: MutationCacheMap = new Map()

const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
Expand Down Expand Up @@ -192,7 +202,20 @@ export const Devtools: Component<DevtoolsPanelProps> = (props) => {
)

return (
<>
<DevtoolsStateContext.Provider
value={{
selectedQueryHash,
setSelectedQueryHash,
selectedMutationId,
setSelectedMutationId,
panelWidth,
setPanelWidth,
offline,
setOffline,
queryCacheMap,
mutationCacheMap,
}}
>
<Show when={pip().pipWindow && pip_open() == 'true'}>
<Portal mount={pip().pipWindow?.document.body}>
<PiPPanel>
Expand Down Expand Up @@ -274,7 +297,7 @@ export const Devtools: Component<DevtoolsPanelProps> = (props) => {
</Show>
</TransitionGroup>
</div>
</>
</DevtoolsStateContext.Provider>
)
}

Expand All @@ -283,6 +306,7 @@ const PiPPanel: Component<{
}> = (props) => {
const pip = usePiPWindow()
const theme = useTheme()
const { panelWidth, setPanelWidth } = useDevtoolsState()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
Expand Down Expand Up @@ -352,6 +376,7 @@ export const ParentPanel: Component<{
children: JSX.Element
}> = (props) => {
const theme = useTheme()
const { panelWidth, setPanelWidth } = useDevtoolsState()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
: goober.css
Expand Down Expand Up @@ -408,6 +433,7 @@ export const ParentPanel: Component<{
}

const DraggablePanel: Component<DevtoolsPanelProps> = (props) => {
const { setSelectedQueryHash, setPanelWidth, panelWidth } = useDevtoolsState()
const theme = useTheme()
const css = useQueryDevtoolsContext().shadowDOMTarget
? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget })
Expand Down Expand Up @@ -689,6 +715,15 @@ export const ContentView: Component<ContentViewProps> = (props) => {
'queries',
)

const {
selectedQueryHash,
offline,
setSelectedQueryHash,
selectedMutationId,
setSelectedMutationId,
panelWidth,
} = useDevtoolsState()

const sort = createMemo(() => props.localStore.sort || DEFAULT_SORT_FN_NAME)
const sortOrder = createMemo(
() => Number(props.localStore.sortOrder) || DEFAULT_SORT_ORDER,
Expand Down Expand Up @@ -1382,6 +1417,7 @@ const QueryRow: Component<{ query: Query }> = (props) => {

const { colors, alpha } = tokens
const t = (light: string, dark: string) => (theme() === 'dark' ? dark : light)
const { selectedQueryHash, setSelectedQueryHash } = useDevtoolsState()

const queryState = createSubscribeToQueryCacheBatcher(
(queryCache) =>
Expand Down Expand Up @@ -1512,6 +1548,8 @@ const MutationRow: Component<{ mutation: Mutation }> = (props) => {
const { colors, alpha } = tokens
const t = (light: string, dark: string) => (theme() === 'dark' ? dark : light)

const { selectedMutationId, setSelectedMutationId } = useDevtoolsState()

const mutationState = createSubscribeToMutationCacheBatcher(
(mutationCache) => {
const mutations = mutationCache().getAll()
Expand Down Expand Up @@ -1758,6 +1796,8 @@ const QueryStatus: Component<QueryStatusProps> = (props) => {
const [mouseOver, setMouseOver] = createSignal(false)
const [focused, setFocused] = createSignal(false)

const { selectedQueryHash, panelWidth } = useDevtoolsState()

const showLabel = createMemo(() => {
if (selectedQueryHash()) {
if (panelWidth() < firstBreakpoint && panelWidth() > secondBreakpoint) {
Expand Down Expand Up @@ -1874,6 +1914,8 @@ const QueryDetails = () => {
const [dataMode, setDataMode] = createSignal<'view' | 'edit'>('view')
const [dataEditError, setDataEditError] = createSignal<boolean>(false)

const { selectedQueryHash, setSelectedQueryHash } = useDevtoolsState()

const errorTypes = createMemo(() => {
return useQueryDevtoolsContext().errorTypes || []
})
Expand Down Expand Up @@ -2401,6 +2443,8 @@ const MutationDetails = () => {
const { colors } = tokens
const t = (light: string, dark: string) => (theme() === 'dark' ? dark : light)

const { selectedMutationId } = useDevtoolsState()

const isPaused = createSubscribeToMutationCacheBatcher((mutationCache) => {
const mutations = mutationCache().getAll()
const mutation = mutations.find(
Expand Down Expand Up @@ -2577,15 +2621,8 @@ const MutationDetails = () => {
)
}

const queryCacheMap = new Map<
(q: Accessor<QueryCache>) => any,
{
setter: Setter<any>
shouldUpdate: (event: QueryCacheNotifyEvent) => boolean
}
>()

const setupQueryCacheSubscription = () => {
const { queryCacheMap } = useDevtoolsState()
const queryCache = createMemo(() => {
const client = useQueryDevtoolsContext().client
return client.getQueryCache()
Expand Down Expand Up @@ -2613,6 +2650,7 @@ const createSubscribeToQueryCacheBatcher = <T,>(
equalityCheck: boolean = true,
shouldUpdate: (event: QueryCacheNotifyEvent) => boolean = () => true,
) => {
const { queryCacheMap } = useDevtoolsState()
const queryCache = createMemo(() => {
const client = useQueryDevtoolsContext().client
return client.getQueryCache()
Expand All @@ -2639,21 +2677,17 @@ const createSubscribeToQueryCacheBatcher = <T,>(
return value
}

const mutationCacheMap = new Map<
(q: Accessor<MutationCache>) => any,
Setter<any>
>()

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()) {
for (const [callback, value] of mutationCacheMap.entries()) {
queueMicrotask(() => {
setter(callback(mutationCache))
value.setter(callback(mutationCache))
})
}
})
Expand All @@ -2670,6 +2704,7 @@ const createSubscribeToMutationCacheBatcher = <T,>(
callback: (queryCache: Accessor<MutationCache>) => Exclude<T, Function>,
equalityCheck: boolean = true,
) => {
const { mutationCacheMap } = useDevtoolsState()
const mutationCache = createMemo(() => {
const client = useQueryDevtoolsContext().client
return client.getMutationCache()
Expand All @@ -2684,7 +2719,9 @@ const createSubscribeToMutationCacheBatcher = <T,>(
setValue(callback(mutationCache))
})

mutationCacheMap.set(callback, setValue)
mutationCacheMap.set(callback, {
setter: setValue,
})

onCleanup(() => {
mutationCacheMap.delete(callback)
Expand Down
67 changes: 49 additions & 18 deletions packages/query-devtools/src/DevtoolsPanelComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import { createLocalStorage } from '@solid-primitives/storage'
import { createMemo } from 'solid-js'
import { createMemo, createSignal } from 'solid-js'
import { ContentView, ParentPanel } from './Devtools'
import { getPreferredColorScheme } from './utils'
import { THEME_PREFERENCE } from './constants'
import { PiPProvider, QueryDevtoolsContext, ThemeContext } from './contexts'
import type { Theme } from './contexts'
import {
DevtoolsStateContext,
PiPProvider,
QueryDevtoolsContext,
ThemeContext,
} from './contexts'
import type { MutationCacheMap, QueryCacheMap, Theme } from './contexts'
import type { DevtoolsComponentType } from './Devtools'

const DevtoolsPanelComponent: DevtoolsComponentType = (props) => {
const [localStore, setLocalStore] = createLocalStorage({
prefix: 'TanstackQueryDevtools',
})

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 queryCacheMap: QueryCacheMap = new Map()
const mutationCacheMap: MutationCacheMap = new Map()

const colorScheme = getPreferredColorScheme()

const theme = createMemo(() => {
Expand All @@ -24,22 +40,37 @@ const DevtoolsPanelComponent: DevtoolsComponentType = (props) => {

return (
<QueryDevtoolsContext.Provider value={props}>
<PiPProvider
disabled
localStore={localStore}
setLocalStore={setLocalStore}
<DevtoolsStateContext.Provider
value={{
selectedQueryHash,
setSelectedQueryHash,
selectedMutationId,
setSelectedMutationId,
panelWidth,
setPanelWidth,
offline,
setOffline,
queryCacheMap,
mutationCacheMap,
}}
>
<ThemeContext.Provider value={theme}>
<ParentPanel>
<ContentView
localStore={localStore}
setLocalStore={setLocalStore}
onClose={props.onClose}
showPanelViewOnly
/>
</ParentPanel>
</ThemeContext.Provider>
</PiPProvider>
<PiPProvider
disabled
localStore={localStore}
setLocalStore={setLocalStore}
>
<ThemeContext.Provider value={theme}>
<ParentPanel>
<ContentView
localStore={localStore}
setLocalStore={setLocalStore}
onClose={props.onClose}
showPanelViewOnly
/>
</ParentPanel>
</ThemeContext.Provider>
</PiPProvider>
</DevtoolsStateContext.Provider>
</QueryDevtoolsContext.Provider>
)
}
Expand Down
45 changes: 45 additions & 0 deletions packages/query-devtools/src/contexts/DevtoolsStateContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createContext, useContext } from 'solid-js'
import type { Accessor, Setter } from 'solid-js'
import type {
MutationCache,
QueryCache,
QueryCacheNotifyEvent,
} from '@tanstack/query-core'

type QueryCacheMapValue = {
setter: Setter<any>
shouldUpdate: (event: QueryCacheNotifyEvent) => boolean
}

type MutationCacheMapValue = {
setter: Setter<any>
}

export type QueryCacheMap = Map<
(q: Accessor<QueryCache>) => any,
QueryCacheMapValue
>

export type MutationCacheMap = Map<
(q: Accessor<MutationCache>) => any,
MutationCacheMapValue
>

interface DevtoolsState {
selectedQueryHash: Accessor<string | null>
setSelectedQueryHash: Setter<string | null>
selectedMutationId: Accessor<number | null>
setSelectedMutationId: Setter<number | null>
panelWidth: Accessor<number>
setPanelWidth: Setter<number>
offline: Accessor<boolean>
setOffline: Setter<boolean>
queryCacheMap: QueryCacheMap
mutationCacheMap: MutationCacheMap
}

export const DevtoolsStateContext = createContext<DevtoolsState>()

export function useDevtoolsState() {
return useContext(DevtoolsStateContext)!
}
Comment on lines +43 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add runtime guard for missing context provider.

The non-null assertion (!) assumes the context is always provided, but calling this hook outside a DevtoolsStateContext.Provider will cause a runtime error when the undefined value is accessed.

🔎 Proposed fix with runtime guard
 export function useDevtoolsState() {
-  return useContext(DevtoolsStateContext)!
+  const context = useContext(DevtoolsStateContext)
+  if (!context) {
+    throw new Error('useDevtoolsState must be used within a DevtoolsStateContext.Provider')
+  }
+  return context
 }

This provides a clear error message during development rather than cryptic undefined access errors at runtime.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function useDevtoolsState() {
return useContext(DevtoolsStateContext)!
}
export function useDevtoolsState() {
const context = useContext(DevtoolsStateContext)
if (!context) {
throw new Error('useDevtoolsState must be used within a DevtoolsStateContext.Provider')
}
return context
}
🤖 Prompt for AI Agents
In packages/query-devtools/src/contexts/DevtoolsStateContext.ts around lines 43
to 45, the hook returns the context with a non-null assertion which will throw a
confusing error if used outside a Provider; add a runtime guard that checks if
the context value is undefined and throw a clear, descriptive error (e.g.
"useDevtoolsState must be used within a DevtoolsStateContext.Provider") so
callers get an explicit message during development; implement the check before
returning the value and keep types intact.

1 change: 1 addition & 0 deletions packages/query-devtools/src/contexts/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './PiPContext'
export * from './QueryDevtoolsContext'
export * from './ThemeContext'
export * from './DevtoolsStateContext'
Loading