Skip to content

Commit 2a83ad6

Browse files
committed
feat(react-query): support server snapshots during hydration
- Add optional `serverSnapshot` support to `QueryClientProvider` - Prevent hydration mismatches when streamed queries resolve early
1 parent 12027ef commit 2a83ad6

4 files changed

Lines changed: 302 additions & 13 deletions

File tree

.changeset/tidy-poems-guess.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/react-query': minor
3+
---
4+
5+
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.

packages/react-query/src/QueryClientProvider.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22
import * as React from 'react'
33

4-
import type { QueryClient } from '@tanstack/query-core'
4+
import { QueryCache, QueryClient } from '@tanstack/query-core'
5+
import type { DehydratedState } from '@tanstack/query-core'
56

67
/**
78
* The context that `useQueryClient` reads from. `QueryClientProvider` is the normal way to set it.
@@ -10,6 +11,16 @@ export const QueryClientContext = React.createContext<QueryClient | undefined>(
1011
undefined,
1112
)
1213

14+
/**
15+
* Internal context that carries the frozen server snapshot used during hydration. The value is a
16+
* throwaway `QueryClient` whose cache holds the exact query state that produced the server markup,
17+
* so hooks can replay it instead of reading newer live cache data. `undefined` outside of SSR
18+
* hydration (i.e. on the server without a provided snapshot, or after hydration).
19+
*/
20+
export const QueryServerSnapshotContext = React.createContext<
21+
QueryClient | undefined
22+
>(undefined)
23+
1324
/**
1425
* The `useQueryClient` hook returns the current `QueryClient` instance.
1526
*
@@ -42,6 +53,17 @@ export type QueryClientProviderProps = {
4253
* The `QueryClient` instance to provide.
4354
*/
4455
client: QueryClient
56+
/**
57+
* Optional frozen snapshot of the query state that produced the server-rendered markup. When
58+
* provided, hooks replay this state during hydration (via `useSyncExternalStore`'s server
59+
* snapshot) before switching to the live cache, so the first client render matches the server
60+
* output even if the live cache already advanced (e.g. a streamed promise resolved before
61+
* hydration).
62+
*
63+
* Typically this is the same `DehydratedState` that was passed to `hydrate`/`HydrationBoundary`.
64+
* Passing the server state here mirrors React Redux's `Provider serverState` API.
65+
*/
66+
serverSnapshot?: DehydratedState | null
4567
/**
4668
* The components that get access to the provided `QueryClient`.
4769
*/
@@ -70,6 +92,7 @@ export type QueryClientProviderProps = {
7092
export const QueryClientProvider = ({
7193
client,
7294
children,
95+
serverSnapshot,
7396
}: QueryClientProviderProps): React.JSX.Element => {
7497
React.useEffect(() => {
7598
client.mount()
@@ -78,9 +101,34 @@ export const QueryClientProvider = ({
78101
}
79102
}, [client])
80103

104+
// A throwaway client whose cache holds the server-rendered query state. We build queries directly
105+
// from the dehydrated state (rather than calling `hydrate`) so the frozen `fetchStatus` is
106+
// preserved and hooks replay exactly what the server rendered.
107+
const snapshotClient = React.useMemo(() => {
108+
if (!serverSnapshot) {
109+
return undefined
110+
}
111+
112+
const queryCache = new QueryCache()
113+
const frozenClient = new QueryClient({ queryCache })
114+
115+
serverSnapshot.queries.forEach(({ queryKey, queryHash, state, meta }) => {
116+
queryCache.build(
117+
frozenClient,
118+
{ queryKey, queryHash, meta },
119+
// Build from a copy so the caller's dehydrated state is never mutated.
120+
{ ...state },
121+
)
122+
})
123+
124+
return frozenClient
125+
}, [serverSnapshot])
126+
81127
return (
82128
<QueryClientContext.Provider value={client}>
83-
{children}
129+
<QueryServerSnapshotContext.Provider value={snapshotClient}>
130+
{children}
131+
</QueryServerSnapshotContext.Provider>
84132
</QueryClientContext.Provider>
85133
)
86134
}

packages/react-query/src/__tests__/ssr-hydration.test.tsx

Lines changed: 200 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ import {
1414
} from '..'
1515
import { setIsServer } from './utils'
1616

17-
const ReactHydrate = (element: React.ReactElement, container: Element) => {
17+
const ReactHydrate = (
18+
element: React.ReactElement,
19+
container: Element,
20+
options?: { onRecoverableError?: (error: unknown) => void },
21+
) => {
1822
let root: any
1923
act(() => {
20-
root = hydrateRoot(container, element)
24+
root = hydrateRoot(container, element, options)
2125
})
2226
return () => {
2327
root.unmount()
@@ -275,4 +279,198 @@ describe('Server side rendering with de/rehydration', () => {
275279
queryClient.clear()
276280
consoleMock.mockRestore()
277281
})
282+
283+
it('should not mismatch when a pending prefetched query resolves before client hydration', async () => {
284+
const key = queryKey()
285+
286+
let resolveQuery!: (value: string) => void
287+
const pendingQuery = new Promise<string>((resolve) => {
288+
resolveQuery = resolve
289+
})
290+
const queryFn = vi.fn(() => pendingQuery)
291+
const renderedStates: Array<string> = []
292+
293+
// -- Shared part --
294+
function SuccessComponent() {
295+
const result = useQuery({
296+
queryKey: key,
297+
queryFn,
298+
})
299+
const rendered = `SuccessComponent - status:${result.status} fetching:${result.isFetching} data:${result.data}`
300+
renderedStates.push(rendered)
301+
return rendered
302+
}
303+
304+
// -- Server part --
305+
setIsServer(true)
306+
307+
const prefetchClient = new QueryClient()
308+
// Prefetch without awaiting: the query is dehydrated while still pending.
309+
prefetchClient.prefetchQuery({ queryKey: key, queryFn }).catch(noop)
310+
// Let the retryer start so the pending promise is part of the dehydrated state.
311+
await vi.advanceTimersByTimeAsync(1)
312+
const dehydratedStateServer = dehydrate(prefetchClient, {
313+
shouldDehydrateQuery: () => true,
314+
})
315+
expect(dehydratedStateServer.queries[0]?.promise).toBeDefined()
316+
317+
const renderCache = new QueryCache()
318+
const renderClient = new QueryClient({ queryCache: renderCache })
319+
hydrate(renderClient, dehydratedStateServer)
320+
const markup = ReactDOMServer.renderToString(
321+
<QueryClientProvider client={renderClient}>
322+
<SuccessComponent />
323+
</QueryClientProvider>,
324+
)
325+
renderClient.clear()
326+
setIsServer(false)
327+
328+
const expectedMarkup =
329+
'SuccessComponent - status:pending fetching:true data:undefined'
330+
331+
expect(markup).toBe(expectedMarkup)
332+
333+
// -- Client part --
334+
renderedStates.length = 0
335+
const el = document.createElement('div')
336+
el.innerHTML = markup
337+
338+
const queryCache = new QueryCache()
339+
const queryClient = new QueryClient({ queryCache })
340+
// Pass the dehydrated promise through (not JSON-serializable) to mimic a
341+
// framework streaming the pending query's promise to the browser.
342+
hydrate(queryClient, dehydratedStateServer)
343+
344+
// The streamed promise resolves before React hydrates, so the live cache is
345+
// already successful while the server markup shows the pending state.
346+
resolveQuery('success!')
347+
await vi.advanceTimersByTimeAsync(1)
348+
349+
expect(queryClient.getQueryData(key)).toBe('success!')
350+
351+
const onRecoverableError = vi.fn()
352+
const unmount = ReactHydrate(
353+
<QueryClientProvider
354+
client={queryClient}
355+
serverSnapshot={dehydratedStateServer}
356+
>
357+
<SuccessComponent />
358+
</QueryClientProvider>,
359+
el,
360+
{ onRecoverableError },
361+
)
362+
363+
// Hydration must not report a mismatch, and the first client render must
364+
// replay the frozen server snapshot (pending) even though the live cache is
365+
// already successful.
366+
expect(onRecoverableError).toHaveBeenCalledTimes(0)
367+
expect(renderedStates[0]).toBe(expectedMarkup)
368+
369+
await vi.advanceTimersByTimeAsync(50)
370+
expect(renderedStates.at(-1)).toBe(
371+
'SuccessComponent - status:success fetching:false data:success!',
372+
)
373+
expect(el.innerHTML).toBe(
374+
'SuccessComponent - status:success fetching:false data:success!',
375+
)
376+
377+
unmount()
378+
queryClient.clear()
379+
})
380+
381+
// Adapted from the reproduction in
382+
// https://github.com/TanStack/query/issues/9399#issuecomment-4323008704 — the streamed promise
383+
// is simulated with a synchronously-resolvable thenable, so the client's `hydrate` resolves it
384+
// via `tryResolveSync` during the first render.
385+
it('should not mismatch on a query whose streamed promise is synchronously resolved by hydrate', async () => {
386+
const key = queryKey()
387+
const renderedStates: Array<string> = []
388+
389+
function SuccessComponent() {
390+
const result = useQuery({
391+
queryKey: key,
392+
queryFn: () => Promise.resolve('success!'),
393+
})
394+
const rendered = `SuccessComponent - status:${result.status} fetching:${result.isFetching} data:${result.data}`
395+
renderedStates.push(rendered)
396+
return rendered
397+
}
398+
399+
// -- Server --
400+
setIsServer(true)
401+
const prefetchClient = new QueryClient({
402+
defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } },
403+
})
404+
let resolvePrefetch: ((value: string) => void) | undefined
405+
const prefetchPromise = new Promise<string>((resolve) => {
406+
resolvePrefetch = resolve
407+
})
408+
void prefetchClient.prefetchQuery({
409+
queryKey: key,
410+
queryFn: () => prefetchPromise,
411+
})
412+
413+
const dehydrated = dehydrate(prefetchClient)
414+
expect(dehydrated.queries[0]?.state.status).toBe('pending')
415+
416+
const renderClient = new QueryClient()
417+
hydrate(renderClient, dehydrated)
418+
const markup = ReactDOMServer.renderToString(
419+
<QueryClientProvider client={renderClient}>
420+
<SuccessComponent />
421+
</QueryClientProvider>,
422+
)
423+
renderClient.clear()
424+
setIsServer(false)
425+
426+
const expectedMarkup =
427+
'SuccessComponent - status:pending fetching:true data:undefined'
428+
expect(markup).toBe(expectedMarkup)
429+
430+
// The promise resolves *between* SSR and client hydration (streamed value arrives).
431+
resolvePrefetch?.('success!')
432+
const promiseRef = dehydrated.queries[0]?.promise
433+
if (promiseRef) {
434+
// Synchronously-resolvable thenable, mirroring a streamed React promise.
435+
// @ts-expect-error deliberately replacing the native `then` so it resolves synchronously
436+
promiseRef.then = (cb?: (value: unknown) => unknown) => {
437+
cb?.('success!')
438+
return promiseRef
439+
}
440+
}
441+
442+
// -- Client --
443+
renderedStates.length = 0
444+
const el = document.createElement('div')
445+
el.innerHTML = markup
446+
const queryClient = new QueryClient()
447+
hydrate(queryClient, dehydrated)
448+
449+
expect(queryClient.getQueryData(key)).toBe('success!')
450+
451+
const onRecoverableError = vi.fn()
452+
const unmount = ReactHydrate(
453+
<QueryClientProvider client={queryClient} serverSnapshot={dehydrated}>
454+
<SuccessComponent />
455+
</QueryClientProvider>,
456+
el,
457+
{ onRecoverableError },
458+
)
459+
460+
// No mismatch, and the first client render replays the pending server snapshot even though
461+
// `hydrate` resolved the streamed promise synchronously.
462+
expect(onRecoverableError).toHaveBeenCalledTimes(0)
463+
expect(renderedStates[0]).toBe(expectedMarkup)
464+
465+
await vi.advanceTimersByTimeAsync(50)
466+
expect(renderedStates.at(-1)).toBe(
467+
'SuccessComponent - status:success fetching:false data:success!',
468+
)
469+
expect(el.innerHTML).toBe(
470+
'SuccessComponent - status:success fetching:false data:success!',
471+
)
472+
473+
unmount()
474+
queryClient.clear()
475+
})
278476
})

0 commit comments

Comments
 (0)