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 8bb79ec6e9..ef138c2bf8 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1385,4 +1385,250 @@ 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('infinite') + + 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) + }) + + test('should restore infinite query type through dehydrate and hydrate cycle', async () => { + const serverClient = new QueryClient({ queryCache: new QueryCache() }) + + await vi.waitFor(() => + serverClient.prefetchInfiniteQuery({ + queryKey: ['infinite-type-restore'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`item-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + const dehydrated = dehydrate(serverClient) + + const dehydratedQuery = dehydrated.queries.find( + (q) => q.queryKey[0] === 'infinite-type-restore', + ) + expect(dehydratedQuery?.queryType).toBe('infinite') + + const clientCache = new QueryCache() + const clientClient = new QueryClient({ queryCache: clientCache }) + hydrate(clientClient, dehydrated) + + const hydratedQuery = clientCache.find({ + queryKey: ['infinite-type-restore'], + }) + expect(hydratedQuery?.queryType).toBe('infinite') + }) + + test('should preserve pages structure when refetching infinite query after hydration', async () => { + const serverClient = new QueryClient({ queryCache: new QueryCache() }) + + await vi.waitFor(() => + serverClient.prefetchInfiniteQuery({ + queryKey: ['refetch'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`page-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + const dehydrated = dehydrate(serverClient) + + const clientCache = new QueryCache() + const clientClient = new QueryClient({ queryCache: clientCache }) + hydrate(clientClient, dehydrated) + + const beforeRefetch = clientClient.getQueryData<{ + pages: Array<{ items: Array; next: number }> + pageParams: Array + }>(['refetch']) + expect(beforeRefetch?.pages).toHaveLength(1) + expect(beforeRefetch?.pageParams).toHaveLength(1) + + const result = await vi.waitFor(() => + clientClient.fetchInfiniteQuery({ + queryKey: ['refetch'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`page-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + expect(result).toHaveProperty('pages') + expect(result).toHaveProperty('pageParams') + expect(Array.isArray(result.pages)).toBe(true) + expect(result.pages).toHaveLength(1) + expect(result.pages[0]).toHaveProperty('items') + }) + + test('should retain infinite query type after subsequent setOptions calls', async () => { + const serverClient = new QueryClient({ queryCache: new QueryCache() }) + + await vi.waitFor(() => + serverClient.prefetchInfiniteQuery({ + queryKey: ['infinite-setoptions-guard'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + data: `p${pageParam}`, + next: pageParam + 1, + })), + initialPageParam: 0, + getNextPageParam: (lastPage: { data: string; next: number }) => + lastPage.next, + }), + ) + + const dehydrated = dehydrate(serverClient) + + const clientCache = new QueryCache() + const clientClient = new QueryClient({ queryCache: clientCache }) + hydrate(clientClient, dehydrated) + + const query = clientCache.find({ queryKey: ['infinite-setoptions-guard'] })! + expect(query.queryType).toBe('infinite') + + query.setOptions({ queryKey: ['infinite-setoptions-guard'] }) + expect(query.queryType).toBe('infinite') + }) + + test('should restore all pages when refetching multi-page infinite query after hydration', async () => { + const serverClient = new QueryClient({ queryCache: new QueryCache() }) + + await vi.waitFor(() => + serverClient.prefetchInfiniteQuery({ + queryKey: ['infinite-multipage-restore'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`item-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + pages: 2, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + const dehydrated = dehydrate(serverClient) + + const clientCache = new QueryCache() + const clientClient = new QueryClient({ queryCache: clientCache }) + hydrate(clientClient, dehydrated) + + const beforeRefetch = clientClient.getQueryData<{ + pages: Array + pageParams: Array + }>(['infinite-multipage-restore']) + expect(beforeRefetch?.pages).toHaveLength(2) + + const result = await vi.waitFor(() => + clientClient.fetchInfiniteQuery({ + queryKey: ['infinite-multipage-restore'], + queryFn: async ({ pageParam }) => + sleep(0).then(() => ({ + items: [`item-${pageParam}`], + next: pageParam + 1, + })), + initialPageParam: 0, + pages: 2, + getNextPageParam: (lastPage: { items: Array; next: number }) => + lastPage.next, + }), + ) + + expect(result.pages).toHaveLength(2) + expect(result.pageParams).toHaveLength(2) + expect(result.pages[0]).toHaveProperty('items') + expect(result.pages[1]).toHaveProperty('items') + }) }) diff --git a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx index 1a72d81c98..e26d507a25 100644 --- a/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx +++ b/packages/query-core/src/__tests__/infiniteQueryObserver.test.tsx @@ -235,8 +235,7 @@ describe('InfiniteQueryObserver', () => { const result = observer.getOptimisticResult(options) - expect(options.behavior).toBeDefined() - expect(options.behavior?.onFetch).toBeDefined() + expect(options._type).toBe('infinite') expect(result).toMatchObject({ data: undefined, diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index c75d8ee332..3ba12d4f49 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -48,6 +48,7 @@ interface DehydratedQuery { state: QueryState promise?: Promise meta?: QueryMeta + queryType?: 'infinite' // This is only optional because older versions of Query might have dehydrated // without it which we need to handle for backwards compatibility. // This should be changed to required in the future. @@ -117,6 +118,7 @@ function dehydrateQuery( promise: dehydratePromise(), }), ...(query.meta && { meta: query.meta }), + ...(query.queryType && { queryType: query.queryType }), } } @@ -209,7 +211,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) @@ -248,6 +258,7 @@ export function hydrate( queryKey, queryHash, meta, + _type: queryType, }, // Reset fetch status to idle to avoid // query being stuck in fetching state upon hydration diff --git a/packages/query-core/src/infiniteQueryObserver.ts b/packages/query-core/src/infiniteQueryObserver.ts index 1499b13816..1cdd32a885 100644 --- a/packages/query-core/src/infiniteQueryObserver.ts +++ b/packages/query-core/src/infiniteQueryObserver.ts @@ -1,9 +1,5 @@ import { QueryObserver } from './queryObserver' -import { - hasNextPage, - hasPreviousPage, - infiniteQueryBehavior, -} from './infiniteQueryBehavior' +import { hasNextPage, hasPreviousPage } from './infiniteQueryBehavior' import type { Subscribable } from './subscribable' import type { DefaultError, @@ -93,10 +89,8 @@ export class InfiniteQueryObserver< TPageParam >, ): void { - super.setOptions({ - ...options, - behavior: infiniteQueryBehavior(), - }) + options._type = 'infinite' + super.setOptions(options) } getOptimisticResult( @@ -108,7 +102,7 @@ export class InfiniteQueryObserver< TPageParam >, ): InfiniteQueryObserverResult { - options.behavior = infiniteQueryBehavior() + options._type = 'infinite' return super.getOptimisticResult(options) as InfiniteQueryObserverResult< TData, TError diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 7dfaa58772..d7af64d86e 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -10,6 +10,7 @@ import { import { notifyManager } from './notifyManager' import { CancelledError, canFetch, createRetryer } from './retryer' import { Removable } from './removable' +import { infiniteQueryBehavior } from './infiniteQueryBehavior' import type { QueryCache } from './queryCache' import type { QueryClient } from './queryClient' import type { @@ -166,6 +167,7 @@ export class Query< queryHash: string options!: QueryOptions state: QueryState + #queryType?: 'infinite' #initialState: QueryState #revertState?: QueryState @@ -195,6 +197,10 @@ export class Query< return this.options.meta } + get queryType() { + return this.#queryType + } + get promise(): Promise | undefined { return this.#retryer?.promise } @@ -204,6 +210,10 @@ export class Query< ): void { this.options = { ...this.#defaultOptions, ...options } + if (options?._type) { + this.#queryType = options._type + } + this.updateGcTime(this.options.gcTime) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -510,7 +520,13 @@ export class Query< const context = createFetchContext() - this.options.behavior?.onFetch(context, this as unknown as Query) + const behavior = + this.#queryType === 'infinite' + ? (infiniteQueryBehavior( + (this.options as { pages?: number }).pages, + ) as QueryBehavior) + : this.options.behavior + behavior?.onFetch(context, this as unknown as Query) // Store state in case the current fetch needs to be reverted this.#revertState = this.state diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 80cc36668a..f448a068f2 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -12,7 +12,6 @@ import { MutationCache } from './mutationCache' import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { notifyManager } from './notifyManager' -import { infiniteQueryBehavior } from './infiniteQueryBehavior' import type { CancelOptions, DefaultError, @@ -395,12 +394,7 @@ export class QueryClient { TPageParam >, ): Promise> { - options.behavior = infiniteQueryBehavior< - TQueryFnData, - TError, - TData, - TPageParam - >(options.pages) + options._type = 'infinite' return this.fetchQuery(options as any) } @@ -437,12 +431,7 @@ export class QueryClient { TPageParam >, ): Promise> { - options.behavior = infiniteQueryBehavior< - TQueryFnData, - TError, - TData, - TPageParam - >(options.pages) + options._type = 'infinite' return this.ensureQueryData(options as any) } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 4f3f4caed2..140f6bf632 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -266,6 +266,7 @@ export interface QueryOptions< | boolean | ((oldData: unknown | undefined, newData: unknown) => unknown) _defaulted?: boolean + _type?: 'infinite' /** * Additional payload to be stored on each query. * Use this property to pass information that can be used in other places. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index ed8eed4534..62b531a8ff 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -2098,7 +2098,10 @@ const QueryDetails = () => { type: 'INVALIDATE', queryHash: activeQuery()?.queryHash, }) - queryClient.invalidateQueries(activeQuery()) + queryClient.invalidateQueries({ + queryKey: activeQuery()?.queryKey, + exact: true, + }) }} disabled={queryStatus() === 'pending'} > @@ -2123,7 +2126,10 @@ const QueryDetails = () => { type: 'RESET', queryHash: activeQuery()?.queryHash, }) - queryClient.resetQueries(activeQuery()) + queryClient.resetQueries({ + queryKey: activeQuery()?.queryKey, + exact: true, + }) }} disabled={queryStatus() === 'pending'} > @@ -2148,7 +2154,10 @@ const QueryDetails = () => { type: 'REMOVE', queryHash: activeQuery()?.queryHash, }) - queryClient.removeQueries(activeQuery()) + queryClient.removeQueries({ + queryKey: activeQuery()?.queryKey, + exact: true, + }) setSelectedQueryHash(null) }} disabled={statusLabel() === 'fetching'} @@ -2228,7 +2237,9 @@ const QueryDetails = () => { type: 'RESTORE_ERROR', queryHash: activeQuery()?.queryHash, }) - queryClient.resetQueries(activeQuery()) + queryClient.resetQueries({ + queryKey: activeQuery()?.queryKey, + }) } }} disabled={queryStatus() === 'pending'} diff --git a/packages/vue-query/src/devtools/devtools.ts b/packages/vue-query/src/devtools/devtools.ts index beff565841..42dfc05d73 100644 --- a/packages/vue-query/src/devtools/devtools.ts +++ b/packages/vue-query/src/devtools/devtools.ts @@ -90,7 +90,10 @@ export function setupDevtools(app: any, queryClient: QueryClient) { tooltip: 'Invalidate', action: (queryHash: string) => { const query = queryCache.get(queryHash) as Query - queryClient.invalidateQueries(query) + queryClient.invalidateQueries({ + queryKey: query.queryKey, + exact: true, + }) }, }, {