From 909d9536a9a3670563f7f7bb272527a627a2a4a5 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Tue, 9 Sep 2025 18:28:00 +0900 Subject: [PATCH] fix(query-core,react-query): preserve infinite query type through failed SSR hydration --- .../src/__tests__/hydration.test.tsx | 203 +++++++++++++++ packages/query-core/src/hydration.ts | 16 +- packages/query-core/src/query.ts | 21 +- .../src/__tests__/ssr-hydration.test.tsx | 238 ++++++++++++++++++ 4 files changed, 476 insertions(+), 2 deletions(-) diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 30a4b9a985a..c6fc69cc950 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1399,4 +1399,207 @@ describe('dehydration and rehydration', () => { // error and test will fail await originalPromise }) + + test('should preserve infinite query type when hydrating failed promise', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery: () => true, + }, + }, + }) + + const promise = queryClient + .prefetchInfiniteQuery({ + queryKey: ['infinite', 'failed'], + queryFn: () => Promise.reject(new Error('fetch failed')), + initialPageParam: 0, + getNextPageParam: () => 1, + retry: false, + }) + .catch(() => {}) + + const dehydrated = dehydrate(queryClient) + + const hydrationClient = new QueryClient() + hydrate(hydrationClient, dehydrated) + + const hydratedQuery = hydrationClient.getQueryCache().find({ + queryKey: ['infinite', 'failed'], + }) + + expect(hydratedQuery?.isInfiniteQuery).toBe(true) + + await promise + }) + + test('should mark infinite queries with isInfiniteQuery flag during dehydration', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await queryClient.prefetchInfiniteQuery({ + queryKey: ['infinite'], + queryFn: ({ pageParam = 0 }) => Promise.resolve(`page-${pageParam}`), + initialPageParam: 0, + getNextPageParam: (_lastPage: any, pages: any) => pages.length, + retry: false, + }) + + await queryClient.prefetchQuery({ + queryKey: ['regular'], + queryFn: () => Promise.resolve('data'), + }) + + const dehydrated = dehydrate(queryClient) + + const infiniteQuery = dehydrated.queries.find( + (q) => q.queryKey[0] === 'infinite', + ) + expect(infiniteQuery?.isInfiniteQuery).toBe(true) + + const regularQuery = dehydrated.queries.find( + (q) => q.queryKey[0] === 'regular', + ) + expect(regularQuery?.isInfiniteQuery).toBeUndefined() + }) + + test('should preserve isInfiniteQuery flag through hydration', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await queryClient + .prefetchInfiniteQuery({ + queryKey: ['infinite'], + queryFn: () => Promise.reject(new Error('Failed')), + initialPageParam: 0, + getNextPageParam: () => 1, + retry: false, + }) + .catch(() => {}) + + const dehydrated = dehydrate(queryClient) + + expect(dehydrated.queries[0]?.isInfiniteQuery).toBe(true) + + const newClient = new QueryClient() + hydrate(newClient, dehydrated) + + const hydratedQuery = newClient.getQueryCache().find({ + queryKey: ['infinite'], + }) + + expect(hydratedQuery?.isInfiniteQuery).toBe(true) + }) + + test('should handle JSON serialization of dehydrated infinite queries', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await queryClient + .prefetchInfiniteQuery({ + queryKey: ['infinite'], + queryFn: () => Promise.reject(new Error('Failed')), + initialPageParam: 0, + getNextPageParam: () => 1, + retry: false, + }) + .catch(() => {}) + + const dehydrated = dehydrate(queryClient) + + const serialized = JSON.stringify(dehydrated) + const deserialized = JSON.parse(serialized) + + expect(deserialized.queries[0]?.isInfiniteQuery).toBe(true) + + const newClient = new QueryClient() + hydrate(newClient, deserialized) + + const hydratedQuery = newClient.getQueryCache().find({ + queryKey: ['infinite'], + }) + + expect(hydratedQuery?.isInfiniteQuery).toBe(true) + }) + + test('should not affect regular query hydration', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: ['regular1'], + queryFn: () => Promise.resolve('data1'), + }), + queryClient.prefetchInfiniteQuery({ + queryKey: ['infinite1'], + queryFn: () => Promise.resolve('page1'), + initialPageParam: 0, + getNextPageParam: () => 1, + }), + queryClient + .prefetchQuery({ + queryKey: ['regular2'], + queryFn: () => Promise.reject(new Error('Failed')), + retry: false, + }) + .catch(() => {}), + ]) + + const dehydrated = dehydrate(queryClient) + const newClient = new QueryClient() + hydrate(newClient, dehydrated) + + const regular1 = newClient.getQueryCache().find({ queryKey: ['regular1'] }) + const infinite1 = newClient + .getQueryCache() + .find({ queryKey: ['infinite1'] }) + const regular2 = newClient.getQueryCache().find({ queryKey: ['regular2'] }) + + expect(regular1?.isInfiniteQuery).toBeUndefined() + expect(infinite1?.isInfiniteQuery).toBe(true) + expect(regular2?.isInfiniteQuery).toBeUndefined() + + expect(regular1?.state.data).toBe('data1') + }) + + test('should handle nested infinite query keys correctly', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await queryClient + .prefetchInfiniteQuery({ + queryKey: ['posts', { userId: 1, filter: 'active' }], + queryFn: () => Promise.reject(new Error('Failed')), + initialPageParam: 0, + getNextPageParam: () => 1, + retry: false, + }) + .catch(() => {}) + + const dehydrated = dehydrate(queryClient) + const newClient = new QueryClient() + hydrate(newClient, dehydrated) + + const hydratedQuery = newClient.getQueryCache().find({ + queryKey: ['posts', { userId: 1, filter: 'active' }], + }) + + expect(hydratedQuery?.isInfiniteQuery).toBe(true) + }) }) diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 1361036d86f..c36cb92a7b8 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -51,6 +51,7 @@ interface DehydratedQuery { // without it which we need to handle for backwards compatibility. // This should be changed to required in the future. dehydratedAt?: number + isInfiniteQuery?: boolean } export interface DehydratedState { @@ -104,6 +105,7 @@ function dehydrateQuery( }), }), ...(query.meta && { meta: query.meta }), + ...(query.options.behavior && { isInfiniteQuery: true }), } } @@ -196,7 +198,15 @@ export function hydrate( }) queries.forEach( - ({ queryKey, state, queryHash, meta, promise, dehydratedAt }) => { + ({ + queryKey, + state, + queryHash, + meta, + promise, + dehydratedAt, + isInfiniteQuery, + }) => { const syncData = promise ? tryResolveSync(promise) : undefined const rawData = state.data === undefined ? syncData?.data : state.data const data = rawData === undefined ? rawData : deserializeData(rawData) @@ -245,6 +255,10 @@ export function hydrate( status: data !== undefined ? 'success' : state.status, }, ) + + if (isInfiniteQuery) { + query.setIsInfiniteQuery(true) + } } if ( diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index a34c8630dc8..6ad45c150c7 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -175,6 +175,7 @@ export class Query< observers: Array> #defaultOptions?: QueryOptions #abortSignalConsumed: boolean + #isInfiniteQuery?: boolean constructor(config: QueryConfig) { super() @@ -199,6 +200,10 @@ export class Query< return this.#retryer?.promise } + get isInfiniteQuery(): boolean | undefined { + return this.#isInfiniteQuery + } + setOptions( options?: QueryOptions, ): void { @@ -249,6 +254,10 @@ export class Query< this.#dispatch({ type: 'setState', state, setStateOptions }) } + setIsInfiniteQuery(value: boolean): void { + this.#isInfiniteQuery = value + } + cancel(options?: CancelOptions): Promise { const promise = this.#retryer?.promise this.#retryer?.cancel(options) @@ -395,7 +404,17 @@ export class Query< // pending state when that happens this.#retryer?.status() !== 'rejected' ) { - if (this.state.data !== undefined && fetchOptions?.cancelRefetch) { + const isStuckHydratedInfinite = + this.#isInfiniteQuery && + this.state.data === undefined && + this.#retryer?.status() === 'pending' && + options?.behavior && + fetchOptions?.cancelRefetch + + if (isStuckHydratedInfinite) { + // Silently cancel current fetch if the user wants to cancel refetch + this.cancel({ silent: true }) + } else if (this.state.data !== undefined && fetchOptions?.cancelRefetch) { // Silently cancel current fetch if the user wants to cancel refetch this.cancel({ silent: true }) } else if (this.#retryer) { diff --git a/packages/react-query/src/__tests__/ssr-hydration.test.tsx b/packages/react-query/src/__tests__/ssr-hydration.test.tsx index 07f469b5684..d065f599973 100644 --- a/packages/react-query/src/__tests__/ssr-hydration.test.tsx +++ b/packages/react-query/src/__tests__/ssr-hydration.test.tsx @@ -3,6 +3,7 @@ import { hydrateRoot } from 'react-dom/client' import { act } from 'react' import * as ReactDOMServer from 'react-dom/server' import { + InfiniteQueryObserver, QueryCache, QueryClient, QueryClientProvider, @@ -266,4 +267,241 @@ describe('Server side rendering with de/rehydration', () => { queryClient.clear() consoleMock.mockRestore() }) + + it('should handle failed SSR infinite query without data corruption', async () => { + const serverClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await serverClient + .prefetchInfiniteQuery({ + queryKey: ['posts'], + queryFn: () => { + throw new Error('Network error') + }, + initialPageParam: 1, + getNextPageParam: (_lastPage: any, allPages: any) => + allPages.length + 1, + retry: false, + }) + .catch(() => {}) + + const dehydrated = dehydrate(serverClient) + + const clientQueryClient = new QueryClient() + hydrate(clientQueryClient, dehydrated) + + const observer = new InfiniteQueryObserver(clientQueryClient, { + queryKey: ['posts'], + queryFn: ({ pageParam = 1 }) => { + return Promise.resolve({ + posts: [`Post ${pageParam}-1`, `Post ${pageParam}-2`], + nextCursor: pageParam + 1, + }) + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.nextCursor, + retry: 1, + }) + + const initialResult = observer.getCurrentResult() + + expect(() => { + const pageCount = initialResult.data?.pages?.length ?? 0 + console.log('Initial page count:', pageCount) + }).not.toThrow() + + await observer.refetch() + const afterRefetch = observer.getCurrentResult() + + expect(afterRefetch.data).toBeDefined() + expect(afterRefetch.data?.pages).toBeDefined() + expect(afterRefetch.data?.pages).toHaveLength(1) + expect(afterRefetch.data?.pageParams).toHaveLength(1) + + await observer.fetchNextPage() + const afterNextPage = observer.getCurrentResult() + + expect(afterNextPage.data?.pages).toHaveLength(2) + expect(afterNextPage.data?.pages[1]).toEqual({ + posts: ['Post 2-1', 'Post 2-2'], + nextCursor: 3, + }) + }) + + it('should handle race conditions between hydration and observer', async () => { + const serverClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await Promise.all([ + serverClient + .prefetchInfiniteQuery({ + queryKey: ['posts', 'user1'], + queryFn: () => Promise.reject(new Error('Failed')), + initialPageParam: 0, + getNextPageParam: () => 1, + retry: false, + }) + .catch(() => {}), + serverClient + .prefetchInfiniteQuery({ + queryKey: ['posts', 'user2'], + queryFn: () => Promise.reject(new Error('Failed')), + initialPageParam: 0, + getNextPageParam: () => 1, + retry: false, + }) + .catch(() => {}), + ]) + + const dehydrated = dehydrate(serverClient) + const clientQueryClient = new QueryClient() + hydrate(clientQueryClient, dehydrated) + + const observer1 = new InfiniteQueryObserver(clientQueryClient, { + queryKey: ['posts', 'user1'], + queryFn: ({ pageParam = 0 }) => + Promise.resolve({ data: `user1-${pageParam}` }), + initialPageParam: 0, + getNextPageParam: () => 1, + }) + + const observer2 = new InfiniteQueryObserver(clientQueryClient, { + queryKey: ['posts', 'user2'], + queryFn: ({ pageParam = 0 }) => + Promise.resolve({ data: `user2-${pageParam}` }), + initialPageParam: 0, + getNextPageParam: () => 1, + }) + + const [result1, result2] = await Promise.all([ + observer1.refetch(), + observer2.refetch(), + ]) + + expect(result1.data?.pages).toBeDefined() + expect(result2.data?.pages).toBeDefined() + expect(result1.data?.pages[0]).toEqual({ data: 'user1-0' }) + expect(result2.data?.pages[0]).toEqual({ data: 'user2-0' }) + }) + + it('should handle regular query (non-infinite) after hydration', async () => { + const serverClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await serverClient + .prefetchQuery({ + queryKey: ['regular'], + queryFn: () => Promise.reject(new Error('Failed')), + retry: false, + }) + .catch(() => {}) + + const dehydrated = dehydrate(serverClient) + const clientQueryClient = new QueryClient() + hydrate(clientQueryClient, dehydrated) + + const query = clientQueryClient + .getQueryCache() + .find({ queryKey: ['regular'] }) + expect((query as any)?.__isInfiniteQuery).toBeUndefined() + }) + + it('should handle mixed queries (infinite + regular) in same hydration', async () => { + const serverClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await Promise.all([ + serverClient + .prefetchInfiniteQuery({ + queryKey: ['infinite'], + queryFn: () => Promise.reject(new Error('Failed')), + initialPageParam: 0, + getNextPageParam: () => 1, + retry: false, + }) + .catch(() => {}), + serverClient + .prefetchQuery({ + queryKey: ['regular'], + queryFn: () => Promise.reject(new Error('Failed')), + retry: false, + }) + .catch(() => {}), + ]) + + const dehydrated = dehydrate(serverClient) + const clientQueryClient = new QueryClient() + hydrate(clientQueryClient, dehydrated) + + const infiniteObserver = new InfiniteQueryObserver(clientQueryClient, { + queryKey: ['infinite'], + queryFn: () => Promise.resolve({ data: 'infinite' }), + initialPageParam: 0, + getNextPageParam: () => 1, + }) + + const regularPromise = clientQueryClient.fetchQuery({ + queryKey: ['regular'], + queryFn: () => Promise.resolve('regular'), + }) + + const [infiniteResult, regularResult] = await Promise.all([ + infiniteObserver.refetch(), + regularPromise, + ]) + + expect(infiniteResult.data?.pages).toBeDefined() + expect(regularResult).toBe('regular') + }) + + it('should handle successful hydrated infinite query (no failure)', async () => { + const serverClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + await serverClient.prefetchInfiniteQuery({ + queryKey: ['success'], + queryFn: ({ pageParam = 0 }) => + Promise.resolve({ + data: `page-${pageParam}`, + next: pageParam + 1, + }), + initialPageParam: 0, + getNextPageParam: (lastPage: any) => lastPage.next, + }) + + const dehydrated = dehydrate(serverClient) + const clientQueryClient = new QueryClient() + hydrate(clientQueryClient, dehydrated) + + const observer = new InfiniteQueryObserver(clientQueryClient, { + queryKey: ['success'], + queryFn: ({ pageParam = 0 }) => + Promise.resolve({ + data: `page-${pageParam}`, + next: pageParam + 1, + }), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.next, + }) + + const result = observer.getCurrentResult() + + expect(result.data?.pages).toHaveLength(1) + expect(result.data?.pages[0]).toEqual({ data: 'page-0', next: 1 }) + }) })