Skip to content

Commit 1bb0d23

Browse files
authored
fix(query-core): fix hydration bugs for already resolved promises (TanStack#10444)
1 parent ad517e5 commit 1bb0d23

3 files changed

Lines changed: 201 additions & 5 deletions

File tree

.changeset/shy-wings-buy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/query-core': patch
3+
---
4+
5+
Fix bugs where hydrating queries with promises that had already resolved could cause queries to briefly and incorrectly show as pending/fetching

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

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,4 +1385,177 @@ describe('dehydration and rehydration', () => {
13851385
// error and test will fail
13861386
await originalPromise
13871387
})
1388+
1389+
// Companion to the test above: when the query already exists in the cache
1390+
// (e.g. after an initial render or a first hydration pass), the same
1391+
// synchronous thenable resolution must also produce status: 'success'.
1392+
// Previously the if (query) branch would spread status: 'pending' from the
1393+
// server state without correcting it for the resolved data.
1394+
it('should set status to success when rehydrating an existing pending query with a synchronously resolved promise', async () => {
1395+
const key = queryKey()
1396+
// --- server ---
1397+
1398+
const serverQueryClient = new QueryClient({
1399+
defaultOptions: {
1400+
dehydrate: { shouldDehydrateQuery: () => true },
1401+
},
1402+
})
1403+
1404+
let resolvePrefetch: undefined | ((value?: unknown) => void)
1405+
const prefetchPromise = new Promise((res) => {
1406+
resolvePrefetch = res
1407+
})
1408+
// Keep the query pending so it dehydrates with status: 'pending' and a promise
1409+
void serverQueryClient.prefetchQuery({
1410+
queryKey: key,
1411+
queryFn: () => prefetchPromise,
1412+
})
1413+
1414+
const dehydrated = dehydrate(serverQueryClient)
1415+
expect(dehydrated.queries[0]?.state.status).toBe('pending')
1416+
1417+
// Simulate a synchronous thenable – models a React streaming promise that
1418+
// resolved before the second hydrate() call.
1419+
resolvePrefetch?.('server data')
1420+
// @ts-expect-error
1421+
dehydrated.queries[0].promise.then = (cb) => {
1422+
cb?.('server data')
1423+
// @ts-expect-error
1424+
return dehydrated.queries[0].promise
1425+
}
1426+
1427+
// --- client ---
1428+
// Query already exists in the cache in a pending state, as it would after
1429+
// a first hydration pass or an initial render.
1430+
const clientQueryClient = new QueryClient()
1431+
void clientQueryClient.prefetchQuery({
1432+
queryKey: key,
1433+
queryFn: () => {
1434+
throw new Error('QueryFn on client should not be called')
1435+
},
1436+
})
1437+
1438+
const query = clientQueryClient.getQueryCache().find({ queryKey: key })!
1439+
expect(query.state.status).toBe('pending')
1440+
1441+
hydrate(clientQueryClient, dehydrated)
1442+
1443+
expect(clientQueryClient.getQueryData(key)).toBe('server data')
1444+
expect(query.state.status).toBe('success')
1445+
1446+
clientQueryClient.clear()
1447+
serverQueryClient.clear()
1448+
})
1449+
1450+
it('should not transition to a fetching/pending state when hydrating an already resolved promise into a new query', async () => {
1451+
const key = queryKey()
1452+
// --- server ---
1453+
const serverQueryClient = new QueryClient({
1454+
defaultOptions: {
1455+
dehydrate: { shouldDehydrateQuery: () => true },
1456+
},
1457+
})
1458+
1459+
let resolvePrefetch: undefined | ((value?: unknown) => void)
1460+
const prefetchPromise = new Promise((res) => {
1461+
resolvePrefetch = res
1462+
})
1463+
void serverQueryClient.prefetchQuery({
1464+
queryKey: key,
1465+
queryFn: () => prefetchPromise,
1466+
})
1467+
const dehydrated = dehydrate(serverQueryClient)
1468+
1469+
// Simulate a synchronous thenable – the promise was already resolved
1470+
// before we hydrate on the client
1471+
resolvePrefetch?.('server data')
1472+
// @ts-expect-error
1473+
dehydrated.queries[0].promise.then = (cb) => {
1474+
cb?.('server data')
1475+
// @ts-expect-error
1476+
return dehydrated.queries[0].promise
1477+
}
1478+
1479+
// --- client ---
1480+
const clientQueryClient = new QueryClient()
1481+
1482+
const states: Array<{ status: string; fetchStatus: string }> = []
1483+
const unsubscribe = clientQueryClient.getQueryCache().subscribe((event) => {
1484+
if (event.type === 'updated') {
1485+
const { status, fetchStatus } = event.query.state
1486+
states.push({ status, fetchStatus })
1487+
}
1488+
})
1489+
1490+
hydrate(clientQueryClient, dehydrated)
1491+
await vi.advanceTimersByTimeAsync(0)
1492+
unsubscribe()
1493+
1494+
expect(states).not.toContainEqual(
1495+
expect.objectContaining({ fetchStatus: 'fetching' }),
1496+
)
1497+
expect(states).not.toContainEqual(
1498+
expect.objectContaining({ status: 'pending' }),
1499+
)
1500+
1501+
clientQueryClient.clear()
1502+
serverQueryClient.clear()
1503+
})
1504+
1505+
it('should not transition to a fetching/pending state when hydrating an already resolved promise into an existing query', async () => {
1506+
const key = queryKey()
1507+
// --- server ---
1508+
const serverQueryClient = new QueryClient({
1509+
defaultOptions: {
1510+
dehydrate: { shouldDehydrateQuery: () => true },
1511+
},
1512+
})
1513+
1514+
let resolvePrefetch: undefined | ((value?: unknown) => void)
1515+
const prefetchPromise = new Promise((res) => {
1516+
resolvePrefetch = res
1517+
})
1518+
void serverQueryClient.prefetchQuery({
1519+
queryKey: key,
1520+
queryFn: () => prefetchPromise,
1521+
})
1522+
const dehydrated = dehydrate(serverQueryClient)
1523+
1524+
// Simulate a synchronous thenable – the promise was already resolved
1525+
// before we hydrate on the client
1526+
resolvePrefetch?.('server data')
1527+
// @ts-expect-error
1528+
dehydrated.queries[0].promise.then = (cb) => {
1529+
cb?.('server data')
1530+
// @ts-expect-error
1531+
return dehydrated.queries[0].promise
1532+
}
1533+
1534+
// --- client ---
1535+
// Pre-populate with old data (updatedAt: 0 ensures dehydratedAt is newer)
1536+
const clientQueryClient = new QueryClient()
1537+
clientQueryClient.setQueryData(key, 'old data', { updatedAt: 0 })
1538+
1539+
const states: Array<{ status: string; fetchStatus: string }> = []
1540+
const unsubscribe = clientQueryClient.getQueryCache().subscribe((event) => {
1541+
if (event.type === 'updated') {
1542+
const { status, fetchStatus } = event.query.state
1543+
states.push({ status, fetchStatus })
1544+
}
1545+
})
1546+
1547+
hydrate(clientQueryClient, dehydrated)
1548+
await vi.advanceTimersByTimeAsync(0)
1549+
unsubscribe()
1550+
1551+
expect(states).not.toContainEqual(
1552+
expect.objectContaining({ fetchStatus: 'fetching' }),
1553+
)
1554+
expect(states).not.toContainEqual(
1555+
expect.objectContaining({ status: 'pending' }),
1556+
)
1557+
1558+
clientQueryClient.clear()
1559+
serverQueryClient.clear()
1560+
})
13881561
})

