Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stupid-seals-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-core': patch
---

fix: preserve infinite query behavior during SSR hydration (#8825)
73 changes: 73 additions & 0 deletions packages/query-core/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1400,4 +1400,77 @@ describe('dehydration and rehydration', () => {
// error and test will fail
await originalPromise
})

test('should preserve queryType for infinite queries during hydration', async () => {
const queryCache = new QueryCache()
const queryClient = new QueryClient({ queryCache })

await vi.waitFor(() =>
queryClient.prefetchInfiniteQuery({
queryKey: ['infinite'],
queryFn: async ({ pageParam }) =>
sleep(0).then(() => ({
items: [`page-${pageParam}`],
nextCursor: pageParam + 1,
})),
initialPageParam: 0,
getNextPageParam: (
lastPage: { items: Array<string>; nextCursor: number },
) => lastPage.nextCursor,
}),
)

const dehydrated = dehydrate(queryClient)

const infiniteQueryState = dehydrated.queries.find(
(q) => q.queryKey[0] === 'infinite',
)
expect(infiniteQueryState?.queryType).toBe('infiniteQuery')

const hydrationCache = new QueryCache()
const hydrationClient = new QueryClient({ queryCache: hydrationCache })
hydrate(hydrationClient, dehydrated)

const hydratedQuery = hydrationCache.find({ queryKey: ['infinite'] })
expect(hydratedQuery?.state.data).toBeDefined()
expect(hydratedQuery?.state.data).toHaveProperty('pages')
expect(hydratedQuery?.state.data).toHaveProperty('pageParams')
expect((hydratedQuery?.state.data as any).pages).toHaveLength(1)
})

test('should attach infiniteQueryBehavior during hydration', async () => {
const queryCache = new QueryCache()
const queryClient = new QueryClient({ queryCache })

await vi.waitFor(() =>
queryClient.prefetchInfiniteQuery({
queryKey: ['infinite-with-behavior'],
queryFn: async ({ pageParam }) =>
sleep(0).then(() => ({ data: `page-${pageParam}`, next: pageParam + 1 })),
initialPageParam: 0,
getNextPageParam: (lastPage: { data: string; next: number }) =>
lastPage.next,
}),
)

const dehydrated = dehydrate(queryClient)

const hydrationCache = new QueryCache()
const hydrationClient = new QueryClient({ queryCache: hydrationCache })
hydrate(hydrationClient, dehydrated)

const result = await vi.waitFor(() =>
hydrationClient.fetchInfiniteQuery({
queryKey: ['infinite-with-behavior'],
queryFn: async ({ pageParam }) =>
sleep(0).then(() => ({ data: `page-${pageParam}`, next: pageParam + 1 })),
initialPageParam: 0,
getNextPageParam: (lastPage: { data: string; next: number }) =>
lastPage.next,
}),
)

expect(result.pages).toHaveLength(1)
expect(result.pageParams).toHaveLength(1)
})
})
62 changes: 47 additions & 15 deletions packages/query-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import type { QueryClient } from './queryClient'
import type { Query, QueryState } from './query'
import type { Mutation, MutationState } from './mutation'
import { infiniteQueryBehavior } from './infiniteQueryBehavior'

// TYPES
type TransformerFn = (data: any) => any
Expand Down Expand Up @@ -52,6 +53,7 @@ interface DehydratedQuery {
// without it which we need to handle for backwards compatibility.
// This should be changed to required in the future.
dehydratedAt?: number
queryType?: 'query' | 'infiniteQuery'
}

export interface DehydratedState {
Expand All @@ -70,6 +72,11 @@ function dehydrateMutation(mutation: Mutation): DehydratedMutation {
}
}

function isInfiniteQuery(query: Query): boolean {
const options = query.options as any
return 'initialPageParam' in options
}

// Most config is not dehydrated but instead meant to configure again when
// consuming the de/rehydrated data, typically with useQuery on the client.
// Sometimes it might make sense to prefetch data on the server and include
Expand Down Expand Up @@ -113,6 +120,7 @@ function dehydrateQuery(
},
queryKey: query.queryKey,
queryHash: query.queryHash,
queryType: isInfiniteQuery(query) ? 'infiniteQuery' : 'query',
...(query.state.status === 'pending' && {
promise: dehydratePromise(),
}),
Expand Down Expand Up @@ -209,7 +217,15 @@ export function hydrate(
})

queries.forEach(
({ queryKey, state, queryHash, meta, promise, dehydratedAt }) => {
({
queryKey,
state,
queryHash,
meta,
promise,
dehydratedAt,
queryType,
}) => {
const syncData = promise ? tryResolveSync(promise) : undefined
const rawData = state.data === undefined ? syncData?.data : state.data
const data = rawData === undefined ? rawData : deserializeData(rawData)
Expand Down Expand Up @@ -239,16 +255,21 @@ export function hydrate(
})
}
} else {
const queryOptions: any = {
...client.getDefaultOptions().hydrate?.queries,
...options?.defaultOptions?.queries,
queryKey,
queryHash,
meta,
}

if (queryType === 'infiniteQuery') {
queryOptions.behavior = infiniteQueryBehavior(undefined)
}
// Restore query
query = queryCache.build(
client,
{
...client.getDefaultOptions().hydrate?.queries,
...options?.defaultOptions?.queries,
queryKey,
queryHash,
meta,
},
queryOptions,
// Reset fetch status to idle to avoid
// query being stuck in fetching state upon hydration
{
Expand All @@ -272,13 +293,24 @@ export function hydrate(
// which will re-use the passed `initialPromise`
// Note that we need to call these even when data was synchronously
// available, as we still need to set up the retryer
query
.fetch(undefined, {
// RSC transformed promises are not thenable
initialPromise: Promise.resolve(promise).then(deserializeData),
})
// Avoid unhandled promise rejections
.catch(noop)

const isRejectedThenable =
promise &&
typeof promise === 'object' &&
'status' in promise &&
(promise as any).status === 'rejected'

if (!isRejectedThenable) {
query
.fetch(undefined, {
// RSC transformed promises are not thenable
initialPromise: Promise.resolve(promise).then((resolvedData) => {
return deserializeData(resolvedData)
}),
})
// Avoid unhandled promise rejections
.catch(noop)
}
}
},
)
Expand Down