Skip to content

Commit 909d953

Browse files
committed
fix(query-core,react-query): preserve infinite query type through failed SSR hydration
1 parent ccedf33 commit 909d953

4 files changed

Lines changed: 476 additions & 2 deletions

File tree

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

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,4 +1399,207 @@ describe('dehydration and rehydration', () => {
13991399
// error and test will fail
14001400
await originalPromise
14011401
})
1402+
1403+
test('should preserve infinite query type when hydrating failed promise', async () => {
1404+
const queryClient = new QueryClient({
1405+
defaultOptions: {
1406+
dehydrate: {
1407+
shouldDehydrateQuery: () => true,
1408+
},
1409+
},
1410+
})
1411+
1412+
const promise = queryClient
1413+
.prefetchInfiniteQuery({
1414+
queryKey: ['infinite', 'failed'],
1415+
queryFn: () => Promise.reject(new Error('fetch failed')),
1416+
initialPageParam: 0,
1417+
getNextPageParam: () => 1,
1418+
retry: false,
1419+
})
1420+
.catch(() => {})
1421+
1422+
const dehydrated = dehydrate(queryClient)
1423+
1424+
const hydrationClient = new QueryClient()
1425+
hydrate(hydrationClient, dehydrated)
1426+
1427+
const hydratedQuery = hydrationClient.getQueryCache().find({
1428+
queryKey: ['infinite', 'failed'],
1429+
})
1430+
1431+
expect(hydratedQuery?.isInfiniteQuery).toBe(true)
1432+
1433+
await promise
1434+
})
1435+
1436+
test('should mark infinite queries with isInfiniteQuery flag during dehydration', async () => {
1437+
const queryClient = new QueryClient({
1438+
defaultOptions: {
1439+
dehydrate: { shouldDehydrateQuery: () => true },
1440+
},
1441+
})
1442+
1443+
await queryClient.prefetchInfiniteQuery({
1444+
queryKey: ['infinite'],
1445+
queryFn: ({ pageParam = 0 }) => Promise.resolve(`page-${pageParam}`),
1446+
initialPageParam: 0,
1447+
getNextPageParam: (_lastPage: any, pages: any) => pages.length,
1448+
retry: false,
1449+
})
1450+
1451+
await queryClient.prefetchQuery({
1452+
queryKey: ['regular'],
1453+
queryFn: () => Promise.resolve('data'),
1454+
})
1455+
1456+
const dehydrated = dehydrate(queryClient)
1457+
1458+
const infiniteQuery = dehydrated.queries.find(
1459+
(q) => q.queryKey[0] === 'infinite',
1460+
)
1461+
expect(infiniteQuery?.isInfiniteQuery).toBe(true)
1462+
1463+
const regularQuery = dehydrated.queries.find(
1464+
(q) => q.queryKey[0] === 'regular',
1465+
)
1466+
expect(regularQuery?.isInfiniteQuery).toBeUndefined()
1467+
})
1468+
1469+
test('should preserve isInfiniteQuery flag through hydration', async () => {
1470+
const queryClient = new QueryClient({
1471+
defaultOptions: {
1472+
dehydrate: { shouldDehydrateQuery: () => true },
1473+
},
1474+
})
1475+
1476+
await queryClient
1477+
.prefetchInfiniteQuery({
1478+
queryKey: ['infinite'],
1479+
queryFn: () => Promise.reject(new Error('Failed')),
1480+
initialPageParam: 0,
1481+
getNextPageParam: () => 1,
1482+
retry: false,
1483+
})
1484+
.catch(() => {})
1485+
1486+
const dehydrated = dehydrate(queryClient)
1487+
1488+
expect(dehydrated.queries[0]?.isInfiniteQuery).toBe(true)
1489+
1490+
const newClient = new QueryClient()
1491+
hydrate(newClient, dehydrated)
1492+
1493+
const hydratedQuery = newClient.getQueryCache().find({
1494+
queryKey: ['infinite'],
1495+
})
1496+
1497+
expect(hydratedQuery?.isInfiniteQuery).toBe(true)
1498+
})
1499+
1500+
test('should handle JSON serialization of dehydrated infinite queries', async () => {
1501+
const queryClient = new QueryClient({
1502+
defaultOptions: {
1503+
dehydrate: { shouldDehydrateQuery: () => true },
1504+
},
1505+
})
1506+
1507+
await queryClient
1508+
.prefetchInfiniteQuery({
1509+
queryKey: ['infinite'],
1510+
queryFn: () => Promise.reject(new Error('Failed')),
1511+
initialPageParam: 0,
1512+
getNextPageParam: () => 1,
1513+
retry: false,
1514+
})
1515+
.catch(() => {})
1516+
1517+
const dehydrated = dehydrate(queryClient)
1518+
1519+
const serialized = JSON.stringify(dehydrated)
1520+
const deserialized = JSON.parse(serialized)
1521+
1522+
expect(deserialized.queries[0]?.isInfiniteQuery).toBe(true)
1523+
1524+
const newClient = new QueryClient()
1525+
hydrate(newClient, deserialized)
1526+
1527+
const hydratedQuery = newClient.getQueryCache().find({
1528+
queryKey: ['infinite'],
1529+
})
1530+
1531+
expect(hydratedQuery?.isInfiniteQuery).toBe(true)
1532+
})
1533+
1534+
test('should not affect regular query hydration', async () => {
1535+
const queryClient = new QueryClient({
1536+
defaultOptions: {
1537+
dehydrate: { shouldDehydrateQuery: () => true },
1538+
},
1539+
})
1540+
1541+
await Promise.all([
1542+
queryClient.prefetchQuery({
1543+
queryKey: ['regular1'],
1544+
queryFn: () => Promise.resolve('data1'),
1545+
}),
1546+
queryClient.prefetchInfiniteQuery({
1547+
queryKey: ['infinite1'],
1548+
queryFn: () => Promise.resolve('page1'),
1549+
initialPageParam: 0,
1550+
getNextPageParam: () => 1,
1551+
}),
1552+
queryClient
1553+
.prefetchQuery({
1554+
queryKey: ['regular2'],
1555+
queryFn: () => Promise.reject(new Error('Failed')),
1556+
retry: false,
1557+
})
1558+
.catch(() => {}),
1559+
])
1560+
1561+
const dehydrated = dehydrate(queryClient)
1562+
const newClient = new QueryClient()
1563+
hydrate(newClient, dehydrated)
1564+
1565+
const regular1 = newClient.getQueryCache().find({ queryKey: ['regular1'] })
1566+
const infinite1 = newClient
1567+
.getQueryCache()
1568+
.find({ queryKey: ['infinite1'] })
1569+
const regular2 = newClient.getQueryCache().find({ queryKey: ['regular2'] })
1570+
1571+
expect(regular1?.isInfiniteQuery).toBeUndefined()
1572+
expect(infinite1?.isInfiniteQuery).toBe(true)
1573+
expect(regular2?.isInfiniteQuery).toBeUndefined()
1574+
1575+
expect(regular1?.state.data).toBe('data1')
1576+
})
1577+
1578+
test('should handle nested infinite query keys correctly', async () => {
1579+
const queryClient = new QueryClient({
1580+
defaultOptions: {
1581+
dehydrate: { shouldDehydrateQuery: () => true },
1582+
},
1583+
})
1584+
1585+
await queryClient
1586+
.prefetchInfiniteQuery({
1587+
queryKey: ['posts', { userId: 1, filter: 'active' }],
1588+
queryFn: () => Promise.reject(new Error('Failed')),
1589+
initialPageParam: 0,
1590+
getNextPageParam: () => 1,
1591+
retry: false,
1592+
})
1593+
.catch(() => {})
1594+
1595+
const dehydrated = dehydrate(queryClient)
1596+
const newClient = new QueryClient()
1597+
hydrate(newClient, dehydrated)
1598+
1599+
const hydratedQuery = newClient.getQueryCache().find({
1600+
queryKey: ['posts', { userId: 1, filter: 'active' }],
1601+
})
1602+
1603+
expect(hydratedQuery?.isInfiniteQuery).toBe(true)
1604+
})
14021605
})

