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 ? ( + *
+ *         {error.event.type}: {error.event.query.queryHash}
+ *       
+ * ) : ( + * + * ) + * } + * > + * + * + * + *
+ * ``` + */ +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
{data}
+ } + + describe('missing-prefetch', () => { + it('should log once when a suspense hook starts a fetch during render', async () => { + const key = queryKey() + + const rendered = renderWithClient( + queryClient, + + + + + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith({ + type: 'missing-prefetch', + suspense: true, + message: expect.stringContaining(getQuery(key)!.queryHash), + query: getQuery(key), + }) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(`data ${String(key)}`)).toBeInTheDocument() + + // The suspense fetch itself must not count as an unused prefetch + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('should log for useSuspenseInfiniteQuery', async () => { + const key = queryKey() + + function Page() { + const { data } = useSuspenseInfiniteQuery({ + queryKey: key, + queryFn: ({ pageParam }) => sleep(10).then(() => pageParam), + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage + 1, + }) + return
pages: {data.pages.join(',')}
+ } + + const rendered = renderWithClient( + queryClient, + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('pages: 1')).toBeInTheDocument() + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0]).toMatchObject({ + type: 'missing-prefetch', + query: getQuery(key), + }) + }) + + it('should not log when the query is being prefetched', async () => { + const key = queryKey() + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => `data ${String(key)}`), + }) + .catch(noop) + + const rendered = renderWithClient( + queryClient, + + + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(`data ${String(key)}`)).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + it('should not log when the query was prefetched with usePrefetchQuery', async () => { + const key = queryKey() + const queryFn = () => sleep(10).then(() => `data ${String(key)}`) + + function Parent() { + usePrefetchQuery({ queryKey: key, queryFn }) + return ( + + + + ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(`data ${String(key)}`)).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + it('should only log the queries of useSuspenseQueries that were not prefetched', async () => { + const key1 = queryKey() + const key2 = queryKey() + + void queryClient + .query({ + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + }) + .catch(noop) + + function Page() { + const [result1, result2] = useSuspenseQueries({ + queries: [ + { queryKey: key1, queryFn: () => sleep(10).then(() => 1) }, + { queryKey: key2, queryFn: () => sleep(10).then(() => 2) }, + ], + }) + return ( +
+ data: {result1.data},{result2.data} +
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0]).toMatchObject({ + type: 'missing-prefetch', + suspense: true, + query: getQuery(key2), + }) + }) + + it('should log once when useQuery starts a fetch after rendering', async () => { + const key = queryKey() + + function Page() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + return
{data ?? 'pending'}
+ } + + const rendered = renderWithClient( + queryClient, + + + + + , + ) + + expect(rendered.getByText('pending')).toBeInTheDocument() + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith({ + type: 'missing-prefetch', + suspense: false, + message: expect.stringContaining(getQuery(key)!.queryHash), + query: getQuery(key), + }) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('should not log when useQuery reads a prefetched query', async () => { + const inFlight = queryKey() + const cached = queryKey() + const queryFn = () => sleep(10).then(() => 'data') + + queryClient.setQueryData(cached, 'data') + void queryClient.query({ queryKey: inFlight, queryFn }).catch(noop) + + function Page() { + const first = useQuery({ queryKey: inFlight, queryFn }) + const second = useQuery({ queryKey: cached, queryFn }) + return ( +
+ {first.data ?? 'pending'},{second.data} +
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data,data')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + it('should not log for a disabled useQuery, but log once it is enabled', async () => { + const key = queryKey() + + function Page({ enabled }: { enabled: boolean }) { + const { data } = useQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + enabled, + }) + return
{data ?? 'pending'}
+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('pending')).toBeInTheDocument() + expect(handler).not.toHaveBeenCalled() + + rendered.rerender( + + + , + ) + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data')).toBeInTheDocument() + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0]).toMatchObject({ + type: 'missing-prefetch', + suspense: false, + query: getQuery(key), + }) + }) + + it('should only log the queries of useQueries that were not prefetched', async () => { + const key1 = queryKey() + const key2 = queryKey() + + void queryClient + .query({ + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + }) + .catch(noop) + + function Page() { + const [result1, result2] = useQueries({ + queries: [ + { queryKey: key1, queryFn: () => sleep(10).then(() => 1) }, + { queryKey: key2, queryFn: () => sleep(10).then(() => 2) }, + ], + }) + return ( +
+ data: {result1.data},{result2.data} +
+ ) + } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: 1,2')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0]).toMatchObject({ + type: 'missing-prefetch', + suspense: false, + query: getQuery(key2), + }) + }) + + it('should use the handleMissingPrefetch of the parent when a nested QueryStrictMode has none', async () => { + const key = queryKey() + + const rendered = renderWithClient( + queryClient, + + + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(`data ${String(key)}`)).toBeInTheDocument() + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0]).toMatchObject({ + type: 'missing-prefetch', + query: getQuery(key), + }) + }) + + it('should use its own handleMissingPrefetch when a nested QueryStrictMode has one', async () => { + const key = queryKey() + const innerHandler = vi.fn() + + renderWithClient( + queryClient, + + + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(innerHandler).toHaveBeenCalledTimes(1) + expect(handler).not.toHaveBeenCalled() + }) + + it('should not log for hooks outside of QueryStrictMode', async () => { + const key = queryKey() + + const rendered = renderWithClient( + queryClient, + <> + + {null} + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(`data ${String(key)}`)).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + it('should not log again when an error boundary is reset', async () => { + const consoleMock = vi.spyOn(console, 'error').mockReturnValue(undefined) + const key = queryKey() + let succeed = false + + function Page() { + const { data } = useSuspenseQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (!succeed) throw new Error('Suspense Error') + return 'data' + }), + retry: false, + }) + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + + {({ reset }) => ( + ( + + )} + > + + + + + )} + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('retry')).toBeInTheDocument() + + succeed = true + fireEvent.click(rendered.getByText('retry')) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data')).toBeInTheDocument() + + expect(handledTypes()).toStrictEqual(['missing-prefetch']) + consoleMock.mockRestore() + }) + + it('should throw a QueryStrictModeMissingPrefetchError by default', async () => { + const consoleMock = vi.spyOn(console, 'error').mockReturnValue(undefined) + const key = queryKey() + const errors: Array = [] + + const rendered = renderWithClient( + queryClient, + + errors.push(error)} + fallbackRender={() =>
error
} + > + + + +
+
, + ) + + expect(rendered.getByText('error')).toBeInTheDocument() + expect(errors[0]).toBeInstanceOf(QueryStrictModeMissingPrefetchError) + expect(errors[0]).toMatchObject({ + message: expect.stringContaining(getQuery(key)!.queryHash), + event: { type: 'missing-prefetch', query: getQuery(key) }, + }) + + await act(() => vi.advanceTimersByTimeAsync(10)) + consoleMock.mockRestore() + }) + + describe('with a handler that throws', () => { + const throwStrictModeError = (event: QueryStrictModeEvent) => { + throw toStrictModeError(event) + } + + function renderThrowing( + id: QueryKey, + queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => `data ${String(id)}`), + ), + ) { + const errors: Array = [] + + function Page() { + const { data } = useSuspenseQuery({ queryKey: id, queryFn }) + return
{data}
+ } + + const rendered = renderWithClient( + queryClient, + + errors.push(error)} + fallbackRender={({ error, resetErrorBoundary }) => ( +
+
error: {(error as Error).message}
+ +
+ )} + > + + + +
+
, + ) + return { rendered, queryFn, errors } + } + + it('should throw to the nearest error boundary instead of fetching', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockReturnValue(undefined) + const key = queryKey() + handler.mockImplementation(throwStrictModeError) + + const { rendered, queryFn, errors } = renderThrowing(key) + + expect( + rendered.getByText( + new RegExp(`error: .*${getQuery(key)!.queryHash}`), + ), + ).toBeInTheDocument() + expect(handler).toHaveBeenCalledWith({ + type: 'missing-prefetch', + suspense: true, + message: expect.stringContaining(getQuery(key)!.queryHash), + query: getQuery(key), + }) + + const error = errors[0] + expect(error).toBeInstanceOf(QueryStrictModeMissingPrefetchError) + expect(error).toBeInstanceOf(QueryStrictModeError) + expect(error).toBeInstanceOf(Error) + expect(error).toMatchObject({ + name: 'QueryStrictModeMissingPrefetchError', + message: expect.stringContaining(getQuery(key)!.queryHash), + event: { + type: 'missing-prefetch', + suspense: true, + query: getQuery(key), + }, + }) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(queryFn).not.toHaveBeenCalled() + expect(rendered.queryByText(`data ${String(key)}`)).toBeNull() + consoleMock.mockRestore() + }) + + it('should throw a custom error to the error boundary unchanged', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockReturnValue(undefined) + const key = queryKey() + const customError = new Error('custom missing-prefetch') + handler.mockImplementation(() => { + throw customError + }) + + const { rendered, queryFn, errors } = renderThrowing(key) + + expect( + rendered.getByText('error: custom missing-prefetch'), + ).toBeInTheDocument() + expect(errors[0]).toBe(customError) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(queryFn).not.toHaveBeenCalled() + consoleMock.mockRestore() + }) + + it('should throw again after the error boundary is reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockReturnValue(undefined) + const key = queryKey() + handler.mockImplementation(throwStrictModeError) + + const { rendered, queryFn, errors } = renderThrowing(key) + expect(rendered.getByText(/error:/)).toBeInTheDocument() + const callsBeforeReset = handler.mock.calls.length + const errorsBeforeReset = errors.length + + fireEvent.click(rendered.getByText('retry')) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(/error:/)).toBeInTheDocument() + expect(handler.mock.calls.length).toBeGreaterThan(callsBeforeReset) + expect(errors.length).toBeGreaterThan(errorsBeforeReset) + expect(queryFn).not.toHaveBeenCalled() + consoleMock.mockRestore() + }) + + it('should only be called once more for the query state when it returns after an error boundary reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockReturnValue(undefined) + const key = queryKey() + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'data'), + ) + let shouldThrow = true + handler.mockImplementation((event) => { + if (shouldThrow) throw toStrictModeError(event) + }) + + function Page() { + const { data } = useSuspenseQuery({ queryKey: key, queryFn }) + return
{data}
+ } + + // React.StrictMode renders `Page` twice, but the handler is only called for the first render + const rendered = renderWithClient( + queryClient, + + + ( + + )} + > + + + + + + , + ) + + expect(rendered.getByText('retry')).toBeInTheDocument() + const callsBeforeReset = handler.mock.calls.length + + shouldThrow = false + fireEvent.click(rendered.getByText('retry')) + expect(rendered.getByText('loading')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).toHaveBeenCalledTimes(callsBeforeReset + 1) + expect(handler.mock.lastCall![0]).toMatchObject({ + type: 'missing-prefetch', + query: getQuery(key), + }) + expect(queryFn).toHaveBeenCalledTimes(1) + consoleMock.mockRestore() + }) + + it('should throw for some queries and report the others', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockReturnValue(undefined) + const thrown = queryKey() + const reported = queryKey() + handler.mockImplementation((event) => { + if (event.query.queryHash === getQuery(thrown)?.queryHash) { + throw toStrictModeError(event) + } + }) + + const first = renderThrowing(thrown) + expect(first.rendered.getByText(/error:/)).toBeInTheDocument() + first.rendered.unmount() + handler.mockClear() + + const second = renderThrowing(reported) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect( + second.rendered.getByText(`data ${String(reported)}`), + ).toBeInTheDocument() + expect(second.errors).toStrictEqual([]) + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0]).toMatchObject({ + type: 'missing-prefetch', + query: getQuery(reported), + }) + consoleMock.mockRestore() + }) + + it('should throw from useQuery instead of fetching', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockReturnValue(undefined) + const key = queryKey() + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'data'), + ) + handler.mockImplementation(throwStrictModeError) + + function Page() { + const { data } = useQuery({ queryKey: key, queryFn }) + return
{data ?? 'pending'}
+ } + + const rendered = renderWithClient( + queryClient, + + ( +
+ {error instanceof QueryStrictModeError + ? `strict: ${error.event.type}` + : 'other'} +
+ )} + > + +
+
, + ) + + expect( + rendered.getByText('strict: missing-prefetch'), + ).toBeInTheDocument() + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'missing-prefetch', + suspense: false, + query: getQuery(key), + }), + ) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(queryFn).not.toHaveBeenCalled() + consoleMock.mockRestore() + }) + }) + }) + + describe('unused-prefetch', () => { + it('should log when a prefetched query is not used within 5000ms of its data arriving', async () => { + const key = queryKey() + + renderWithClient( + queryClient, + + {null} + , + ) + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + + // The timer starts when the data arrives, not when the fetch starts + await act(() => vi.advanceTimersByTimeAsync(10 + 4999)) + expect(handler).not.toHaveBeenCalled() + + await act(() => vi.advanceTimersByTimeAsync(1)) + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith({ + type: 'unused-prefetch', + message: expect.stringContaining(getQuery(key)!.queryHash), + query: getQuery(key), + }) + }) + + it('should warn instead of throwing by default', async () => { + const consoleMock = vi.spyOn(console, 'warn').mockReturnValue(undefined) + const key = queryKey() + + const rendered = renderWithClient( + queryClient, + error}> + +
content
+
+
, + ) + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + + await act(() => vi.advanceTimersByTimeAsync(10 + 5000)) + expect(rendered.getByText('content')).toBeInTheDocument() + expect(consoleMock).toHaveBeenCalledTimes(1) + expect(consoleMock).toHaveBeenCalledWith( + expect.stringContaining(getQuery(key)!.queryHash), + ) + consoleMock.mockRestore() + }) + + it('should log when the prefetched query is only read outside of QueryStrictMode, by a component that unmounted', async () => { + const key = queryKey() + + function Reader() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + return
{data}
+ } + + function App({ showReader }: { showReader: boolean }) { + return ( + <> + + {null} + + {showReader && } + + ) + } + + const rendered = renderWithClient(queryClient, ) + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + + // The data arrives with no observers, which starts the timer + await act(() => vi.advanceTimersByTimeAsync(10)) + + rendered.rerender() + expect(rendered.getByText('data')).toBeInTheDocument() + await act(() => vi.advanceTimersByTimeAsync(10)) + + rendered.rerender() + expect(rendered.queryByText('data')).toBeNull() + + await act(() => vi.advanceTimersByTimeAsync(5000)) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + expect(handler.mock.calls[0]![0].query).toBe(getQuery(key)) + }) + + it('should respect a custom unusedPrefetchTimeout', async () => { + const key = queryKey() + + renderWithClient( + queryClient, + + {null} + , + ) + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + + await act(() => vi.advanceTimersByTimeAsync(10 + 999)) + expect(handler).not.toHaveBeenCalled() + + await act(() => vi.advanceTimersByTimeAsync(1)) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + }) + + it('should not log when the prefetched query is used before the timeout', async () => { + const key = queryKey() + const queryFn = () => sleep(10).then(() => 'data') + + function Page() { + const { data } = useQuery({ queryKey: key, queryFn }) + return
{data ?? 'pending'}
+ } + + const rendered = renderWithClient( + queryClient, + + {null} + , + ) + + void queryClient.query({ queryKey: key, queryFn }).catch(noop) + await act(() => vi.advanceTimersByTimeAsync(10 + 1000)) + + rendered.rerender( + + + , + ) + expect(rendered.getByText('data')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + it('should not log for a prefetched query whose reader is waiting on a slow sibling', async () => { + const fast = queryKey() + const slow = queryKey() + + void queryClient + .query({ + queryKey: fast, + queryFn: () => sleep(10).then(() => `data ${String(fast)}`), + }) + .catch(noop) + void queryClient + .query({ + queryKey: slow, + queryFn: () => sleep(10_000).then(() => `data ${String(slow)}`), + }) + .catch(noop) + + const rendered = renderWithClient( + queryClient, + + + + + + , + ) + + // The fast query has data, but nothing commits (and subscribes to it) until the slow one has + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(rendered.getByText(`data ${String(fast)}`)).toBeInTheDocument() + expect(rendered.getByText(`data ${String(slow)}`)).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + it('should only log once per query', async () => { + const key = queryKey() + + renderWithClient( + queryClient, + + {null} + , + ) + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + + await act(() => vi.advanceTimersByTimeAsync(10 + 5000)) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + + void queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }) + + await act(() => vi.advanceTimersByTimeAsync(10 + 10_000)) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + }) + + it('should not log for queries prefetched before QueryStrictMode mounted', async () => { + const key = queryKey() + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryData(key)).toBe('data') + + renderWithClient( + queryClient, + + {null} + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + it('should log for a render-time prefetch whose data arrives before QueryStrictMode subscribes', async () => { + const key = queryKey() + + renderOutsideAct( + + + , + ) + + await vi.waitFor(() => expect(getQuery(key)?.state.data).toBe('data')) + await sleep(50) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + }) + + it('should log for a render-time prefetch whose data arrives while a sibling suspends the first mount of QueryStrictMode', async () => { + const key = queryKey() + const otherKey = queryKey() + + // The sibling is outside of QueryStrictMode, so it doesn't report a missing prefetch, but it + // keeps QueryStrictMode from committing until its data arrives + const container = renderOutsideAct( + + + + + + , + ) + + await vi.waitFor(() => expect(getQuery(key)?.state.data).toBe('data')) + // React can wait a little after the data of the sibling arrives before it reveals the content + await vi.waitFor(() => + expect(container.textContent).toBe(`data ${String(otherKey)}`), + ) + await sleep(50) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + }) + + it('should not log for a prefetch whose data arrives before QueryStrictMode commits, if a component reads it once it commits', async () => { + const key = queryKey() + const otherKey = queryKey() + + // The reader only subscribes to the query when QueryStrictMode commits, after its data arrived + const container = renderOutsideAct( + + + + + + + , + ) + + await vi.waitFor(() => + expect(container.textContent).toBe(`datadata ${String(otherKey)}`), + ) + await sleep(50) + expect(handler).not.toHaveBeenCalled() + }) + + it('should only log once in React.StrictMode', async () => { + const key = queryKey() + + // React.StrictMode renders QueryStrictMode twice, and mounts it, unmounts it and mounts it again + renderOutsideAct( + + + , + { strictMode: true }, + ) + + await vi.waitFor(() => expect(getQuery(key)?.state.data).toBe('data')) + await sleep(50) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + }) + + it('should still log a prefetch whose timer started before unusedPrefetchTimeout changed', async () => { + const key = queryKey() + + const rendered = renderWithClient( + queryClient, + + {null} + , + ) + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + await act(() => vi.advanceTimersByTimeAsync(10)) + + await act(() => vi.advanceTimersByTimeAsync(10)) + rendered.rerender( + + {null} + , + ) + + // The data arrived at 10ms, so the new timeout ends at 6010ms, not 6000ms after the rerender + await act(() => vi.advanceTimersByTimeAsync(6010 - 20 - 1)) + expect(handler).not.toHaveBeenCalled() + await act(() => vi.advanceTimersByTimeAsync(1)) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + }) + + it('should not check a prefetch again when it runs its effect again', async () => { + const key = queryKey() + + const rendered = renderWithClient( + queryClient, + + {null} + , + ) + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + await act(() => vi.advanceTimersByTimeAsync(10)) + + // Observed when its timer ends, so it counts as used + const observer = new QueryObserver(queryClient, { + queryKey: key, + staleTime: Infinity, + }) + const unsubscribe = observer.subscribe(noop) + await act(() => vi.advanceTimersByTimeAsync(20)) + unsubscribe() + + rendered.rerender( + + {null} + , + ) + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + + // A new prefetch of the same query is checked again + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'new data'), + staleTime: 0, + }) + .catch(noop) + await act(() => vi.advanceTimersByTimeAsync(10 + 30)) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + }) + + it('should not start a timer for setQueryData', async () => { + const key = queryKey() + + renderWithClient( + queryClient, + + {null} + , + ) + + act(() => { + queryClient.setQueryData(key, 'data') + }) + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + it('should not log for hydrated queries', async () => { + const key = queryKey() + const serverClient = new QueryClient() + await serverClient.query({ + queryKey: key, + queryFn: () => 'data', + }) + const dehydrated = dehydrate(serverClient) + + renderWithClient( + queryClient, + + {null} + , + ) + + act(() => { + hydrate(queryClient, dehydrated) + }) + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + serverClient.clear() + }) + + it('should not log for a background refetch of a query that was used before', async () => { + const key = queryKey() + const queryFn = () => sleep(10).then(() => 'data') + + function Page() { + const { data } = useQuery({ queryKey: key, queryFn }) + return
{data ?? 'pending'}
+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data')).toBeInTheDocument() + + rendered.rerender( + + {null} + , + ) + void queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }) + + await act(() => vi.advanceTimersByTimeAsync(10 + 10_000)) + // Only the initial fetch of `Page` is reported + expect(handledTypes()).toStrictEqual(['missing-prefetch']) + }) + + it('should clear its timers on unmount', async () => { + const key = queryKey() + + const rendered = renderWithClient( + queryClient, + + {null} + , + ) + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + await act(() => vi.advanceTimersByTimeAsync(10)) + + rendered.unmount() + + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + it('should stop listening to the cache on unmount', async () => { + const rendered = render( + + + {null} + + , + { reactStrictMode: true }, + ) + expect(queryClient.getQueryCache().hasListeners()).toBe(true) + + rendered.unmount() + await act(() => vi.advanceTimersByTimeAsync(0)) + + expect(queryClient.getQueryCache().hasListeners()).toBe(false) + }) + + it('should only log once when QueryStrictMode is nested', async () => { + const key = queryKey() + const innerHandler = vi.fn() + + renderWithClient( + queryClient, + + + {null} + + , + ) + + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + + await act(() => vi.advanceTimersByTimeAsync(10 + 5000)) + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + expect(innerHandler).not.toHaveBeenCalled() + }) + + it('should not log for a prefetched query that was read inside a nested QueryStrictMode', async () => { + const key = queryKey() + + function Page({ read }: { read: boolean }) { + usePrefetchQuery({ + queryKey: key, + queryFn: () => sleep(10).then(() => `data ${String(key)}`), + }) + return ( + + {read && } + + ) + } + + const rendered = renderWithClient( + queryClient, + + + + + , + ) + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(`data ${String(key)}`)).toBeInTheDocument() + + // Nothing observes it after its reader unmounts, but it was read + rendered.rerender( + + + + + , + ) + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + }) + + describe('with sibling QueryStrictModes', () => { + const prefetch = (key: QueryKey) => + void queryClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => `data ${String(key)}`), + }) + .catch(noop) + + it('should not log for a prefetched query that was read inside the other one', async () => { + const key = queryKey() + const otherHandler = vi.fn() + + function Page({ read }: { read: boolean }) { + return ( + <> + + + {read && } + + + + {null} + + + ) + } + + const rendered = renderWithClient(queryClient, ) + prefetch(key) + rendered.rerender() + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText(`data ${String(key)}`)).toBeInTheDocument() + + // Nothing observes it after its reader unmounts, but it was read + rendered.rerender() + await act(() => vi.advanceTimersByTimeAsync(10_000)) + expect(handler).not.toHaveBeenCalled() + expect(otherHandler).not.toHaveBeenCalled() + }) + + it('should only log an unused prefetch once', async () => { + const key = queryKey() + const otherHandler = vi.fn() + + renderWithClient( + queryClient, + <> + + {null} + + + {null} + + , + ) + prefetch(key) + + await act(() => vi.advanceTimersByTimeAsync(10 + 5000)) + expect(handler.mock.calls.length + otherHandler.mock.calls.length).toBe( + 1, + ) + }) + + it('should only throw an unused prefetch to one error boundary', async () => { + const key = queryKey() + const throwingHandler = (event: QueryStrictModeUnusedPrefetchEvent) => { + throw new QueryStrictModeUnusedPrefetchError(event) + } + + const rendered = renderWithClient( + queryClient, + <> + {['first', 'second'].map((name) => ( + error}> + +
{name}
+
+
+ ))} + , + ) + prefetch(key) + + await act(() => vi.advanceTimersByTimeAsync(10 + 5000)) + expect(rendered.getAllByText('error')).toHaveLength(1) + }) + + it('should not log for the one that unmounted', async () => { + const key = queryKey() + const otherHandler = vi.fn() + + function Page({ both }: { both: boolean }) { + return ( + <> + {both && ( + + {null} + + )} + + {null} + + + ) + } + + const rendered = renderWithClient(queryClient, ) + rendered.rerender() + prefetch(key) + + await act(() => vi.advanceTimersByTimeAsync(10 + 5000)) + expect(otherHandler).not.toHaveBeenCalled() + expect(handledTypes()).toStrictEqual(['unused-prefetch']) + }) + }) + + describe('with a handler that throws', () => { + const prefetch = (key: QueryKey, ms = 10) => + void queryClient + .query({ queryKey: key, queryFn: () => sleep(ms).then(() => 'data') }) + .catch(noop) + + const throwUnusedPrefetch = (event: QueryStrictModeEvent) => { + throw toStrictModeError(event) + } + + function renderThrowing( + strictMode: (children: React.ReactNode) => React.ReactNode, + ) { + const errors: Array = [] + const rendered = renderWithClient( + queryClient, + errors.push(error)} + fallbackRender={({ error }) => ( +
error: {(error as Error).message}
+ )} + > + {strictMode(
content
)} +
, + ) + return { rendered, errors } + } + + it('should throw from QueryStrictMode to the error boundary above it', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockReturnValue(undefined) + const key = queryKey() + handler.mockImplementation(throwUnusedPrefetch) + + const { rendered, errors } = renderThrowing((children) => ( + + {children} + + )) + + prefetch(key) + + await act(() => vi.advanceTimersByTimeAsync(10 + 4999)) + expect(rendered.getByText('content')).toBeInTheDocument() + expect(handler).not.toHaveBeenCalled() + + await act(() => vi.advanceTimersByTimeAsync(1)) + expect(rendered.queryByText('content')).toBeNull() + expect( + rendered.getByText( + new RegExp(`error: .*${getQuery(key)!.queryHash}`), + ), + ).toBeInTheDocument() + expect(handler).toHaveBeenCalledTimes(1) + expect(handler).toHaveBeenCalledWith({ + type: 'unused-prefetch', + message: expect.stringContaining(getQuery(key)!.queryHash), + query: getQuery(key), + }) + + const error = errors[0] + expect(error).toBeInstanceOf(QueryStrictModeUnusedPrefetchError) + expect(error).toBeInstanceOf(QueryStrictModeError) + expect(error).toMatchObject({ + name: 'QueryStrictModeUnusedPrefetchError', + message: expect.stringContaining(getQuery(key)!.queryHash), + event: { type: 'unused-prefetch', query: getQuery(key) }, + }) + consoleMock.mockRestore() + }) + + it('should throw a custom error to the error boundary unchanged', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockReturnValue(undefined) + const key = queryKey() + const customError = new Error('custom unused-prefetch') + handler.mockImplementation(() => { + throw customError + }) + + const { rendered, errors } = renderThrowing((children) => ( + + {children} + + )) + + prefetch(key) + + await act(() => vi.advanceTimersByTimeAsync(10 + 5000)) + expect(rendered.queryByText('content')).toBeNull() + expect( + rendered.getByText('error: custom unused-prefetch'), + ).toBeInTheDocument() + expect(errors[0]).toBe(customError) + consoleMock.mockRestore() + }) + + it('should throw for some queries and report the others', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockReturnValue(undefined) + const reported = queryKey() + const thrown = queryKey() + handler.mockImplementation((event) => { + if (event.query.queryHash === getQuery(thrown)?.queryHash) { + throw toStrictModeError(event) + } + }) + + const { rendered, errors } = renderThrowing((children) => ( + + {children} + + )) + + prefetch(reported, 10) + prefetch(thrown, 20) + + await act(() => vi.advanceTimersByTimeAsync(10 + 5000)) + expect(rendered.getByText('content')).toBeInTheDocument() + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0]![0]).toMatchObject({ + type: 'unused-prefetch', + query: getQuery(reported), + }) + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.queryByText('content')).toBeNull() + expect(errors[0]).toMatchObject({ event: { query: getQuery(thrown) } }) + expect(handler).toHaveBeenCalledTimes(2) + consoleMock.mockRestore() + }) + }) + }) + + it('should not log anything when QueryStrictMode is not used', async () => { + const consoleErrorMock = vi.spyOn(console, 'error') + const consoleWarnMock = vi.spyOn(console, 'warn') + const suspended = queryKey() + const prefetched = queryKey() + + const rendered = renderWithClient( + queryClient, + + + , + ) + + void queryClient + .query({ + queryKey: prefetched, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + + await act(() => vi.advanceTimersByTimeAsync(10 + 10_000)) + expect(rendered.getByText(`data ${String(suspended)}`)).toBeInTheDocument() + expect(consoleErrorMock).not.toHaveBeenCalled() + expect(consoleWarnMock).not.toHaveBeenCalled() + }) + + it('should not log anything while restoring', async () => { + const prefetched = queryKey() + const suspended = queryKey() + + const rendered = renderWithClient( + queryClient, + + + + + + + , + ) + + void queryClient + .query({ + queryKey: prefetched, + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + + await act(() => vi.advanceTimersByTimeAsync(10 + 10_000)) + expect(rendered.getByText(`data ${String(suspended)}`)).toBeInTheDocument() + expect(handler).not.toHaveBeenCalled() + }) + + it('should only render children in production', async () => { + vi.stubEnv('NODE_ENV', 'production') + const key = queryKey() + + void queryClient + .query({ + queryKey: queryKey(), + queryFn: () => sleep(10).then(() => 'data'), + }) + .catch(noop) + + const rendered = renderWithClient( + queryClient, + + + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(10 + 10_000)) + expect(rendered.getByText(`data ${String(key)}`)).toBeInTheDocument() + expect(handler).not.toHaveBeenCalled() + vi.unstubAllEnvs() + }) +}) diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index 6f7197941fa..3233f2a705f 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -54,3 +54,17 @@ export { useMutation } from './useMutation' export { mutationOptions } from './mutationOptions' export { useInfiniteQuery } from './useInfiniteQuery' export { useIsRestoring, IsRestoringProvider } from './IsRestoringProvider' +export { QueryStrictMode } from './QueryStrictMode' +export { + QueryStrictModeError, + QueryStrictModeMissingPrefetchError, + QueryStrictModeUnusedPrefetchError, +} from './QueryStrictModeError' +export type { + QueryStrictModeEvent, + QueryStrictModeMissingPrefetchEvent, + QueryStrictModeMissingPrefetchHandler, + QueryStrictModeProps, + QueryStrictModeUnusedPrefetchEvent, + QueryStrictModeUnusedPrefetchHandler, +} from './QueryStrictModeTypes' diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index ce1a615aff7..b9987602478 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -10,6 +10,8 @@ import { useClearResetErrorBoundary, } from './errorBoundaryUtils' import { useIsRestoring } from './IsRestoringProvider' +import { useQueryStrictMode } from './QueryStrictModeContext' +import { checkQueryStrictMode } from './QueryStrictModeDev' import { ensureSuspenseTimers, fetchOptimistic, @@ -63,6 +65,12 @@ export function useBaseQuery< const isRestoring = useIsRestoring() const errorResetBoundary = useQueryErrorResetBoundary() + const strictMode = + process.env.NODE_ENV !== 'production' + ? // The condition is constant for a build, so the hooks are always called in the same order + // eslint-disable-next-line react-hooks-js/rules-of-hooks + useQueryStrictMode() + : null const client = useQueryClient(queryClient) const defaultedOptions = client.defaultQueryOptions(options) @@ -128,6 +136,16 @@ export function useBaseQuery< observer.setOptions(defaultedOptions) }, [defaultedOptions, observer]) + if (process.env.NODE_ENV !== 'production' && !isRestoring) { + checkQueryStrictMode( + strictMode, + client.getQueryCache(), + defaultedOptions.queryHash, + result, + !!defaultedOptions.suspense, + ) + } + // Handle suspense if (shouldSuspend(defaultedOptions, result)) { throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 6d5886698c2..e8a16826176 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -15,6 +15,8 @@ import { getHasError, useClearResetErrorBoundary, } from './errorBoundaryUtils' +import { useQueryStrictMode } from './QueryStrictModeContext' +import { checkQueryStrictMode } from './QueryStrictModeDev' import { ensureSuspenseTimers, fetchOptimistic, @@ -386,6 +388,12 @@ export function useQueries< const client = useQueryClient(queryClient) const isRestoring = useIsRestoring() const errorResetBoundary = useQueryErrorResetBoundary() + const strictMode = + process.env.NODE_ENV !== 'production' + ? // The condition is constant for a build, so the hooks are always called in the same order + // eslint-disable-next-line react-hooks-js/rules-of-hooks + useQueryStrictMode() + : null const subscribed = options.subscribed !== false const defaultedQueries = React.useMemo( @@ -451,6 +459,21 @@ export function useQueries< ) }, [defaultedQueries, options, observer]) + if (process.env.NODE_ENV !== 'production' && !isRestoring) { + optimisticResult.forEach((result, index) => { + const opts = defaultedQueries[index] + if (opts) { + checkQueryStrictMode( + strictMode, + client.getQueryCache(), + opts.queryHash, + result, + !!opts.suspense, + ) + } + }) + } + const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => shouldSuspend(defaultedQueries[index], result), )