Skip to content

Commit 660ab07

Browse files
committed
test(hydration): implement missing hydration tests
* Do not hydrate older promises * Do not infinite loop on hydrating failed promises * Hydrate already resolved promises immediately
1 parent c1142b9 commit 660ab07

File tree

2 files changed

+198
-11
lines changed

2 files changed

+198
-11
lines changed

packages/query-core/src/__tests__/hydration.test.tsx

Lines changed: 122 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -473,10 +473,12 @@ describe('dehydration and rehydration', () => {
473473
const serverAddTodo = vi
474474
.fn()
475475
.mockImplementation(() => Promise.reject(new Error('offline')))
476-
const serverOnMutate = vi.fn().mockImplementation((variables) => {
477-
const optimisticTodo = { id: 1, text: variables.text }
478-
return { optimisticTodo }
479-
})
476+
const serverOnMutate = vi
477+
.fn()
478+
.mockImplementation((variables: { text: string }) => {
479+
const optimisticTodo = { id: 1, text: variables.text }
480+
return { optimisticTodo }
481+
})
480482
const serverOnSuccess = vi.fn()
481483

482484
const serverClient = new QueryClient()
@@ -511,13 +513,17 @@ describe('dehydration and rehydration', () => {
511513
const parsed = JSON.parse(stringified)
512514
const client = new QueryClient()
513515

514-
const clientAddTodo = vi.fn().mockImplementation((variables) => {
515-
return { id: 2, text: variables.text }
516-
})
517-
const clientOnMutate = vi.fn().mockImplementation((variables) => {
518-
const optimisticTodo = { id: 1, text: variables.text }
519-
return { optimisticTodo }
520-
})
516+
const clientAddTodo = vi
517+
.fn()
518+
.mockImplementation((variables: { text: string }) => {
519+
return { id: 2, text: variables.text }
520+
})
521+
const clientOnMutate = vi
522+
.fn()
523+
.mockImplementation((variables: { text: string }) => {
524+
const optimisticTodo = { id: 1, text: variables.text }
525+
return { optimisticTodo }
526+
})
521527
const clientOnSuccess = vi.fn()
522528

523529
client.setMutationDefaults(['addTodo'], {
@@ -1116,6 +1122,60 @@ describe('dehydration and rehydration', () => {
11161122
serverQueryClient.clear()
11171123
})
11181124

1125+
test('should not overwrite query in cache if existing query is newer (with promise)', async () => {
1126+
// --- server ---
1127+
1128+
const serverQueryClient = new QueryClient({
1129+
defaultOptions: {
1130+
dehydrate: {
1131+
shouldDehydrateQuery: () => true,
1132+
},
1133+
},
1134+
})
1135+
1136+
const promise = serverQueryClient.prefetchQuery({
1137+
queryKey: ['data'],
1138+
queryFn: async () => {
1139+
await sleep(10)
1140+
return 'server data'
1141+
},
1142+
})
1143+
1144+
const dehydrated = dehydrate(serverQueryClient)
1145+
1146+
await vi.advanceTimersByTimeAsync(10)
1147+
await promise
1148+
1149+
// Pretend the output of this server part is cached for a long time
1150+
1151+
// --- client ---
1152+
1153+
await vi.advanceTimersByTimeAsync(10_000) // Arbitrary time in the future
1154+
1155+
const clientQueryClient = new QueryClient()
1156+
1157+
clientQueryClient.setQueryData(['data'], 'newer data', {
1158+
updatedAt: Date.now(),
1159+
})
1160+
1161+
hydrate(clientQueryClient, dehydrated)
1162+
1163+
// If the query was hydrated in error, it would still take some time for it
1164+
// to end up in the cache, so for the test to fail properly on regressions,
1165+
// wait for the fetchStatus to be idle
1166+
await vi.waitFor(() =>
1167+
expect(clientQueryClient.getQueryState(['data'])?.fetchStatus).toBe(
1168+
'idle',
1169+
),
1170+
)
1171+
await vi.waitFor(() =>
1172+
expect(clientQueryClient.getQueryData(['data'])).toBe('newer data'),
1173+
)
1174+
1175+
clientQueryClient.clear()
1176+
serverQueryClient.clear()
1177+
})
1178+
11191179
test('should overwrite data when a new promise is streamed in', async () => {
11201180
const serializeDataMock = vi.fn((data: any) => data)
11211181
const deserializeDataMock = vi.fn((data: any) => data)
@@ -1291,4 +1351,55 @@ describe('dehydration and rehydration', () => {
12911351
process.env.NODE_ENV = originalNodeEnv
12921352
consoleMock.mockRestore()
12931353
})
1354+
1355+
// When React hydrates promises across RSC/client boundaries, it passes
1356+
// them as special ReactPromise types. There are situations where the
1357+
// promise might have time to resolve before we end up hydrating it, in
1358+
// which case React will have made it a special synchronous thenable where
1359+
// .then() resolves immediately.
1360+
// In these cases it's important we hydrate the data synchronously, or else
1361+
// the data in the cache wont match the content that was rendered on the server.
1362+
// What can end up happening otherwise is that the content is visible from the
1363+
// server, but the client renders a Suspense fallback, only to immediately show
1364+
// the data again.
1365+
test('should rehydrate synchronous thenable immediately', async () => {
1366+
// --- server ---
1367+
1368+
const serverQueryClient = new QueryClient({
1369+
defaultOptions: {
1370+
dehydrate: {
1371+
shouldDehydrateQuery: () => true,
1372+
},
1373+
},
1374+
})
1375+
const originalPromise = serverQueryClient.prefetchQuery({
1376+
queryKey: ['data'],
1377+
queryFn: () => null,
1378+
})
1379+
1380+
const dehydrated = dehydrate(serverQueryClient)
1381+
1382+
// --- server end ---
1383+
1384+
// Simulate a synchronous thenable
1385+
// @ts-expect-error
1386+
dehydrated.queries[0].promise.then = (cb) => {
1387+
cb?.('server data')
1388+
}
1389+
1390+
// --- client ---
1391+
1392+
const clientQueryClient = new QueryClient()
1393+
hydrate(clientQueryClient, dehydrated)
1394+
1395+
// If data is already resolved, it should end up in the cache immediately
1396+
expect(clientQueryClient.getQueryData(['data'])).toBe('server data')
1397+
1398+
// Need to await the original promise or else it will get a cancellation
1399+
// error and test will fail
1400+
await originalPromise
1401+
1402+
clientQueryClient.clear()
1403+
serverQueryClient.clear()
1404+
})
12941405
})

packages/react-query/src/__tests__/HydrationBoundary.test.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
QueryClient,
99
QueryClientProvider,
1010
dehydrate,
11+
hydrate,
1112
useQuery,
1213
} from '..'
1314

@@ -364,4 +365,79 @@ describe('React hydration', () => {
364365
hydrateSpy.mockRestore()
365366
queryClient.clear()
366367
})
368+
369+
// https://github.com/TanStack/query/issues/8677
370+
test('should not infinite loop when hydrating promises that resolve to errors', async () => {
371+
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
372+
let hydrationCount = 0
373+
hydrateSpy.mockImplementation((...args: Parameters<typeof hydrate>) => {
374+
hydrationCount++
375+
// Arbitrary number
376+
if (hydrationCount > 10) {
377+
// This is a rough way to detect it. Calling hydrate multiple times with
378+
// the same data is usually fine, but in this case it indicates the
379+
// logic in HydrationBoundary is not working as expected.
380+
throw new Error('Too many hydrations detected')
381+
}
382+
return hydrate(...args)
383+
})
384+
385+
const prefetchQueryClient = new QueryClient({
386+
defaultOptions: {
387+
dehydrate: {
388+
shouldDehydrateQuery: () => true,
389+
},
390+
},
391+
})
392+
prefetchQueryClient.prefetchQuery({
393+
queryKey: ['promise'],
394+
queryFn: async () => {
395+
await sleep(10)
396+
return Promise.reject('Query failed')
397+
},
398+
})
399+
400+
const dehydratedState = dehydrate(prefetchQueryClient)
401+
402+
// Avoid redacted error in test
403+
dehydratedState.queries[0]?.promise?.catch(() => {})
404+
await vi.advanceTimersByTimeAsync(10)
405+
// Mimic what React/our synchronous thenable does for already rejected promises
406+
// @ts-expect-error
407+
dehydratedState.queries[0].promise.status = 'failure'
408+
409+
// For the bug to trigger, there needs to already be a query in the cache
410+
const queryClient = new QueryClient()
411+
await queryClient.prefetchQuery({
412+
queryKey: ['promise'],
413+
queryFn: () => 'existing',
414+
})
415+
416+
function Page() {
417+
const { data } = useQuery({
418+
queryKey: ['promise'],
419+
queryFn: () => sleep(10).then(() => ['new']),
420+
})
421+
return (
422+
<div>
423+
<h1>{data}</h1>
424+
</div>
425+
)
426+
}
427+
428+
const rendered = render(
429+
<QueryClientProvider client={queryClient}>
430+
<HydrationBoundary state={dehydratedState}>
431+
<Page />
432+
</HydrationBoundary>
433+
</QueryClientProvider>,
434+
)
435+
await vi.advanceTimersByTimeAsync(1)
436+
expect(rendered.getByText('existing')).toBeInTheDocument()
437+
await vi.advanceTimersByTimeAsync(10)
438+
expect(rendered.getByText('new')).toBeInTheDocument()
439+
hydrateSpy.mockRestore()
440+
prefetchQueryClient.clear()
441+
queryClient.clear()
442+
})
367443
})

0 commit comments

Comments
 (0)