Skip to content

Commit d4b7818

Browse files
authored
fix(core): do not hydrate promises if existing query is already fetching
1 parent f2b6caf commit d4b7818

File tree

3 files changed

+26
-16
lines changed

3 files changed

+26
-16
lines changed

packages/query-core/src/hydration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ export function hydrate(
203203

204204
let query = queryCache.get(queryHash)
205205
const existingQueryIsPending = query?.state.status === 'pending'
206+
const existingQueryIsFetching = query?.state.fetchStatus === 'fetching'
206207

207208
// Do not hydrate if an existing query exists with newer data
208209
if (query) {
@@ -249,6 +250,7 @@ export function hydrate(
249250
if (
250251
promise &&
251252
!existingQueryIsPending &&
253+
!existingQueryIsFetching &&
252254
// Only hydrate if dehydration is newer than any existing data,
253255
// this is always true for new queries
254256
(dehydratedAt === undefined || dehydratedAt > query.state.dataUpdatedAt)

packages/react-query/src/HydrationBoundary.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export const HydrationBoundary = ({
7676
existingQuery.state.dataUpdatedAt ||
7777
(dehydratedQuery.promise &&
7878
existingQuery.state.status !== 'pending' &&
79+
existingQuery.state.fetchStatus !== 'fetching' &&
7980
dehydratedQuery.dehydratedAt !== undefined &&
8081
dehydratedQuery.dehydratedAt > existingQuery.state.dataUpdatedAt)
8182

@@ -110,7 +111,6 @@ export const HydrationBoundary = ({
110111
React.useEffect(() => {
111112
if (hydrationQueue) {
112113
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
113-
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
114114
setHydrationQueue(undefined)
115115
}
116116
}, [client, hydrationQueue])

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

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import {
88
QueryClient,
99
QueryClientProvider,
1010
dehydrate,
11-
hydrate,
1211
useQuery,
1312
} from '..'
13+
import type { hydrate } from '@tanstack/query-core'
1414

1515
describe('React hydration', () => {
1616
let stringifiedState: string
@@ -368,6 +368,7 @@ describe('React hydration', () => {
368368

369369
// https://github.com/TanStack/query/issues/8677
370370
test('should not infinite loop when hydrating promises that resolve to errors', async () => {
371+
const originalHydrate = coreModule.hydrate
371372
const hydrateSpy = vi.spyOn(coreModule, 'hydrate')
372373
let hydrationCount = 0
373374
hydrateSpy.mockImplementation((...args: Parameters<typeof hydrate>) => {
@@ -379,9 +380,19 @@ describe('React hydration', () => {
379380
// logic in HydrationBoundary is not working as expected.
380381
throw new Error('Too many hydrations detected')
381382
}
382-
return hydrate(...args)
383+
return originalHydrate(...args)
383384
})
384385

386+
// For the bug to trigger, there needs to already be a query in the cache,
387+
// with a dataUpdatedAt earlier than the dehydratedAt of the next query
388+
const clientQueryClient = new QueryClient()
389+
await clientQueryClient.prefetchQuery({
390+
queryKey: ['promise'],
391+
queryFn: () => 'existing',
392+
})
393+
394+
await vi.advanceTimersByTimeAsync(100)
395+
385396
const prefetchQueryClient = new QueryClient({
386397
defaultOptions: {
387398
dehydrate: {
@@ -393,26 +404,21 @@ describe('React hydration', () => {
393404
queryKey: ['promise'],
394405
queryFn: async () => {
395406
await sleep(10)
396-
return Promise.reject('Query failed')
407+
throw new Error('Query failed')
397408
},
398409
})
399410

400411
const dehydratedState = dehydrate(prefetchQueryClient)
401412

402-
// Avoid redacted error in test
403-
dehydratedState.queries[0]?.promise?.catch(() => {})
404-
await vi.advanceTimersByTimeAsync(10)
413+
function ignore() {
414+
// Ignore redacted unhandled rejection
415+
}
416+
process.addListener('unhandledRejection', ignore)
417+
405418
// Mimic what React/our synchronous thenable does for already rejected promises
406419
// @ts-expect-error
407420
dehydratedState.queries[0].promise.status = 'failure'
408421

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-
416422
function Page() {
417423
const { data } = useQuery({
418424
queryKey: ['promise'],
@@ -426,7 +432,7 @@ describe('React hydration', () => {
426432
}
427433

428434
const rendered = render(
429-
<QueryClientProvider client={queryClient}>
435+
<QueryClientProvider client={clientQueryClient}>
430436
<HydrationBoundary state={dehydratedState}>
431437
<Page />
432438
</HydrationBoundary>
@@ -436,8 +442,10 @@ describe('React hydration', () => {
436442
expect(rendered.getByText('existing')).toBeInTheDocument()
437443
await vi.advanceTimersByTimeAsync(10)
438444
expect(rendered.getByText('new')).toBeInTheDocument()
445+
446+
process.removeListener('unhandledRejection', ignore)
439447
hydrateSpy.mockRestore()
440448
prefetchQueryClient.clear()
441-
queryClient.clear()
449+
clientQueryClient.clear()
442450
})
443451
})

0 commit comments

Comments
 (0)