diff --git a/.changeset/stupid-seals-live.md b/.changeset/stupid-seals-live.md new file mode 100644 index 0000000000..e744134969 --- /dev/null +++ b/.changeset/stupid-seals-live.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +fix: preserve infinite query behavior during SSR hydration (#8825) diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 46945e1b89..2632a742b4 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -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; 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) + }) }) diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index c75d8ee332..644e9a1f9d 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -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 @@ -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 { @@ -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 @@ -113,6 +120,7 @@ function dehydrateQuery( }, queryKey: query.queryKey, queryHash: query.queryHash, + queryType: isInfiniteQuery(query) ? 'infiniteQuery' : 'query', ...(query.state.status === 'pending' && { promise: dehydratePromise(), }), @@ -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) @@ -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 { @@ -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) + } } }, )