packages/query-core/src/hydration.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ interface DehydratedQuery {
5151
// without it which we need to handle for backwards compatibility.
5252
// This should be changed to required in the future.
5353
dehydratedAt?: number
54+
isInfiniteQuery?: boolean
5455
}
5556

5657
export interface DehydratedState {
@@ -104,6 +105,7 @@ function dehydrateQuery(
104105
}),
105106
}),
106107
...(query.meta && { meta: query.meta }),
108+
...(query.options.behavior && { isInfiniteQuery: true }),
107109
}
108110
}
109111

@@ -196,7 +198,15 @@ export function hydrate(
196198
})
197199

198200
queries.forEach(
199-
({ queryKey, state, queryHash, meta, promise, dehydratedAt }) => {
201+
({
202+
queryKey,
203+
state,
204+
queryHash,
205+
meta,
206+
promise,
207+
dehydratedAt,
208+
isInfiniteQuery,
209+
}) => {
200210
const syncData = promise ? tryResolveSync(promise) : undefined
201211
const rawData = state.data === undefined ? syncData?.data : state.data
202212
const data = rawData === undefined ? rawData : deserializeData(rawData)
@@ -245,6 +255,10 @@ export function hydrate(
245255
status: data !== undefined ? 'success' : state.status,
246256
},
247257
)
258+
259+
if (isInfiniteQuery) {
260+
query.setIsInfiniteQuery(true)
261+
}
248262
}
249263

250264
if (

packages/query-core/src/query.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export class Query<
175175
observers: Array<QueryObserver<any, any, any, any, any>>
176176
#defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
177177
#abortSignalConsumed: boolean
178+
#isInfiniteQuery?: boolean
178179

179180
constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
180181
super()
@@ -199,6 +200,10 @@ export class Query<
199200
return this.#retryer?.promise
200201
}
201202

203+
get isInfiniteQuery(): boolean | undefined {
204+
return this.#isInfiniteQuery
205+
}
206+
202207
setOptions(
203208
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
204209
): void {
@@ -249,6 +254,10 @@ export class Query<
249254
this.#dispatch({ type: 'setState', state, setStateOptions })
250255
}
251256

257+
setIsInfiniteQuery(value: boolean): void {
258+
this.#isInfiniteQuery = value
259+
}
260+
252261
cancel(options?: CancelOptions): Promise<void> {
253262
const promise = this.#retryer?.promise
254263
this.#retryer?.cancel(options)
@@ -395,7 +404,17 @@ export class Query<
395404
// pending state when that happens
396405
this.#retryer?.status() !== 'rejected'
397406
) {
398-
if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
407+
const isStuckHydratedInfinite =
408+
this.#isInfiniteQuery &&
409+
this.state.data === undefined &&
410+
this.#retryer?.status() === 'pending' &&
411+
options?.behavior &&
412+
fetchOptions?.cancelRefetch
413+
414+
if (isStuckHydratedInfinite) {
415+
// Silently cancel current fetch if the user wants to cancel refetch
416+
this.cancel({ silent: true })
417+
} else if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
399418
// Silently cancel current fetch if the user wants to cancel refetch
400419
this.cancel({ silent: true })
401420
} else if (this.#retryer) {

0 commit comments

Comments
 (0)