Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9eb12e3
feat(react-query): add 'QueryStrictMode' to report missing and unused…
DogPawHat Oct 17, 2026
2e5a80f
chore(changeset): add a minor changeset for 'QueryStrictMode'
DogPawHat Oct 17, 2026
996a2c5
feat(react-query): report missing prefetches of non-suspense queries …
DogPawHat Oct 17, 2026
885103f
test(react-query): add failing tests for unused prefetches that 'Quer…
DogPawHat Oct 19, 2026
04692f2
test(react-query): rewrite 'QueryStrictMode' tests for a single 'hand…
DogPawHat Oct 19, 2026
b8b7a0d
feat(react-query): replace 'logger' and 'throwOn...' with a single 'h…
DogPawHat Oct 19, 2026
9355ed6
fix(react-query): track prefetches from the first render of 'QueryStr…
DogPawHat Oct 19, 2026
c3929b4
docs(react-query): document what a throwing 'QueryStrictModeHandler' …
DogPawHat Oct 19, 2026
85616c2
test(react-query): remove type tests for 'QueryStrictMode' props that…
DogPawHat Oct 19, 2026
61401e0
test(react-query): make 'QueryStrictMode' tests cover StrictMode remo…
DogPawHat Oct 19, 2026
560ea28
test(react-query): add failing tests for sibling 'QueryStrictMode's t…
DogPawHat Oct 19, 2026
6001593
fix(react-query): share used and checked prefetches between 'QueryStr…
DogPawHat Oct 19, 2026
361b5d4
test(react-query): remove redundant 'QueryStrictMode' tests
DogPawHat Oct 19, 2026
12c66bb
perf(react-query): keep 'QueryStrictMode' checks out of production bu…
DogPawHat Oct 19, 2026
ca27d4c
feat(react-query): throw a 'QueryStrictModeError' from the default 'Q…
DogPawHat Oct 20, 2026
2e21c69
docs(react-query): remove blank line before '@example' in 'QueryStric…
DogPawHat Oct 20, 2026
f71dd47
feat(react-query): log unused prefetches from the default 'QueryStric…
DogPawHat Oct 21, 2026
9df7a17
feat(react-query): split the 'QueryStrictMode' handler into 'handleMi…
DogPawHat Oct 21, 2026
eeca709
perf(react-query): move development-only 'QueryStrictMode' code into …
DogPawHat Oct 21, 2026
54db1ec
feat(react-query): inherit 'handleMissingPrefetch' from the parent 'Q…
DogPawHat Oct 21, 2026
3355d74
refactor(react-query): move the 'QueryStrictMode' context into its ow…
DogPawHat Oct 21, 2026
69a33b3
docs(react-query): shorten the 'QueryStrictMode' JSDoc
DogPawHat Oct 21, 2026
c242694
test(react-query): follow main's test conventions in 'QueryStrictMode…
DogPawHat Oct 21, 2026
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/query-strict-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-query': minor
---

feat(react-query): add `QueryStrictMode`, which reports missing prefetches (a query hook starting the initial fetch of its query; `event.suspense` tells suspense hooks apart) and unused prefetches (a prefetched query nobody reads within `unusedPrefetchTimeout`) in development. Missing prefetches are passed to `handleMissingPrefetch`, which throws a `QueryStrictModeMissingPrefetchError` to an error boundary by default. Unused prefetches are passed to `handleUnusedPrefetch`, which logs them with `console.warn` by default. Pass your own handlers to filter problems, or to log or throw them instead; `QueryStrictModeUnusedPrefetchError` is available for throwing unused prefetches, and both errors extend `QueryStrictModeError`. In production it only renders `children`.
45 changes: 45 additions & 0 deletions packages/react-query/src/QueryStrictMode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client'
import { DevQueryStrictMode } from './QueryStrictModeDev'
import type { QueryStrictModeMissingPrefetchError } from './QueryStrictModeError'
import type {
QueryStrictModeMissingPrefetchEvent,
QueryStrictModeProps,
QueryStrictModeUnusedPrefetchEvent,
} from './QueryStrictModeTypes'

