Skip to content

Commit c7f1470

Browse files
authored
Merge branch 'main' into feature/ref/setstateoptions
2 parents d8e4789 + d6a7bf3 commit c7f1470

10 files changed

Lines changed: 307 additions & 32 deletions

File tree

.changeset/stupid-seals-live.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: preserve infinite query behavior during SSR hydration (#8825)

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

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,6 +1386,252 @@ describe('dehydration and rehydration', () => {
13861386
await originalPromise
13871387
})
13881388

1389+
it('should preserve queryType for infinite queries during hydration', async () => {
1390+
const queryCache = new QueryCache()
1391+
const queryClient = new QueryClient({ queryCache })
1392+
1393+
await vi.waitFor(() =>
1394+
queryClient.prefetchInfiniteQuery({
1395+
queryKey: ['infinite'],
1396+
queryFn: async ({ pageParam }) =>
1397+
sleep(0).then(() => ({
1398+
items: [`page-${pageParam}`],
1399+
nextCursor: pageParam + 1,
1400+
})),
1401+
initialPageParam: 0,
1402+
getNextPageParam: (lastPage: {
1403+
items: Array<string>
1404+
nextCursor: number
1405+
}) => lastPage.nextCursor,
1406+
}),
1407+
)
1408+
1409+
const dehydrated = dehydrate(queryClient)
1410+
1411+
const infiniteQueryState = dehydrated.queries.find(
1412+
(q) => q.queryKey[0] === 'infinite',
1413+
)
1414+
expect(infiniteQueryState?.queryType).toBe('infinite')
1415+
1416+
const hydrationCache = new QueryCache()
1417+
const hydrationClient = new QueryClient({ queryCache: hydrationCache })
1418+
hydrate(hydrationClient, dehydrated)
1419+
1420+
const hydratedQuery = hydrationCache.find({ queryKey: ['infinite'] })
1421+
expect(hydratedQuery?.state.data).toBeDefined()
1422+
expect(hydratedQuery?.state.data).toHaveProperty('pages')
1423+
expect(hydratedQuery?.state.data).toHaveProperty('pageParams')
1424+
expect((hydratedQuery?.state.data as any).pages).toHaveLength(1)
1425+
})
1426+
1427+
it('should attach infiniteQueryBehavior during hydration', async () => {
1428+
const queryCache = new QueryCache()
1429+
const queryClient = new QueryClient({ queryCache })
1430+
1431+
await vi.waitFor(() =>
1432+
queryClient.prefetchInfiniteQuery({
1433+
queryKey: ['infinite-with-behavior'],
1434+
queryFn: async ({ pageParam }) =>
1435+
sleep(0).then(() => ({
1436+
data: `page-${pageParam}`,
1437+
next: pageParam + 1,
1438+
})),
1439+
initialPageParam: 0,
1440+
getNextPageParam: (lastPage: { data: string; next: number }) =>
1441+
lastPage.next,
1442+
}),
1443+
)
1444+
1445+
const dehydrated = dehydrate(queryClient)
1446+
1447+
const hydrationCache = new QueryCache()
1448+
const hydrationClient = new QueryClient({ queryCache: hydrationCache })
1449+
hydrate(hydrationClient, dehydrated)
1450+
1451+
const result = await vi.waitFor(() =>
1452+
hydrationClient.fetchInfiniteQuery({
1453+
queryKey: ['infinite-with-behavior'],
1454+
queryFn: async ({ pageParam }) =>
1455+
sleep(0).then(() => ({
1456+
data: `page-${pageParam}`,
1457+
next: pageParam + 1,
1458+
})),
1459+
initialPageParam: 0,
1460+
getNextPageParam: (lastPage: { data: string; next: number }) =>
1461+
lastPage.next,
1462+
}),
1463+
)
1464+
1465+
expect(result.pages).toHaveLength(1)
1466+
expect(result.pageParams).toHaveLength(1)
1467+
})
1468+
1469+
it('should restore infinite query type through dehydrate and hydrate cycle', async () => {
1470+
const serverClient = new QueryClient({ queryCache: new QueryCache() })
1471+
1472+
await vi.waitFor(() =>
1473+
serverClient.prefetchInfiniteQuery({
1474+
queryKey: ['infinite-type-restore'],
1475+
queryFn: async ({ pageParam }) =>
1476+
sleep(0).then(() => ({
1477+
items: [`item-${pageParam}`],
1478+
next: pageParam + 1,
1479+
})),
1480+
initialPageParam: 0,
1481+
getNextPageParam: (lastPage: { items: Array<string>; next: number }) =>
1482+
lastPage.next,
1483+
}),
1484+
)
1485+
1486+
const dehydrated = dehydrate(serverClient)
1487+
1488+
const dehydratedQuery = dehydrated.queries.find(
1489+
(q) => q.queryKey[0] === 'infinite-type-restore',
1490+
)
1491+
expect(dehydratedQuery?.queryType).toBe('infinite')
1492+
1493+
const clientCache = new QueryCache()
1494+
const clientClient = new QueryClient({ queryCache: clientCache })
1495+
hydrate(clientClient, dehydrated)
1496+
1497+
const hydratedQuery = clientCache.find({
1498+
queryKey: ['infinite-type-restore'],
1499+
})
1500+
expect(hydratedQuery?.queryType).toBe('infinite')
1501+
})
1502+
1503+
it('should preserve pages structure when refetching infinite query after hydration', async () => {
1504+
const serverClient = new QueryClient({ queryCache: new QueryCache() })
1505+
1506+
await vi.waitFor(() =>
1507+
serverClient.prefetchInfiniteQuery({
1508+
queryKey: ['refetch'],
1509+
queryFn: async ({ pageParam }) =>
1510+
sleep(0).then(() => ({
1511+
items: [`page-${pageParam}`],
1512+
next: pageParam + 1,
1513+
})),
1514+
initialPageParam: 0,
1515+
getNextPageParam: (lastPage: { items: Array<string>; next: number }) =>
1516+
lastPage.next,
1517+
}),
1518+
)
1519+
1520+
const dehydrated = dehydrate(serverClient)
1521+
1522+
const clientCache = new QueryCache()
1523+
const clientClient = new QueryClient({ queryCache: clientCache })
1524+
hydrate(clientClient, dehydrated)
1525+
1526+
const beforeRefetch = clientClient.getQueryData<{
1527+
pages: Array<{ items: Array<string>; next: number }>
1528+
pageParams: Array<unknown>
1529+
}>(['refetch'])
1530+
expect(beforeRefetch?.pages).toHaveLength(1)
1531+
expect(beforeRefetch?.pageParams).toHaveLength(1)
1532+
1533+
const result = await vi.waitFor(() =>
1534+
clientClient.fetchInfiniteQuery({
1535+
queryKey: ['refetch'],
1536+
queryFn: async ({ pageParam }) =>
1537+
sleep(0).then(() => ({
1538+
items: [`page-${pageParam}`],
1539+
next: pageParam + 1,
1540+
})),
1541+
initialPageParam: 0,
1542+
getNextPageParam: (lastPage: { items: Array<string>; next: number }) =>
1543+
lastPage.next,
1544+
}),
1545+
)
1546+
1547+
expect(result).toHaveProperty('pages')
1548+
expect(result).toHaveProperty('pageParams')
1549+
expect(Array.isArray(result.pages)).toBe(true)
1550+
expect(result.pages).toHaveLength(1)
1551+
expect(result.pages[0]).toHaveProperty('items')
1552+
})
1553+
1554+
it('should retain infinite query type after subsequent setOptions calls', async () => {
1555+
const serverClient = new QueryClient({ queryCache: new QueryCache() })
1556+
1557+
await vi.waitFor(() =>
1558+
serverClient.prefetchInfiniteQuery({
1559+
queryKey: ['infinite-setoptions-guard'],
1560+
queryFn: async ({ pageParam }) =>
1561+
sleep(0).then(() => ({
1562+
data: `p${pageParam}`,
1563+
next: pageParam + 1,
1564+
})),
1565+
initialPageParam: 0,
1566+
getNextPageParam: (lastPage: { data: string; next: number }) =>
1567+
lastPage.next,
1568+
}),
1569+
)
1570+
1571+
const dehydrated = dehydrate(serverClient)
1572+
1573+
const clientCache = new QueryCache()
1574+
const clientClient = new QueryClient({ queryCache: clientCache })
1575+
hydrate(clientClient, dehydrated)
1576+
1577+
const query = clientCache.find({ queryKey: ['infinite-setoptions-guard'] })!
1578+
expect(query.queryType).toBe('infinite')
1579+
1580+
query.setOptions({ queryKey: ['infinite-setoptions-guard'] })
1581+
expect(query.queryType).toBe('infinite')
1582+
})
1583+
1584+
it('should restore all pages when refetching multi-page infinite query after hydration', async () => {
1585+
const serverClient = new QueryClient({ queryCache: new QueryCache() })
1586+
1587+
await vi.waitFor(() =>
1588+
serverClient.prefetchInfiniteQuery({
1589+
queryKey: ['infinite-multipage-restore'],
1590+
queryFn: async ({ pageParam }) =>
1591+
sleep(0).then(() => ({
1592+
items: [`item-${pageParam}`],
1593+
next: pageParam + 1,
1594+
})),
1595+
initialPageParam: 0,
1596+
pages: 2,
1597+
getNextPageParam: (lastPage: { items: Array<string>; next: number }) =>
1598+
lastPage.next,
1599+
}),
1600+
)
1601+
1602+
const dehydrated = dehydrate(serverClient)
1603+
1604+
const clientCache = new QueryCache()
1605+
const clientClient = new QueryClient({ queryCache: clientCache })
1606+
hydrate(clientClient, dehydrated)
1607+
1608+
const beforeRefetch = clientClient.getQueryData<{
1609+
pages: Array<unknown>
1610+
pageParams: Array<unknown>
1611+
}>(['infinite-multipage-restore'])
1612+
expect(beforeRefetch?.pages).toHaveLength(2)
1613+
1614+
const result = await vi.waitFor(() =>
1615+
clientClient.fetchInfiniteQuery({
1616+
queryKey: ['infinite-multipage-restore'],
1617+
queryFn: async ({ pageParam }) =>
1618+
sleep(0).then(() => ({
1619+
items: [`item-${pageParam}`],
1620+
next: pageParam + 1,
1621+
})),
1622+
initialPageParam: 0,
1623+
pages: 2,
1624+
getNextPageParam: (lastPage: { items: Array<string>; next: number }) =>
1625+
lastPage.next,
1626+
}),
1627+
)
1628+
1629+
expect(result.pages).toHaveLength(2)
1630+
expect(result.pageParams).toHaveLength(2)
1631+
expect(result.pages[0]).toHaveProperty('items')
1632+
expect(result.pages[1]).toHaveProperty('items')
1633+
})
1634+
13891635
// Companion to the test above: when the query already exists in the cache
13901636
// (e.g. after an initial render or a first hydration pass), the same
13911637
// synchronous thenable resolution must also produce status: 'success'.

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,7 @@ describe('InfiniteQueryObserver', () => {
235235

236236
const result = observer.getOptimisticResult(options)
237237

238-
expect(options.behavior).toBeDefined()
239-
expect(options.behavior?.onFetch).toBeDefined()
238+
expect(options._type).toBe('infinite')
240239

241240
expect(result).toMatchObject({
242241
data: undefined,

packages/query-core/src/hydration.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ interface DehydratedQuery {
4848
state: QueryState
4949
promise?: Promise<unknown>
5050
meta?: QueryMeta
51+
queryType?: 'infinite'
5152
// This is only optional because older versions of Query might have dehydrated
5253
// without it which we need to handle for backwards compatibility.
5354
// This should be changed to required in the future.
@@ -117,6 +118,7 @@ function dehydrateQuery(
117118
promise: dehydratePromise(),
118119
}),
119120
...(query.meta && { meta: query.meta }),
121+
...(query.queryType && { queryType: query.queryType }),
120122
}
121123
}
122124

@@ -209,7 +211,15 @@ export function hydrate(
209211
})
210212

211213
queries.forEach(
212-
({ queryKey, state, queryHash, meta, promise, dehydratedAt }) => {
214+
({
215+
queryKey,
216+
state,
217+
queryHash,
218+
meta,
219+
promise,
220+
dehydratedAt,
221+
queryType,
222+
}) => {
213223
const syncData = promise ? tryResolveSync(promise) : undefined
214224
const rawData = state.data === undefined ? syncData?.data : state.data
215225
const data = rawData === undefined ? rawData : deserializeData(rawData)
@@ -260,6 +270,7 @@ export function hydrate(
260270
queryKey,
261271
queryHash,
262272
meta,
273+
_type: queryType,
263274
},
264275
// Reset fetch status to idle to avoid
265276
// query being stuck in fetching state upon hydration

packages/query-core/src/infiniteQueryObserver.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import { QueryObserver } from './queryObserver'
2-
import {
3-
hasNextPage,
4-
hasPreviousPage,
5-
infiniteQueryBehavior,
6-
} from './infiniteQueryBehavior'
2+
import { hasNextPage, hasPreviousPage } from './infiniteQueryBehavior'
73
import type { Subscribable } from './subscribable'
84
import type {
95
DefaultError,
@@ -93,10 +89,8 @@ export class InfiniteQueryObserver<
9389
TPageParam
9490
>,
9591
): void {
96-
super.setOptions({
97-
...options,
98-
behavior: infiniteQueryBehavior(),
99-
})
92+
options._type = 'infinite'
93+
super.setOptions(options)
10094
}
10195

10296
getOptimisticResult(
@@ -108,7 +102,7 @@ export class InfiniteQueryObserver<
108102
TPageParam
109103
>,
110104
): InfiniteQueryObserverResult<TData, TError> {
111-
options.behavior = infiniteQueryBehavior()
105+
options._type = 'infinite'
112106
return super.getOptimisticResult(options) as InfiniteQueryObserverResult<
113107
TData,
114108
TError

packages/query-core/src/query.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { notifyManager } from './notifyManager'
1111
import { CancelledError, canFetch, createRetryer } from './retryer'
1212
import { Removable } from './removable'
13+
import { infiniteQueryBehavior } from './infiniteQueryBehavior'
1314
import type { QueryCache } from './queryCache'
1415
import type { QueryClient } from './queryClient'
1516
import type {
@@ -161,6 +162,7 @@ export class Query<
161162
queryHash: string
162163
options!: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
163164
state: QueryState<TData, TError>
165+
#queryType?: 'infinite'
164166

165167
#initialState: QueryState<TData, TError>
166168
#revertState?: QueryState<TData, TError>
@@ -190,6 +192,10 @@ export class Query<
190192
return this.options.meta
191193
}
192194

195+
get queryType() {
196+
return this.#queryType
197+
}
198+
193199
get promise(): Promise<TData> | undefined {
194200
return this.#retryer?.promise
195201
}
@@ -199,6 +205,10 @@ export class Query<
199205
): void {
200206
this.options = { ...this.#defaultOptions, ...options }
201207

208+
if (options?._type) {
209+
this.#queryType = options._type
210+
}
211+
202212
this.updateGcTime(this.options.gcTime)
203213

204214
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -503,7 +513,13 @@ export class Query<
503513

504514
const context = createFetchContext()
505515

506-
this.options.behavior?.onFetch(context, this as unknown as Query)
516+
const behavior =
517+
this.#queryType === 'infinite'
518+
? (infiniteQueryBehavior(
519+
(this.options as { pages?: number }).pages,
520+
) as QueryBehavior<TQueryFnData, TError, TData, TQueryKey>)
521+
: this.options.behavior
522+
behavior?.onFetch(context, this as unknown as Query)
507523

508524
// Store state in case the current fetch needs to be reverted
509525
this.#revertState = this.state

0 commit comments

Comments
 (0)