From 2a83ad6861224ae5e73f3a2b47b3d9506e2955a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Curley?= Date: Mon, 14 Sep 2026 16:36:49 +0100 Subject: [PATCH 1/3] feat(react-query): support server snapshots during hydration - Add optional `serverSnapshot` support to `QueryClientProvider` - Prevent hydration mismatches when streamed queries resolve early --- .changeset/tidy-poems-guess.md | 5 + .../react-query/src/QueryClientProvider.tsx | 52 ++++- .../src/__tests__/ssr-hydration.test.tsx | 202 +++++++++++++++++- packages/react-query/src/useBaseQuery.ts | 56 ++++- 4 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 .changeset/tidy-poems-guess.md diff --git a/.changeset/tidy-poems-guess.md b/.changeset/tidy-poems-guess.md new file mode 100644 index 00000000000..c45b25c1ab6 --- /dev/null +++ b/.changeset/tidy-poems-guess.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': minor +--- + +Add an optional `serverSnapshot` prop to `QueryClientProvider`. When supplied with the same `DehydratedState` used to hydrate the client, `useQuery` and the other hooks built on `useBaseQuery` replay the frozen server-rendered result during hydration (through `useSyncExternalStore`'s server snapshot) before switching to the live cache. This prevents hydration mismatches when a streamed query promise resolves before the browser hydrates, which previously made the first client render differ from the server markup. diff --git a/packages/react-query/src/QueryClientProvider.tsx b/packages/react-query/src/QueryClientProvider.tsx index 26abacf685e..713f0da1e1c 100644 --- a/packages/react-query/src/QueryClientProvider.tsx +++ b/packages/react-query/src/QueryClientProvider.tsx @@ -1,7 +1,8 @@ 'use client' import * as React from 'react' -import type { QueryClient } from '@tanstack/query-core' +import { QueryCache, QueryClient } from '@tanstack/query-core' +import type { DehydratedState } from '@tanstack/query-core' /** * The context that `useQueryClient` reads from. `QueryClientProvider` is the normal way to set it. @@ -10,6 +11,16 @@ export const QueryClientContext = React.createContext( undefined, ) +/** + * Internal context that carries the frozen server snapshot used during hydration. The value is a + * throwaway `QueryClient` whose cache holds the exact query state that produced the server markup, + * so hooks can replay it instead of reading newer live cache data. `undefined` outside of SSR + * hydration (i.e. on the server without a provided snapshot, or after hydration). + */ +export const QueryServerSnapshotContext = React.createContext< + QueryClient | undefined +>(undefined) + /** * The `useQueryClient` hook returns the current `QueryClient` instance. * @@ -42,6 +53,17 @@ export type QueryClientProviderProps = { * The `QueryClient` instance to provide. */ client: QueryClient + /** + * Optional frozen snapshot of the query state that produced the server-rendered markup. When + * provided, hooks replay this state during hydration (via `useSyncExternalStore`'s server + * snapshot) before switching to the live cache, so the first client render matches the server + * output even if the live cache already advanced (e.g. a streamed promise resolved before + * hydration). + * + * Typically this is the same `DehydratedState` that was passed to `hydrate`/`HydrationBoundary`. + * Passing the server state here mirrors React Redux's `Provider serverState` API. + */ + serverSnapshot?: DehydratedState | null /** * The components that get access to the provided `QueryClient`. */ @@ -70,6 +92,7 @@ export type QueryClientProviderProps = { export const QueryClientProvider = ({ client, children, + serverSnapshot, }: QueryClientProviderProps): React.JSX.Element => { React.useEffect(() => { client.mount() @@ -78,9 +101,34 @@ export const QueryClientProvider = ({ } }, [client]) + // A throwaway client whose cache holds the server-rendered query state. We build queries directly + // from the dehydrated state (rather than calling `hydrate`) so the frozen `fetchStatus` is + // preserved and hooks replay exactly what the server rendered. + const snapshotClient = React.useMemo(() => { + if (!serverSnapshot) { + return undefined + } + + const queryCache = new QueryCache() + const frozenClient = new QueryClient({ queryCache }) + + serverSnapshot.queries.forEach(({ queryKey, queryHash, state, meta }) => { + queryCache.build( + frozenClient, + { queryKey, queryHash, meta }, + // Build from a copy so the caller's dehydrated state is never mutated. + { ...state }, + ) + }) + + return frozenClient + }, [serverSnapshot]) + return ( - {children} + + {children} + ) } diff --git a/packages/react-query/src/__tests__/ssr-hydration.test.tsx b/packages/react-query/src/__tests__/ssr-hydration.test.tsx index 6299a9560b3..72722eb65a7 100644 --- a/packages/react-query/src/__tests__/ssr-hydration.test.tsx +++ b/packages/react-query/src/__tests__/ssr-hydration.test.tsx @@ -14,10 +14,14 @@ import { } from '..' import { setIsServer } from './utils' -const ReactHydrate = (element: React.ReactElement, container: Element) => { +const ReactHydrate = ( + element: React.ReactElement, + container: Element, + options?: { onRecoverableError?: (error: unknown) => void }, +) => { let root: any act(() => { - root = hydrateRoot(container, element) + root = hydrateRoot(container, element, options) }) return () => { root.unmount() @@ -275,4 +279,198 @@ describe('Server side rendering with de/rehydration', () => { queryClient.clear() consoleMock.mockRestore() }) + + it('should not mismatch when a pending prefetched query resolves before client hydration', async () => { + const key = queryKey() + + let resolveQuery!: (value: string) => void + const pendingQuery = new Promise((resolve) => { + resolveQuery = resolve + }) + const queryFn = vi.fn(() => pendingQuery) + const renderedStates: Array = [] + + // -- Shared part -- + function SuccessComponent() { + const result = useQuery({ + queryKey: key, + queryFn, + }) + const rendered = `SuccessComponent - status:${result.status} fetching:${result.isFetching} data:${result.data}` + renderedStates.push(rendered) + return rendered + } + + // -- Server part -- + setIsServer(true) + + const prefetchClient = new QueryClient() + // Prefetch without awaiting: the query is dehydrated while still pending. + prefetchClient.prefetchQuery({ queryKey: key, queryFn }).catch(noop) + // Let the retryer start so the pending promise is part of the dehydrated state. + await vi.advanceTimersByTimeAsync(1) + const dehydratedStateServer = dehydrate(prefetchClient, { + shouldDehydrateQuery: () => true, + }) + expect(dehydratedStateServer.queries[0]?.promise).toBeDefined() + + const renderCache = new QueryCache() + const renderClient = new QueryClient({ queryCache: renderCache }) + hydrate(renderClient, dehydratedStateServer) + const markup = ReactDOMServer.renderToString( + + + , + ) + renderClient.clear() + setIsServer(false) + + const expectedMarkup = + 'SuccessComponent - status:pending fetching:true data:undefined' + + expect(markup).toBe(expectedMarkup) + + // -- Client part -- + renderedStates.length = 0 + const el = document.createElement('div') + el.innerHTML = markup + + const queryCache = new QueryCache() + const queryClient = new QueryClient({ queryCache }) + // Pass the dehydrated promise through (not JSON-serializable) to mimic a + // framework streaming the pending query's promise to the browser. + hydrate(queryClient, dehydratedStateServer) + + // The streamed promise resolves before React hydrates, so the live cache is + // already successful while the server markup shows the pending state. + resolveQuery('success!') + await vi.advanceTimersByTimeAsync(1) + + expect(queryClient.getQueryData(key)).toBe('success!') + + const onRecoverableError = vi.fn() + const unmount = ReactHydrate( + + + , + el, + { onRecoverableError }, + ) + + // Hydration must not report a mismatch, and the first client render must + // replay the frozen server snapshot (pending) even though the live cache is + // already successful. + expect(onRecoverableError).toHaveBeenCalledTimes(0) + expect(renderedStates[0]).toBe(expectedMarkup) + + await vi.advanceTimersByTimeAsync(50) + expect(renderedStates.at(-1)).toBe( + 'SuccessComponent - status:success fetching:false data:success!', + ) + expect(el.innerHTML).toBe( + 'SuccessComponent - status:success fetching:false data:success!', + ) + + unmount() + queryClient.clear() + }) + + // Adapted from the reproduction in + // https://github.com/TanStack/query/issues/9399#issuecomment-4323008704 — the streamed promise + // is simulated with a synchronously-resolvable thenable, so the client's `hydrate` resolves it + // via `tryResolveSync` during the first render. + it('should not mismatch on a query whose streamed promise is synchronously resolved by hydrate', async () => { + const key = queryKey() + const renderedStates: Array = [] + + function SuccessComponent() { + const result = useQuery({ + queryKey: key, + queryFn: () => Promise.resolve('success!'), + }) + const rendered = `SuccessComponent - status:${result.status} fetching:${result.isFetching} data:${result.data}` + renderedStates.push(rendered) + return rendered + } + + // -- Server -- + setIsServer(true) + const prefetchClient = new QueryClient({ + defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } }, + }) + let resolvePrefetch: ((value: string) => void) | undefined + const prefetchPromise = new Promise((resolve) => { + resolvePrefetch = resolve + }) + void prefetchClient.prefetchQuery({ + queryKey: key, + queryFn: () => prefetchPromise, + }) + + const dehydrated = dehydrate(prefetchClient) + expect(dehydrated.queries[0]?.state.status).toBe('pending') + + const renderClient = new QueryClient() + hydrate(renderClient, dehydrated) + const markup = ReactDOMServer.renderToString( + + + , + ) + renderClient.clear() + setIsServer(false) + + const expectedMarkup = + 'SuccessComponent - status:pending fetching:true data:undefined' + expect(markup).toBe(expectedMarkup) + + // The promise resolves *between* SSR and client hydration (streamed value arrives). + resolvePrefetch?.('success!') + const promiseRef = dehydrated.queries[0]?.promise + if (promiseRef) { + // Synchronously-resolvable thenable, mirroring a streamed React promise. + // @ts-expect-error deliberately replacing the native `then` so it resolves synchronously + promiseRef.then = (cb?: (value: unknown) => unknown) => { + cb?.('success!') + return promiseRef + } + } + + // -- Client -- + renderedStates.length = 0 + const el = document.createElement('div') + el.innerHTML = markup + const queryClient = new QueryClient() + hydrate(queryClient, dehydrated) + + expect(queryClient.getQueryData(key)).toBe('success!') + + const onRecoverableError = vi.fn() + const unmount = ReactHydrate( + + + , + el, + { onRecoverableError }, + ) + + // No mismatch, and the first client render replays the pending server snapshot even though + // `hydrate` resolved the streamed promise synchronously. + expect(onRecoverableError).toHaveBeenCalledTimes(0) + expect(renderedStates[0]).toBe(expectedMarkup) + + await vi.advanceTimersByTimeAsync(50) + expect(renderedStates.at(-1)).toBe( + 'SuccessComponent - status:success fetching:false data:success!', + ) + expect(el.innerHTML).toBe( + 'SuccessComponent - status:success fetching:false data:success!', + ) + + unmount() + queryClient.clear() + }) }) diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 30beffc14b6..069d7d8f0db 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -2,7 +2,10 @@ import * as React from 'react' import { noop, notifyManager } from '@tanstack/query-core' -import { useQueryClient } from './QueryClientProvider' +import { + QueryServerSnapshotContext, + useQueryClient, +} from './QueryClientProvider' import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' import { ensurePreventErrorBoundaryRetry, @@ -92,10 +95,45 @@ export function useBaseQuery< ) // note: this must be called before useSyncExternalStore - const result = observer.getOptimisticResult(defaultedOptions) + // The return value is intentionally discarded: the call's purpose is the side effect of priming + // the observer's `#currentResult`, which `getSnapshot` (`observer.getCurrentResult()`) reads. The + // rendered value comes from the `useSyncExternalStore` result below. + observer.getOptimisticResult(defaultedOptions) + + // Result to replay while React is hydrating, matching what the server rendered. Built once on a + // throwaway client so the live cache (which may already have advanced) is untouched. Computed in + // a lazy initializer rather than a memo because `defaultedOptions` is intentionally mutated above. + const snapshotClient = React.useContext(QueryServerSnapshotContext) + const [serverSnapshotResult] = React.useState< + QueryObserverResult | undefined + >(() => { + if (!snapshotClient) { + return undefined + } + + const snapshotQuery = snapshotClient + .getQueryCache() + .get(defaultedOptions.queryHash) + + if (!snapshotQuery) { + return undefined + } + + const snapshotObserver = new Observer(snapshotClient, defaultedOptions) + const snapshotResult = + snapshotObserver.getOptimisticResult(defaultedOptions) + snapshotObserver.destroy() + + return snapshotResult + }) + + const serverSnapshot = React.useCallback( + () => serverSnapshotResult ?? observer.getCurrentResult(), + [serverSnapshotResult, observer], + ) const shouldSubscribe = !isRestoring && subscribed - React.useSyncExternalStore( + const resultToRender = React.useSyncExternalStore( React.useCallback( (onStoreChange) => { const unsubscribe = shouldSubscribe @@ -111,7 +149,7 @@ export function useBaseQuery< [observer, shouldSubscribe], ), () => observer.getCurrentResult(), - () => observer.getCurrentResult(), + serverSnapshot, ) React.useEffect(() => { @@ -119,25 +157,25 @@ export function useBaseQuery< }, [defaultedOptions, observer]) // Handle suspense - if (shouldSuspend(defaultedOptions, result)) { + if (shouldSuspend(defaultedOptions, resultToRender)) { throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) } // Handle error boundary if ( getHasError({ - result, + result: resultToRender, errorResetBoundary, throwOnError: defaultedOptions.throwOnError, query, suspense: defaultedOptions.suspense, }) ) { - throw result.error + throw resultToRender.error } // Handle result property usage tracking return !defaultedOptions.notifyOnChangeProps - ? observer.trackResult(result) - : result + ? observer.trackResult(resultToRender) + : resultToRender } From 5fc2c1788b68d9f6bd933253ce02e8f6173cfebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Curley?= Date: Tue, 15 Sep 2026 15:14:13 +0100 Subject: [PATCH 2/3] feat(react-query): support server snapshot hydration options - Deserialize server snapshots with matching hydration options --- .changeset/tidy-poems-guess.md | 2 +- .../react-query/src/QueryClientProvider.tsx | 20 +++- .../src/__tests__/ssr-hydration.test.tsx | 99 +++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/.changeset/tidy-poems-guess.md b/.changeset/tidy-poems-guess.md index c45b25c1ab6..81c25df055f 100644 --- a/.changeset/tidy-poems-guess.md +++ b/.changeset/tidy-poems-guess.md @@ -2,4 +2,4 @@ '@tanstack/react-query': minor --- -Add an optional `serverSnapshot` prop to `QueryClientProvider`. When supplied with the same `DehydratedState` used to hydrate the client, `useQuery` and the other hooks built on `useBaseQuery` replay the frozen server-rendered result during hydration (through `useSyncExternalStore`'s server snapshot) before switching to the live cache. This prevents hydration mismatches when a streamed query promise resolves before the browser hydrates, which previously made the first client render differ from the server markup. +Add optional `serverSnapshot` and `serverSnapshotOptions` props to `QueryClientProvider`. When supplied with the same state and hydration options used to hydrate the client, `useQuery` and the other hooks built on `useBaseQuery` replay the frozen, deserialized server-rendered result during hydration (through `useSyncExternalStore`'s server snapshot) before switching to the live cache. This prevents hydration mismatches when a streamed query promise resolves before the browser hydrates, which previously made the first client render differ from the server markup. diff --git a/packages/react-query/src/QueryClientProvider.tsx b/packages/react-query/src/QueryClientProvider.tsx index 713f0da1e1c..3bdc6acac0e 100644 --- a/packages/react-query/src/QueryClientProvider.tsx +++ b/packages/react-query/src/QueryClientProvider.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { QueryCache, QueryClient } from '@tanstack/query-core' -import type { DehydratedState } from '@tanstack/query-core' +import type { DehydratedState, HydrateOptions } from '@tanstack/query-core' /** * The context that `useQueryClient` reads from. `QueryClientProvider` is the normal way to set it. @@ -64,6 +64,11 @@ export type QueryClientProviderProps = { * Passing the server state here mirrors React Redux's `Provider serverState` API. */ serverSnapshot?: DehydratedState | null + /** + * Options used to deserialize `serverSnapshot`. Pass the same options supplied to `hydrate` or + * `HydrationBoundary`. When omitted, the client's default hydration options are used. + */ + serverSnapshotOptions?: HydrateOptions /** * The components that get access to the provided `QueryClient`. */ @@ -93,6 +98,7 @@ export const QueryClientProvider = ({ client, children, serverSnapshot, + serverSnapshotOptions, }: QueryClientProviderProps): React.JSX.Element => { React.useEffect(() => { client.mount() @@ -111,18 +117,26 @@ export const QueryClientProvider = ({ const queryCache = new QueryCache() const frozenClient = new QueryClient({ queryCache }) + const deserializeData = + serverSnapshotOptions?.defaultOptions?.deserializeData ?? + client.getDefaultOptions().hydrate?.deserializeData serverSnapshot.queries.forEach(({ queryKey, queryHash, state, meta }) => { + const data = + state.data === undefined || !deserializeData + ? state.data + : deserializeData(state.data) + queryCache.build( frozenClient, { queryKey, queryHash, meta }, // Build from a copy so the caller's dehydrated state is never mutated. - { ...state }, + { ...state, data }, ) }) return frozenClient - }, [serverSnapshot]) + }, [client, serverSnapshot, serverSnapshotOptions]) return ( diff --git a/packages/react-query/src/__tests__/ssr-hydration.test.tsx b/packages/react-query/src/__tests__/ssr-hydration.test.tsx index 72722eb65a7..d460cae6ff9 100644 --- a/packages/react-query/src/__tests__/ssr-hydration.test.tsx +++ b/packages/react-query/src/__tests__/ssr-hydration.test.tsx @@ -4,6 +4,7 @@ import { act } from 'react' import * as ReactDOMServer from 'react-dom/server' import { queryKey } from '@tanstack/query-test-utils' import { + HydrationBoundary, QueryCache, QueryClient, QueryClientProvider, @@ -378,6 +379,104 @@ describe('Server side rendering with de/rehydration', () => { queryClient.clear() }) + it.each([ + ['hydrate options', 'hydrate'], + ['HydrationBoundary options', 'boundary'], + ['client defaults', 'defaults'], + ] as const)( + 'should deserialize the server snapshot with %s', + async (_source, optionSource) => { + const key = queryKey() + const date = new Date('2024-01-01T00:00:00.000Z') + const deserializeData = (data: unknown) => new Date(data as string) + const hydrationOptions = { + defaultOptions: { deserializeData }, + } + + function DateComponent() { + const { data } = useQuery({ + queryKey: key, + queryFn: () => Promise.resolve(date), + staleTime: Infinity, + }) + return data instanceof Date ? data.toISOString() : `serialized:${data}` + } + + setIsServer(true) + const prefetchClient = new QueryClient() + await prefetchClient.prefetchQuery({ + queryKey: key, + queryFn: () => Promise.resolve(date), + }) + const dehydrated = JSON.parse( + JSON.stringify( + dehydrate(prefetchClient, { + serializeData: (data) => (data as Date).toISOString(), + }), + ), + ) + + const renderClient = new QueryClient() + hydrate(renderClient, dehydrated, hydrationOptions) + const markup = ReactDOMServer.renderToString( + + + , + ) + renderClient.clear() + setIsServer(false) + + const queryClient = new QueryClient({ + defaultOptions: { + hydrate: { + deserializeData: + optionSource === 'defaults' + ? deserializeData + : (data) => `default:${data}`, + }, + }, + }) + if (optionSource === 'hydrate') { + hydrate(queryClient, dehydrated, hydrationOptions) + } else if (optionSource === 'defaults') { + hydrate(queryClient, dehydrated) + } + + const el = document.createElement('div') + el.innerHTML = markup + const onRecoverableError = vi.fn() + const children = + optionSource === 'boundary' ? ( + + + + ) : ( + + ) + const unmount = ReactHydrate( + + {children} + , + el, + { onRecoverableError }, + ) + + expect(markup).toBe(date.toISOString()) + expect(onRecoverableError).toHaveBeenCalledTimes(0) + expect(el.innerHTML).toBe(date.toISOString()) + + unmount() + queryClient.clear() + prefetchClient.clear() + }, + ) + // Adapted from the reproduction in // https://github.com/TanStack/query/issues/9399#issuecomment-4323008704 — the streamed promise // is simulated with a synchronously-resolvable thenable, so the client's `hydrate` resolves it From 956d7b8f4b80c98da4228e94e6dd11ecf3f5b9e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ciar=C3=A1n=20Curley?= Date: Wed, 16 Sep 2026 11:50:13 +0100 Subject: [PATCH 3/3] fix(react-query): scope server snapshots to hydration boundaries --- .changeset/tidy-poems-guess.md | 4 +- .../react-query/src/HydrationBoundary.tsx | 67 +++++++++- .../react-query/src/QueryClientProvider.tsx | 66 +-------- .../src/__tests__/ssr-hydration.test.tsx | 126 ++++++++++++++---- packages/react-query/src/useBaseQuery.ts | 6 +- 5 files changed, 166 insertions(+), 103 deletions(-) diff --git a/.changeset/tidy-poems-guess.md b/.changeset/tidy-poems-guess.md index 81c25df055f..bd8347c3417 100644 --- a/.changeset/tidy-poems-guess.md +++ b/.changeset/tidy-poems-guess.md @@ -1,5 +1,5 @@ --- -'@tanstack/react-query': minor +'@tanstack/react-query': patch --- -Add optional `serverSnapshot` and `serverSnapshotOptions` props to `QueryClientProvider`. When supplied with the same state and hydration options used to hydrate the client, `useQuery` and the other hooks built on `useBaseQuery` replay the frozen, deserialized server-rendered result during hydration (through `useSyncExternalStore`'s server snapshot) before switching to the live cache. This prevents hydration mismatches when a streamed query promise resolves before the browser hydrates, which previously made the first client render differ from the server markup. +Make queries inside a `HydrationBoundary` replay the boundary's server-rendered state during hydration before switching to the live cache. This prevents hydration mismatches when the cache changes before browser hydration, including when a query above the boundary creates an empty cache entry or a streamed promise resolves early. diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index ce03eb40c83..f18375c40f1 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -1,15 +1,23 @@ 'use client' import * as React from 'react' -import { hydrate } from '@tanstack/query-core' +import { QueryCache, QueryClient, hydrate } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' import type { DehydratedState, HydrateOptions, OmitKeyof, - QueryClient, } from '@tanstack/query-core' +/** + * Internal context that carries the frozen server snapshot for the nearest + * hydration boundary. Hooks use it to replay the result that produced the + * server markup instead of reading newer data from the live cache. + */ +export const QueryServerSnapshotContext = React.createContext< + QueryClient | undefined +>(undefined) + /** * The props accepted by `HydrationBoundary`. */ @@ -85,7 +93,7 @@ export interface HydrationBoundaryProps { */ export const HydrationBoundary = ({ children, - options = {}, + options, state, queryClient, }: HydrationBoundaryProps) => { @@ -96,6 +104,53 @@ export const HydrationBoundary = ({ optionsRef.current = options }) + // Keep an immutable copy of the query state that produced this boundary's + // server markup. We build the queries directly instead of calling `hydrate` + // because hydration deliberately changes fetchStatus and may resolve a + // streamed promise synchronously. + const snapshotClient = React.useMemo(() => { + if (!state || typeof state !== 'object') { + return undefined + } + + const queryCache = new QueryCache() + const clientOptions = client.getDefaultOptions().hydrate + const boundaryOptions = options?.defaultOptions + const frozenClient = new QueryClient({ queryCache }) + const deserializeData = + boundaryOptions?.deserializeData ?? clientOptions?.deserializeData + + // State is supplied from the outside, so handle an invalid shape + // gracefully just like the live-cache hydration below. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const queries = state.queries || [] + + queries.forEach( + ({ queryKey, queryHash, state: queryState, meta, queryType }) => { + const data = + queryState.data === undefined || !deserializeData + ? queryState.data + : deserializeData(queryState.data) + + queryCache.build( + frozenClient, + { + ...clientOptions?.queries, + ...boundaryOptions?.queries, + queryKey, + queryHash, + meta, + _type: queryType, + }, + // Copy the state so the caller's dehydrated state remains untouched. + { ...queryState, data }, + ) + }, + ) + + return frozenClient + }, [client, options, state]) + // This useMemo is for performance reasons only, everything inside it must // be safe to run in every render and code here should be read as "in render". // @@ -167,5 +222,9 @@ export const HydrationBoundary = ({ } }, [client, hydrationQueue]) - return children as React.ReactElement + return ( + + {children} + + ) } diff --git a/packages/react-query/src/QueryClientProvider.tsx b/packages/react-query/src/QueryClientProvider.tsx index 3bdc6acac0e..26abacf685e 100644 --- a/packages/react-query/src/QueryClientProvider.tsx +++ b/packages/react-query/src/QueryClientProvider.tsx @@ -1,8 +1,7 @@ 'use client' import * as React from 'react' -import { QueryCache, QueryClient } from '@tanstack/query-core' -import type { DehydratedState, HydrateOptions } from '@tanstack/query-core' +import type { QueryClient } from '@tanstack/query-core' /** * The context that `useQueryClient` reads from. `QueryClientProvider` is the normal way to set it. @@ -11,16 +10,6 @@ export const QueryClientContext = React.createContext( undefined, ) -/** - * Internal context that carries the frozen server snapshot used during hydration. The value is a - * throwaway `QueryClient` whose cache holds the exact query state that produced the server markup, - * so hooks can replay it instead of reading newer live cache data. `undefined` outside of SSR - * hydration (i.e. on the server without a provided snapshot, or after hydration). - */ -export const QueryServerSnapshotContext = React.createContext< - QueryClient | undefined ->(undefined) - /** * The `useQueryClient` hook returns the current `QueryClient` instance. * @@ -53,22 +42,6 @@ export type QueryClientProviderProps = { * The `QueryClient` instance to provide. */ client: QueryClient - /** - * Optional frozen snapshot of the query state that produced the server-rendered markup. When - * provided, hooks replay this state during hydration (via `useSyncExternalStore`'s server - * snapshot) before switching to the live cache, so the first client render matches the server - * output even if the live cache already advanced (e.g. a streamed promise resolved before - * hydration). - * - * Typically this is the same `DehydratedState` that was passed to `hydrate`/`HydrationBoundary`. - * Passing the server state here mirrors React Redux's `Provider serverState` API. - */ - serverSnapshot?: DehydratedState | null - /** - * Options used to deserialize `serverSnapshot`. Pass the same options supplied to `hydrate` or - * `HydrationBoundary`. When omitted, the client's default hydration options are used. - */ - serverSnapshotOptions?: HydrateOptions /** * The components that get access to the provided `QueryClient`. */ @@ -97,8 +70,6 @@ export type QueryClientProviderProps = { export const QueryClientProvider = ({ client, children, - serverSnapshot, - serverSnapshotOptions, }: QueryClientProviderProps): React.JSX.Element => { React.useEffect(() => { client.mount() @@ -107,42 +78,9 @@ export const QueryClientProvider = ({ } }, [client]) - // A throwaway client whose cache holds the server-rendered query state. We build queries directly - // from the dehydrated state (rather than calling `hydrate`) so the frozen `fetchStatus` is - // preserved and hooks replay exactly what the server rendered. - const snapshotClient = React.useMemo(() => { - if (!serverSnapshot) { - return undefined - } - - const queryCache = new QueryCache() - const frozenClient = new QueryClient({ queryCache }) - const deserializeData = - serverSnapshotOptions?.defaultOptions?.deserializeData ?? - client.getDefaultOptions().hydrate?.deserializeData - - serverSnapshot.queries.forEach(({ queryKey, queryHash, state, meta }) => { - const data = - state.data === undefined || !deserializeData - ? state.data - : deserializeData(state.data) - - queryCache.build( - frozenClient, - { queryKey, queryHash, meta }, - // Build from a copy so the caller's dehydrated state is never mutated. - { ...state, data }, - ) - }) - - return frozenClient - }, [client, serverSnapshot, serverSnapshotOptions]) - return ( - - {children} - + {children} ) } diff --git a/packages/react-query/src/__tests__/ssr-hydration.test.tsx b/packages/react-query/src/__tests__/ssr-hydration.test.tsx index d460cae6ff9..57501015caf 100644 --- a/packages/react-query/src/__tests__/ssr-hydration.test.tsx +++ b/packages/react-query/src/__tests__/ssr-hydration.test.tsx @@ -320,7 +320,9 @@ describe('Server side rendering with de/rehydration', () => { hydrate(renderClient, dehydratedStateServer) const markup = ReactDOMServer.renderToString( - + + + , ) renderClient.clear() @@ -351,11 +353,10 @@ describe('Server side rendering with de/rehydration', () => { const onRecoverableError = vi.fn() const unmount = ReactHydrate( - - + + + + , el, { onRecoverableError }, @@ -379,8 +380,80 @@ describe('Server side rendering with de/rehydration', () => { queryClient.clear() }) + it('should not mismatch when a query above the boundary creates an empty cache entry', async () => { + const key = queryKey() + const queryFn = () => new Promise(noop) + const renderedStates: Array = [] + + function Page() { + const result = useQuery({ queryKey: key, queryFn }) + const rendered = `${result.status}:${result.data}` + renderedStates.push(rendered) + return
{rendered}
+ } + + function App({ + client, + state, + }: { + client: QueryClient + state: ReturnType + }) { + return ( + + + + + + + ) + } + + const prefetchClient = new QueryClient() + prefetchClient.setQueryData(key, 'server data') + const dehydrated = dehydrate(prefetchClient) + + setIsServer(true) + const renderClient = new QueryClient() + const markup = ReactDOMServer.renderToString( + , + ) + setIsServer(false) + + expect(markup).toBe( + '
pending:undefined
success:server data
', + ) + + renderedStates.length = 0 + const el = document.createElement('div') + el.innerHTML = markup + const queryClient = new QueryClient() + const onRecoverableError = vi.fn() + const unmount = ReactHydrate( + , + el, + { onRecoverableError }, + ) + + expect(onRecoverableError).toHaveBeenCalledTimes(0) + expect(renderedStates.slice(0, 2)).toEqual([ + 'pending:undefined', + 'success:server data', + ]) + + await vi.advanceTimersByTimeAsync(1) + expect(Array.from(el.children, (child) => child.textContent)).toEqual([ + 'success:server data', + 'success:server data', + ]) + + unmount() + queryClient.clear() + renderClient.clear() + prefetchClient.clear() + }) + it.each([ - ['hydrate options', 'hydrate'], ['HydrationBoundary options', 'boundary'], ['client defaults', 'defaults'], ] as const)( @@ -420,7 +493,9 @@ describe('Server side rendering with de/rehydration', () => { hydrate(renderClient, dehydrated, hydrationOptions) const markup = ReactDOMServer.renderToString( - + + + , ) renderClient.clear() @@ -436,32 +511,21 @@ describe('Server side rendering with de/rehydration', () => { }, }, }) - if (optionSource === 'hydrate') { - hydrate(queryClient, dehydrated, hydrationOptions) - } else if (optionSource === 'defaults') { + if (optionSource === 'defaults') { hydrate(queryClient, dehydrated) } const el = document.createElement('div') el.innerHTML = markup const onRecoverableError = vi.fn() - const children = - optionSource === 'boundary' ? ( - + const unmount = ReactHydrate( + + - ) : ( - - ) - const unmount = ReactHydrate( - - {children} , el, { onRecoverableError }, @@ -516,7 +580,9 @@ describe('Server side rendering with de/rehydration', () => { hydrate(renderClient, dehydrated) const markup = ReactDOMServer.renderToString( - + + + , ) renderClient.clear() @@ -549,8 +615,10 @@ describe('Server side rendering with de/rehydration', () => { const onRecoverableError = vi.fn() const unmount = ReactHydrate( - - + + + + , el, { onRecoverableError }, diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 069d7d8f0db..2e1d865298f 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -2,10 +2,8 @@ import * as React from 'react' import { noop, notifyManager } from '@tanstack/query-core' -import { - QueryServerSnapshotContext, - useQueryClient, -} from './QueryClientProvider' +import { useQueryClient } from './QueryClientProvider' +import { QueryServerSnapshotContext } from './HydrationBoundary' import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' import { ensurePreventErrorBoundaryRetry,