Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -79,7 +87,7 @@ import type {
QueryState,
} 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 @@ -99,20 +107,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 @@ -193,7 +203,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 @@ -275,7 +298,7 @@ export const Devtools: Component<DevtoolsPanelProps> = (props) => {
</Show>
</TransitionGroup>
</div>
</>
</DevtoolsStateContext.Provider>
)
}

Expand All @@ -284,6 +307,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 @@ -353,6 +377,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 @@ -409,6 +434,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 @@ -690,6 +716,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 @@ -1383,6 +1418,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 @@ -1513,6 +1549,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 @@ -1759,6 +1797,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 @@ -1875,6 +1915,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 @@ -2402,6 +2444,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 @@ -2578,15 +2622,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 @@ -2614,6 +2651,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 @@ -2640,21 +2678,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 @@ -2671,6 +2705,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 @@ -2685,7 +2720,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