/**
* Reports prefetching issues in development. See
* {@link QueryStrictModeMissingPrefetchEvent} and {@link QueryStrictModeUnusedPrefetchEvent}.
*
* By default, a missing prefetch throws a {@link QueryStrictModeMissingPrefetchError} to an error
* boundary, and an unused prefetch is logged with `console.warn`. Pass `handleMissingPrefetch` or
* `handleUnusedPrefetch` to filter problems, or to log or throw them instead.
*
* Only affects development builds; returns children in production.
* @param props - The `children` to check, and optionally `handleMissingPrefetch`,
* `handleUnusedPrefetch`, an `unusedPrefetchTimeout`, and a `queryClient`.
* @returns The `children`.
* @example
* ```tsx
* import { QueryStrictMode } from '@tanstack/react-query'
*
* function App() {
* return (
* <QueryStrictMode
* handleMissingPrefetch={(event) => {
* if (!event.query.meta?.allowWaterfall) console.warn(event.message)
* }}
* >
* <Routes />
* </QueryStrictMode>
* )
* }
* ```
*/
// A single expression, so that bundlers drop `DevQueryStrictMode` once `NODE_ENV` is replaced
export const QueryStrictMode = (props: QueryStrictModeProps) =>
process.env.NODE_ENV === 'production' ? (
props.children
) : (
<DevQueryStrictMode {...props} />
)
19 changes: 19 additions & 0 deletions packages/react-query/src/QueryStrictModeContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client'
import * as React from 'react'
import type { QueryStrictModeValue } from './QueryStrictModeTypes'

const QueryStrictModeContext = React.createContext<QueryStrictModeValue | null>(
null,
)

/**
* Reads the nearest `QueryStrictMode`.
* @returns The value of the nearest `QueryStrictMode`, or `null` outside of one.
*/
export const useQueryStrictMode = () => React.useContext(QueryStrictModeContext)

/**
* The Provider that `QueryStrictMode` uses to share its value with the hooks below it, read by
* `useQueryStrictMode`.
*/
export const QueryStrictModeProvider = QueryStrictModeContext.Provider
298 changes: 298 additions & 0 deletions packages/react-query/src/QueryStrictModeDev.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
'use client'
// The development-only part of `QueryStrictMode`. Only imported behind `NODE_ENV` checks, so
// production bundles leave this file out.
import * as React from 'react'

import { environmentManager } from '@tanstack/query-core'
import { useQueryClient } from './QueryClientProvider'
import { useIsRestoring } from './IsRestoringProvider'
import {
QueryStrictModeProvider,
useQueryStrictMode,
} from './QueryStrictModeContext'
import { QueryStrictModeMissingPrefetchError } from './QueryStrictModeError'
import type {
Query,
QueryCache,
QueryObserverResult,
} from '@tanstack/query-core'
import type {
QueryStrictModeMissingPrefetchEvent,
QueryStrictModeMissingPrefetchHandler,
QueryStrictModeProps,
QueryStrictModeUnusedPrefetchEvent,
QueryStrictModeUnusedPrefetchHandler,
QueryStrictModeValue,
} from './QueryStrictModeTypes'

// Missing prefetches cause request waterfalls, so they are thrown. Unused prefetches only waste a
// request, so they are logged.
const defaultHandleMissingPrefetch: QueryStrictModeMissingPrefetchHandler = (
event,
) => {
throw new QueryStrictModeMissingPrefetchError(event)
}

const defaultHandleUnusedPrefetch: QueryStrictModeUnusedPrefetchHandler = (
event,
) => {
console.warn(event.message)
}

/**
* Records when the data of each prefetch arrives in a `QueryCache`, starting from the first render
* of an outermost `QueryStrictMode`. Data can arrive before `QueryStrictMode` commits, for example
* while a sibling suspends, so the tracking can't wait for an effect.
*/
interface PrefetchTracker {
/**
* When the data of the last prefetch of each query arrived. Data from `setQueryData` and
* `hydrate` isn't recorded.
*/
prefetches: Map<Query<any, any, any, any>, number>
/**
* The queries read by a hook inside a `QueryStrictMode`, or already reported. A query counts as
* used even before the reading component commits, for example while a sibling still suspends.
* Shared by all `QueryStrictMode`s of the cache, so each unused prefetch is reported only once.
*/
used: WeakSet<Query<any, any, any, any>>
/**
* When the prefetch was recorded that each query was last checked for, so a timer that already
* ran doesn't start again
*/
checked: WeakMap<Query<any, any, any, any>, number>
/**
* Called with a query when its prefetch is recorded, or when it is removed from the cache.
*/
listeners: Set<(query: Query<any, any, any, any>) => void>
/**
* How many committed `QueryStrictMode`s use the tracker.
*/
mounted: number
unsubscribe: () => void
}

