@@ -14,10 +14,14 @@ import {
1414} from '..'
1515import { 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