packages/query-core/src/hydration.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -230,12 +230,24 @@ export function hydrate(
230230
state.dataUpdatedAt > query.state.dataUpdatedAt ||
231231
hasNewerSyncData
232232
) {
233-
// omit fetchStatus from dehydrated state
234-
// so that query stays in its current fetchStatus
233+
// Omit fetchStatus from dehydrated state so that query stays in its current fetchStatus
235234
const { fetchStatus: _ignored, ...serializedState } = state
236235
query.setState({
237236
...serializedState,
238237
data,
238+
// If the query was pending at the moment of dehydration, but resolved to have data
239+
// before hydration, we can assume the query should be hydrated as successful.
240+
//
241+
// Since you can opt into dehydrating failed queries, and those can have data from
242+
// previous successful fetches, we make sure we only do this for pending queries.
243+
...(state.status === 'pending' &&
244+
data !== undefined && {
245+
status: 'success' as const,
246+
// Preserve existing fetchStatus if the existing query is actively fetching.
247+
...(!existingQueryIsFetching && {
248+
fetchStatus: 'idle' as const,
249+
}),
250+
}),
239251
})
240252
}
241253
} else {
@@ -255,13 +267,21 @@ export function hydrate(
255267
...state,
256268
data,
257269
fetchStatus: 'idle',
258-
status: data !== undefined ? 'success' : state.status,
270+
// Like above, if the query was pending at the moment of dehydration but has data,
271+
// we can assume it should be hydrated as successful.
272+
status:
273+
state.status === 'pending' && data !== undefined
274+
? 'success'
275+
: state.status,
259276
},
260277
)
261278
}
262279

263280
if (
264281
promise &&
282+
// If the data was synchronously available, there is no need to set up
283+
// a retryer and thus no reason to call fetch
284+
!syncData &&
265285
!existingQueryIsPending &&
266286
!existingQueryIsFetching &&
267287
// Only hydrate if dehydration is newer than any existing data,
@@ -270,8 +290,6 @@ export function hydrate(
270290
) {
271291
// This doesn't actually fetch - it just creates a retryer
272292
// which will re-use the passed `initialPromise`
273-
// Note that we need to call these even when data was synchronously
274-
// available, as we still need to set up the retryer
275293
query
276294
.fetch(undefined, {
277295
// RSC transformed promises are not thenable

0 commit comments

Comments
 (0)