const prefetchTrackers = new WeakMap<QueryCache, PrefetchTracker>()

/**
* Gets the tracker of a `QueryCache`, and starts tracking if no `QueryStrictMode` uses it yet.
* @param cache - The cache to track.
* @returns The tracker of `cache`.
*/
const getPrefetchTracker = (cache: QueryCache) => {
let tracker = prefetchTrackers.get(cache)
if (!tracker) {
const prefetches: PrefetchTracker['prefetches'] = new Map()
const listeners: PrefetchTracker['listeners'] = new Set()
const unsubscribe = cache.subscribe((event) => {
if (event.type === 'removed') {
prefetches.delete(event.query)
} else if (
event.type === 'updated' &&
event.action.type === 'success' &&
!event.action.manual
) {
prefetches.set(event.query, Date.now())
} else {
return
}
listeners.forEach((listener) => listener(event.query))
})
tracker = {
prefetches,
used: new WeakSet(),
checked: new WeakMap(),
listeners,
mounted: 0,
unsubscribe,
}
prefetchTrackers.set(cache, tracker)
}
return tracker
}
/**
* The development implementation of `QueryStrictMode`. Tracks prefetches for the whole cache, and
* shares `handleMissingPrefetch` with the hooks below it.
* @param props - The `children` to check, and the handlers and options passed to `QueryStrictMode`.
* @returns The `children`, inside the context of this `QueryStrictMode`.
* @throws {unknown} Whatever `handleUnusedPrefetch` throws, during the next render.
*/
export const DevQueryStrictMode = ({
children,
handleUnusedPrefetch = defaultHandleUnusedPrefetch,
unusedPrefetchTimeout = 5000,
queryClient,
...props
}: QueryStrictModeProps) => {
const client = useQueryClient(queryClient)
const isRestoring = useIsRestoring()
const parent = useQueryStrictMode()
const isNested = parent !== null
// A nested `QueryStrictMode` without its own handler keeps the one of its parent
const handleMissingPrefetch =
props.handleMissingPrefetch ??
parent?.handleMissingPrefetch ??
defaultHandleMissingPrefetch

// Thrown during render, so it reaches the error boundary above this `QueryStrictMode`. Wrapped,
// so that any value thrown by `handleUnusedPrefetch` is rethrown unchanged
const [thrown, setThrown] = React.useState<{ error: unknown } | null>(null)
if (thrown) {
throw thrown.error
}

// Tracked from the first render, so prefetches whose data arrives before the effect runs are
// reported too. Not on the server, where nothing reports them.
const cache = client.getQueryCache()
if (!isNested && !environmentManager.isServer()) {
getPrefetchTracker(cache)
}

const handleUnusedPrefetchRef = React.useRef(handleUnusedPrefetch)
React.useEffect(() => {
handleUnusedPrefetchRef.current = handleUnusedPrefetch
}, [handleUnusedPrefetch])

React.useEffect(() => {
if (isNested || isRestoring) {
return
}

const tracker = getPrefetchTracker(cache)
const { used, checked } = tracker
const timers = new Map<
Query<any, any, any, any>,
ReturnType<typeof setTimeout>
>()

const isUnused = (query: Query<any, any, any, any>) =>
!used.has(query) && query.getObserversCount() === 0

// Starts a timer from when the prefetch of `query` was recorded, or clears it when `query` was
// removed from the cache
const check = (query: Query<any, any, any, any>) => {
const prefetchedAt = tracker.prefetches.get(query)
if (prefetchedAt === undefined) {
clearTimeout(timers.get(query))
timers.delete(query)
return
}
if (
timers.has(query) ||
checked.get(query) === prefetchedAt ||
!isUnused(query)
) {
return
}
const timer = setTimeout(
() => {
timers.delete(query)
checked.set(query, prefetchedAt)
if (isUnused(query)) {
// Report each query only once
used.add(query)
const problem: QueryStrictModeUnusedPrefetchEvent = {
type: 'unused-prefetch',
message: `[${query.queryHash}]: The query was prefetched, but nothing used it within ${unusedPrefetchTimeout}ms. Remove the prefetch, or check that it uses the same queryKey as the query that reads it.`,
query,
}
try {
handleUnusedPrefetchRef.current(problem)
} catch (error) {
setThrown({ error })
}
}
},
Math.max(0, prefetchedAt + unusedPrefetchTimeout - Date.now()),
)
timers.set(query, timer)
}

tracker.mounted++
tracker.listeners.add(check)
tracker.prefetches.forEach((_, query) => check(query))

return () => {
tracker.listeners.delete(check)
timers.forEach((timer) => clearTimeout(timer))
timers.clear()
tracker.mounted--
// Stop tracking once no `QueryStrictMode` uses the tracker. Checked in a microtask, because
// `React.StrictMode` runs the effect again right after this cleanup.
queueMicrotask(() => {
if (tracker.mounted === 0 && prefetchTrackers.get(cache) === tracker) {
tracker.unsubscribe()
prefetchTrackers.delete(cache)
}
})
}
}, [cache, isNested, isRestoring, unusedPrefetchTimeout])

const value = React.useMemo(
() => ({ handleMissingPrefetch }),
[handleMissingPrefetch],
)
return (
<QueryStrictModeProvider value={value}>{children}</QueryStrictModeProvider>
)
}

