diff --git a/.changeset/tidy-poems-guess.md b/.changeset/tidy-poems-guess.md new file mode 100644 index 0000000000..bd8347c341 --- /dev/null +++ b/.changeset/tidy-poems-guess.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': patch +--- + +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 ce03eb40c8..f18375c40f 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/__tests__/ssr-hydration.test.tsx b/packages/react-query/src/__tests__/ssr-hydration.test.tsx index 6299a9560b..57501015ca 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, @@ -14,10 +15,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 +280,364 @@ 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() + }) + + 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([ + ['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 === 'defaults') { + hydrate(queryClient, dehydrated) + } + + const el = document.createElement('div') + el.innerHTML = markup + const onRecoverableError = vi.fn() + const unmount = ReactHydrate( + + + + + , + 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 + // 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 30beffc14b..2e1d865298 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -3,6 +3,7 @@ import * as React from 'react' import { noop, notifyManager } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' +import { QueryServerSnapshotContext } from './HydrationBoundary' import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' import { ensurePreventErrorBoundaryRetry, @@ -92,10 +93,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 +147,7 @@ export function useBaseQuery< [observer, shouldSubscribe], ), () => observer.getCurrentResult(), - () => observer.getCurrentResult(), + serverSnapshot, ) React.useEffect(() => { @@ -119,25 +155,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 }