diff --git a/.changeset/query-strict-mode.md b/.changeset/query-strict-mode.md
new file mode 100644
index 00000000000..0f2bfcf3578
--- /dev/null
+++ b/.changeset/query-strict-mode.md
@@ -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`.
diff --git a/packages/react-query/src/QueryStrictMode.tsx b/packages/react-query/src/QueryStrictMode.tsx
new file mode 100644
index 00000000000..0b6f5cc921b
--- /dev/null
+++ b/packages/react-query/src/QueryStrictMode.tsx
@@ -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 (
+ * {
+ * if (!event.query.meta?.allowWaterfall) console.warn(event.message)
+ * }}
+ * >
+ *
+ *
+ * )
+ * }
+ * ```
+ */
+// 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
+ ) : (
+
+ )
diff --git a/packages/react-query/src/QueryStrictModeContext.ts b/packages/react-query/src/QueryStrictModeContext.ts
new file mode 100644
index 00000000000..b1995b74bb9
--- /dev/null
+++ b/packages/react-query/src/QueryStrictModeContext.ts
@@ -0,0 +1,19 @@
+'use client'
+import * as React from 'react'
+import type { QueryStrictModeValue } from './QueryStrictModeTypes'
+
+const QueryStrictModeContext = React.createContext(
+ 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
diff --git a/packages/react-query/src/QueryStrictModeDev.tsx b/packages/react-query/src/QueryStrictModeDev.tsx
new file mode 100644
index 00000000000..77688b03115
--- /dev/null
+++ b/packages/react-query/src/QueryStrictModeDev.tsx
@@ -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, 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>
+ /**
+ * When the prefetch was recorded that each query was last checked for, so a timer that already
+ * ran doesn't start again
+ */
+ checked: WeakMap, number>
+ /**
+ * Called with a query when its prefetch is recorded, or when it is removed from the cache.
+ */
+ listeners: Set<(query: Query) => void>
+ /**
+ * How many committed `QueryStrictMode`s use the tracker.
+ */
+ mounted: number
+ unsubscribe: () => void
+}
+
+const prefetchTrackers = new WeakMap()
+
+/**
+ * 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,
+ ReturnType
+ >()
+
+ const isUnused = (query: Query) =>
+ !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) => {
+ 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 (
+ {children}
+ )
+}
+
+// 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['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,
+ 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)
+ }
+ }
+}
diff --git a/packages/react-query/src/QueryStrictModeError.ts b/packages/react-query/src/QueryStrictModeError.ts
new file mode 100644
index 00000000000..a9d8a8952ee
--- /dev/null
+++ b/packages/react-query/src/QueryStrictModeError.ts
@@ -0,0 +1,109 @@
+import type {
+ QueryStrictModeEvent,
+ QueryStrictModeMissingPrefetchEvent,
+ QueryStrictModeUnusedPrefetchEvent,
+} from './QueryStrictModeTypes'
+
+/**
+ * The base class of the errors for the problems found by `QueryStrictMode`: a
+ * {@link QueryStrictModeMissingPrefetchError} or a {@link QueryStrictModeUnusedPrefetchError}. Use
+ * it to catch both in an error boundary.
+ * @example
+ * ```tsx
+ *
+ * error instanceof QueryStrictModeError ? (
+ *
+ * ) : (
+ *
+ * )
+ * }
+ * >
+ *
+ *
+ *
+ *
+ * ```
+ */
+export class QueryStrictModeError extends Error {
+ /**
+ * The problem the error was created for.
+ */
+ event: QueryStrictModeEvent
+ /**
+ * Creates the error for a problem found by `QueryStrictMode`. Passes down `event.message` as the
+ * `Error.message`. Only called by the errors for each problem.
+ * @param event - The problem the error is for.
+ */
+ protected constructor(event: QueryStrictModeEvent) {
+ super(event.message)
+ this.name = 'QueryStrictModeError'
+ this.event = event
+ }
+}
+
+/**
+ * The error for a {@link QueryStrictModeMissingPrefetchEvent}. The default `handleMissingPrefetch`
+ * throws it.
+ * @example
+ * ```tsx
+ *
+ * error instanceof QueryStrictModeMissingPrefetchError ? (
+ *
Prefetch {error.event.query.queryHash} in the route loader
+ * ) : (
+ *
+ * )
+ * }
+ * >
+ *
+ *
+ *
+ *
+ * ```
+ */
+export class QueryStrictModeMissingPrefetchError extends QueryStrictModeError {
+ /**
+ * The missing prefetch the error was created for.
+ */
+ declare event: QueryStrictModeMissingPrefetchEvent
+ /**
+ * Creates the error for a missing prefetch.
+ * @param event - The missing prefetch the error is for.
+ */
+ constructor(event: QueryStrictModeMissingPrefetchEvent) {
+ super(event)
+ this.name = 'QueryStrictModeMissingPrefetchError'
+ }
+}
+
+/**
+ * The error for a {@link QueryStrictModeUnusedPrefetchEvent}. The default `handleUnusedPrefetch`
+ * only logs, so throw it from your own.
+ * @example
+ * ```tsx
+ * {
+ * throw new QueryStrictModeUnusedPrefetchError(event)
+ * }}
+ * >
+ *
+ *
+ * ```
+ */
+export class QueryStrictModeUnusedPrefetchError extends QueryStrictModeError {
+ /**
+ * The unused prefetch the error was created for.
+ */
+ declare event: QueryStrictModeUnusedPrefetchEvent
+ /**
+ * Creates the error for an unused prefetch.
+ * @param event - The unused prefetch the error is for.
+ */
+ constructor(event: QueryStrictModeUnusedPrefetchEvent) {
+ super(event)
+ this.name = 'QueryStrictModeUnusedPrefetchError'
+ }
+}
diff --git a/packages/react-query/src/QueryStrictModeTypes.ts b/packages/react-query/src/QueryStrictModeTypes.ts
new file mode 100644
index 00000000000..9b67a290152
--- /dev/null
+++ b/packages/react-query/src/QueryStrictModeTypes.ts
@@ -0,0 +1,169 @@
+import type * as React from 'react'
+import type { Query, QueryClient } from '@tanstack/query-core'
+// Only used in `{@link}`s
+import type {
+ QueryStrictModeMissingPrefetchError,
+ QueryStrictModeUnusedPrefetchError,
+} from './QueryStrictModeError'
+
+/**
+ * Reported when a query hook, like `useQuery` or `useSuspenseQuery`, starts the initial fetch of
+ * its query because it wasn't prefetched. Suspense hooks start it during render, which causes
+ * request waterfalls; `suspense` tells them apart. Only reported for hooks inside
+ * `QueryStrictMode`.
+ *
+ * Data from `setQueryData` and `hydrate` doesn't count as a prefetch.
+ */
+export interface QueryStrictModeMissingPrefetchEvent {
+ /**
+ * Always `'missing-prefetch'`. Tells it apart from a {@link QueryStrictModeUnusedPrefetchEvent}.
+ */
+ type: 'missing-prefetch'
+ /**
+ * Whether the hook that started the fetch suspends, like `useSuspenseQuery`.
+ */
+ suspense: boolean
+ /**
+ * A readable description of the problem.
+ */
+ message: string
+ /**
+ * The query the problem was found for.
+ */
+ query: Query
+}
+
+/**
+ * Reported when a query was prefetched, but nothing used it within `unusedPrefetchTimeout`. A
+ * query counts as used once a hook inside a `QueryStrictMode` reads it, or while anything observes
+ * it. A query only read outside of `QueryStrictMode`, by a component that unmounted before the
+ * timeout, is reported. Checked for the whole cache of the `QueryClient`, except for queries that
+ * were already fetched before `QueryStrictMode` mounted.
+ *
+ * Data from `setQueryData` and `hydrate` doesn't count as a prefetch.
+ */
+export interface QueryStrictModeUnusedPrefetchEvent {
+ /**
+ * Always `'unused-prefetch'`. Tells it apart from a {@link QueryStrictModeMissingPrefetchEvent}.
+ */
+ type: 'unused-prefetch'
+ /**
+ * A readable description of the problem.
+ */
+ message: string
+ /**
+ * The query the problem was found for.
+ */
+ query: Query
+}
+
+/**
+ * A problem reported by `QueryStrictMode`: a {@link QueryStrictModeMissingPrefetchEvent} or a
+ * {@link QueryStrictModeUnusedPrefetchEvent}. Narrow it by `type`.
+ */
+export type QueryStrictModeEvent =
+ | QueryStrictModeMissingPrefetchEvent
+ | QueryStrictModeUnusedPrefetchEvent
+
+/**
+ * Called when a hook inside `QueryStrictMode` starts the initial fetch of a query that wasn't
+ * prefetched. Throw to send the problem to an error boundary, or return to let rendering continue,
+ * for example after logging `event.message`. Thrown values are rethrown unchanged, so you can throw
+ * a {@link QueryStrictModeMissingPrefetchError} or your own error.
+ *
+ * It is called during the render of the hook that reads the query, using the
+ * `handleMissingPrefetch` of the nearest `QueryStrictMode` that has one. A throw goes to the nearest error
+ * boundary above the hook, before the hook starts its fetch. The query stays idle, so it is called
+ * again after the error boundary is reset. If it returns, it isn't called again for the same query
+ * state.
+ *
+ * Use it to filter out known or intentional waterfalls, for example by `queryKey`, `meta`, or
+ * `suspense`.
+ * @param event - The problem that was found.
+ * @throws {unknown} Anything, to send the problem to an error boundary.
+ * @example
+ * ```tsx
+ * {
+ * if (event.query.meta?.allowWaterfall) return
+ * throw new QueryStrictModeMissingPrefetchError(event)
+ * }}
+ * >
+ *
+ *
+ * ```
+ */
+export type QueryStrictModeMissingPrefetchHandler = (
+ event: QueryStrictModeMissingPrefetchEvent,
+) => void
+
+/**
+ * Called when a prefetched query isn't used within `unusedPrefetchTimeout`. Throw to send the
+ * problem to an error boundary, or return, for example after logging `event.message`. Thrown values
+ * are rethrown unchanged, so you can throw a {@link QueryStrictModeUnusedPrefetchError} or your own
+ * error.
+ *
+ * Only the `handleUnusedPrefetch` of the outermost `QueryStrictMode` is called. A throw goes to the
+ * error boundary above that `QueryStrictMode`, which unmounts the whole subtree.
+ * @param event - The problem that was found.
+ * @throws {unknown} Anything, to send the problem to an error boundary.
+ * @example
+ * ```tsx
+ * {
+ * throw new QueryStrictModeUnusedPrefetchError(event)
+ * }}
+ * >
+ *
+ *
+ * ```
+ */
+export type QueryStrictModeUnusedPrefetchHandler = (
+ event: QueryStrictModeUnusedPrefetchEvent,
+) => void
+
+/**
+ * The props accepted by `QueryStrictMode`.
+ */
+export interface QueryStrictModeProps {
+ /**
+ * The subtree to check.
+ */
+ children?: React.ReactNode
+ /**
+ * Called when a hook starts the initial fetch of a query that wasn't prefetched. See
+ * {@link QueryStrictModeMissingPrefetchHandler} for where a throw goes. A nested
+ * `QueryStrictMode` without one uses the one of its parent.
+ * @defaultValue The `handleMissingPrefetch` of the parent `QueryStrictMode`, or a handler that
+ * throws a `QueryStrictModeMissingPrefetchError` outside of one.
+ */
+ handleMissingPrefetch?: QueryStrictModeMissingPrefetchHandler
+ /**
+ * Called when a prefetched query isn't used within `unusedPrefetchTimeout`. See
+ * {@link QueryStrictModeUnusedPrefetchHandler} for where a throw goes. Ignored on a nested
+ * `QueryStrictMode`.
+ * @defaultValue A handler that logs `event.message` with `console.warn`.
+ */
+ handleUnusedPrefetch?: QueryStrictModeUnusedPrefetchHandler
+ /**
+ * How long a prefetched query may go unused, in milliseconds, before it is reported. The timer
+ * starts when the data arrives in the cache.
+ * @defaultValue 5000
+ */
+ unusedPrefetchTimeout?: number
+ /**
+ * Use this to use a custom `QueryClient`. Otherwise, the one from the nearest context will be
+ * used.
+ */
+ queryClient?: QueryClient
+}
+
+/**
+ * The value a `QueryStrictMode` shares with the hooks below it.
+ */
+export interface QueryStrictModeValue {
+ /**
+ * Called for the missing prefetches found by hooks below this `QueryStrictMode`.
+ */
+ handleMissingPrefetch: QueryStrictModeMissingPrefetchHandler
+}
diff --git a/packages/react-query/src/__tests__/QueryStrictMode.test-d.tsx b/packages/react-query/src/__tests__/QueryStrictMode.test-d.tsx
new file mode 100644
index 00000000000..1432ecf3be1
--- /dev/null
+++ b/packages/react-query/src/__tests__/QueryStrictMode.test-d.tsx
@@ -0,0 +1,76 @@
+import { assertType, describe, expectTypeOf, it } from 'vitest'
+import {
+ QueryStrictMode,
+ QueryStrictModeError,
+ QueryStrictModeMissingPrefetchError,
+ QueryStrictModeUnusedPrefetchError,
+} from '..'
+import type {
+ QueryStrictModeEvent,
+ QueryStrictModeMissingPrefetchEvent,
+ QueryStrictModeUnusedPrefetchEvent,
+} from '..'
+
+describe('QueryStrictMode', () => {
+ it('should pass each handler its own event', () => {
+ assertType(
+ {
+ expectTypeOf(
+ event,
+ ).toEqualTypeOf()
+ expectTypeOf(event.suspense).toEqualTypeOf()
+ throw new QueryStrictModeMissingPrefetchError(event)
+ }}
+ handleUnusedPrefetch={(event) => {
+ expectTypeOf(
+ event,
+ ).toEqualTypeOf()
+ throw new QueryStrictModeUnusedPrefetchError(event)
+ }}
+ />,
+ )
+ })
+
+ it('should narrow a QueryStrictModeEvent by its type', () => {
+ const narrow = (event: QueryStrictModeEvent) => {
+ if (event.type === 'missing-prefetch') {
+ expectTypeOf(event).toEqualTypeOf()
+ } else {
+ expectTypeOf(event).toEqualTypeOf()
+ }
+ }
+ assertType(narrow)
+ })
+
+ it('should narrow the event of each error', () => {
+ const narrow = (error: unknown) => {
+ if (error instanceof QueryStrictModeMissingPrefetchError) {
+ expectTypeOf(
+ error.event,
+ ).toEqualTypeOf()
+ } else if (error instanceof QueryStrictModeUnusedPrefetchError) {
+ expectTypeOf(
+ error.event,
+ ).toEqualTypeOf()
+ } else if (error instanceof QueryStrictModeError) {
+ expectTypeOf(error.event).toEqualTypeOf()
+ }
+ }
+ assertType(narrow)
+ })
+
+ it('should not construct a QueryStrictModeError directly', () => {
+ const create = (event: QueryStrictModeEvent) =>
+ // @ts-expect-error The constructor is protected
+ new QueryStrictModeError(event)
+ assertType(create)
+ })
+
+ it('should not create an error for the other problem', () => {
+ const create = (event: QueryStrictModeUnusedPrefetchEvent) =>
+ // @ts-expect-error A missing prefetch error needs a missing prefetch event
+ new QueryStrictModeMissingPrefetchError(event)
+ assertType(create)
+ })
+})
diff --git a/packages/react-query/src/__tests__/QueryStrictMode.test.tsx b/packages/react-query/src/__tests__/QueryStrictMode.test.tsx
new file mode 100644
index 00000000000..48ac0120827
--- /dev/null
+++ b/packages/react-query/src/__tests__/QueryStrictMode.test.tsx
@@ -0,0 +1,1799 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ onTestFinished,
+ vi,
+} from 'vitest'
+import { act, fireEvent, render } from '@testing-library/react'
+import * as React from 'react'
+import * as ReactDOMClient from 'react-dom/client'
+import { ErrorBoundary } from 'react-error-boundary'
+import { queryKey, sleep } from '@tanstack/query-test-utils'
+import {
+ IsRestoringProvider,
+ QueryClient,
+ QueryClientProvider,
+ QueryErrorResetBoundary,
+ QueryObserver,
+ QueryStrictMode,
+ QueryStrictModeError,
+ QueryStrictModeMissingPrefetchError,
+ QueryStrictModeUnusedPrefetchError,
+ dehydrate,
+ hydrate,
+ noop,
+ notifyManager,
+ usePrefetchQuery,
+ useQueries,
+ useQuery,
+ useSuspenseInfiniteQuery,
+ useSuspenseQueries,
+ useSuspenseQuery,
+} from '..'
+import { renderWithClient } from './utils'
+import type {
+ QueryKey,
+ QueryStrictModeEvent,
+ QueryStrictModeUnusedPrefetchEvent,
+} from '..'
+
+type Handler = (event: QueryStrictModeEvent) => void
+
+const toStrictModeError = (event: QueryStrictModeEvent) =>
+ event.type === 'missing-prefetch'
+ ? new QueryStrictModeMissingPrefetchError(event)
+ : new QueryStrictModeUnusedPrefetchError(event)
+
+describe('QueryStrictMode', () => {
+ let queryClient: QueryClient
+ let handler: ReturnType>
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ queryClient = new QueryClient()
+ handler = vi.fn()
+ })
+
+ afterEach(() => {
+ queryClient.clear()
+ vi.useRealTimers()
+ })
+
+ const getQuery = (key: QueryKey) =>
+ queryClient.getQueryCache().find({ queryKey: key })
+
+ const handledTypes = () => handler.mock.calls.map(([event]) => event.type)
+
+ function SuspenseQuery({ id, ms = 10 }: { id: QueryKey; ms?: number }) {
+ const { data } = useSuspenseQuery({
+ queryKey: id,
+ queryFn: () => sleep(ms).then(() => `data ${String(id)}`),
+ })
+ return
{data}
+ }
+
+ // Renders like an app does, outside of `act`. `act` flushes effects as soon as it renders, but an
+ // app flushes passive effects in a later task, after any data that arrives in a microtask. React
+ // schedules that task with the real `setImmediate`, so this uses real timers. `React.StrictMode`
+ // only mounts, unmounts and mounts effects again at the root, so `strictMode` wraps the provider
+ const renderOutsideAct = (
+ ui: React.ReactElement,
+ { strictMode = false }: { strictMode?: boolean } = {},
+ ) => {
+ vi.useRealTimers()
+ const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ const previousIsReactActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT
+ globals.IS_REACT_ACT_ENVIRONMENT = false
+ notifyManager.setNotifyFunction((fn) => fn())
+
+ const container = document.createElement('div')
+ const root = ReactDOMClient.createRoot(container)
+ const tree = (
+ {ui}
+ )
+ root.render(strictMode ? {tree} : tree)
+
+ onTestFinished(() => {
+ root.unmount()
+ notifyManager.setNotifyFunction((fn) => {
+ act(fn)
+ })
+ globals.IS_REACT_ACT_ENVIRONMENT = previousIsReactActEnvironment
+ })
+
+ return container
+ }
+
+ // Prefetches a query whose data arrives in a microtask
+ function ResolvedPrefetch({ id }: { id: QueryKey }) {
+ usePrefetchQuery({ queryKey: id, queryFn: () => Promise.resolve('data') })
+ return null
+ }
+
+ // Reads the query that `ResolvedPrefetch` prefetches
+ function ResolvedQuery({ id }: { id: QueryKey }) {
+ const { data } = useQuery({
+ queryKey: id,
+ queryFn: () => Promise.resolve('data'),
+ })
+ return