// The query states a `'missing-prefetch'` was reported for. A component can render more than once
// before it starts the fetch, for example in `React.StrictMode`, but each state is only reported
// once
const reportedMissingPrefetches = new WeakSet<
Query<any, any, any, any>['state']
>()

/**
* Inside a `QueryStrictMode`, marks a query read by a hook as used, and reports a
* `'missing-prefetch'` if the hook is about to start the initial fetch of the query. Does nothing
* in production.
* @param strictMode - The value of the nearest `QueryStrictMode`, if any.
* @param cache - The cache of the hook's `QueryClient`.
* @param queryHash - The hash of the query the hook reads.
* @param result - The optimistic result of the hook.
* @param suspense - Whether the hook suspends while the query is pending.
* @throws {unknown} Whatever `handleMissingPrefetch` throws.
*/
export const checkQueryStrictMode = (
strictMode: QueryStrictModeValue | null,
cache: QueryCache,
queryHash: string,
result: QueryObserverResult<any, any>,
suspense: boolean,
) => {
const query = strictMode && cache.get(queryHash)
if (!query) {
return
}
prefetchTrackers.get(cache)?.used.add(query)
// A suspending hook starts the fetch during render. Otherwise, the optimistic result reports
// `'fetching'` when the hook starts the fetch on mount, or after its options change.
const startsInitialFetch = suspense
? result.isPending
: result.fetchStatus === 'fetching' && query.state.data === undefined
// Check the query's own state: the fetch hasn't started yet if it's still `'idle'`. A query
// that errored is refetched after an error boundary reset, which isn't a missing prefetch.
if (
startsInitialFetch &&
query.state.fetchStatus === 'idle' &&
query.state.status !== 'error'
) {
const event: QueryStrictModeMissingPrefetchEvent = {
type: 'missing-prefetch',
suspense,
message: suspense
? `[${query.queryHash}]: A suspense query started its fetch during render. Prefetch it higher up (in a route loader, with usePrefetchQuery, or on the server) to avoid request waterfalls.`
: `[${query.queryHash}]: A query started its fetch after rendering. Prefetch it higher up (in a route loader, with usePrefetchQuery, or on the server) to start the fetch earlier.`,
query,
}
if (!reportedMissingPrefetches.has(query.state)) {
// A throw from `handleMissingPrefetch` happens before the hook starts its fetch. The state
// is only marked once it returns, so the query stays idle and throws again after an error
// boundary reset.
strictMode.handleMissingPrefetch(event)
reportedMissingPrefetches.add(query.state)
}
}
}
Loading
Loading