From 29eb336cd5484b99419976b9fbddbd8b1e25c287 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Sun, 17 Aug 2025 14:25:51 +0900 Subject: [PATCH 1/7] fix(react-query): resolve hydration mismatch in SSR with prefetched queries --- .../src/__tests__/queryObserver.test.tsx | 175 ++++++++++++++++++ packages/query-core/src/queryObserver.ts | 28 +++ packages/react-query/src/useBaseQuery.ts | 2 +- 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index c1ddefbfa5..ba7568a632 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -1494,4 +1494,179 @@ describe('queryObserver', () => { unsubscribe2() }) }) + + describe('SSR Hydration', () => { + describe('Hydration Mismatch Problem', () => { + test('should demonstrate hydration mismatch issue (before fix)', () => { + const key = queryKey() + + queryClient.setQueryData(key, { amount: 10 }) + const cache = queryClient.getQueryCache().find({ queryKey: key }) + if (cache) { + cache.state.dataUpdatedAt = 0 + cache.state.fetchStatus = 'idle' + } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ amount: 10 }), + }) + + const clientResult = observer.getCurrentResult() + + expect(clientResult).toMatchObject({ + status: 'success', + data: { amount: 10 }, + isLoading: false, + isPending: false, + }) + }) + }) + + describe('Solution with getServerResult', () => { + test('getServerResult should return pending state for hydrated data', () => { + const key = queryKey() + + queryClient.setQueryData(key, { amount: 10 }) + const cache = queryClient.getQueryCache().find({ queryKey: key }) + if (cache) { + cache.state.dataUpdatedAt = 0 + cache.state.fetchStatus = 'idle' + } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ amount: 10 }), + }) + + const clientResult = observer.getCurrentResult() + expect(clientResult).toMatchObject({ + status: 'success', + data: { amount: 10 }, + isLoading: false, + }) + + const serverResult = observer.getServerResult() + expect(serverResult).toMatchObject({ + status: 'pending', + data: undefined, + isLoading: false, + isPending: true, + isSuccess: false, + }) + }) + + test('should handle fetching state during hydration', () => { + const key = queryKey() + + queryClient.setQueryData(key, { amount: 10 }) + const cache = queryClient.getQueryCache().find({ queryKey: key }) + if (cache) { + cache.state.dataUpdatedAt = 0 + cache.state.fetchStatus = 'fetching' + } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ amount: 10 }), + }) + + const serverResult = observer.getServerResult() + expect(serverResult).toMatchObject({ + status: 'pending', + fetchStatus: 'fetching', + isLoading: true, + isPending: true, + }) + }) + + test('should return normal result for non-hydrated data', () => { + const key = queryKey() + + queryClient.setQueryData(key, { amount: 10 }) + const cache = queryClient.getQueryCache().find({ queryKey: key }) + if (cache) { + cache.state.dataUpdatedAt = Date.now() + } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ amount: 10 }), + }) + + const clientResult = observer.getCurrentResult() + const serverResult = observer.getServerResult() + + expect(serverResult.status).toBe(clientResult.status) + expect(serverResult.data).toBe(clientResult.data) + expect(serverResult.isLoading).toBe(clientResult.isLoading) + }) + + test('should handle error state correctly', () => { + const key = queryKey() + const error = new Error('fetch error') + + queryClient.getQueryCache().build( + queryClient, + { + queryKey: key, + queryFn: () => Promise.reject(error), + }, + { + status: 'error', + error, + data: undefined, + dataUpdatedAt: 0, + dataUpdateCount: 0, + errorUpdateCount: 1, + errorUpdatedAt: Date.now(), + fetchFailureCount: 1, + fetchFailureReason: error, + fetchMeta: null, + fetchStatus: 'idle', + isInvalidated: false, + }, + ) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => Promise.reject(error), + }) + + const serverResult = observer.getServerResult() + + expect(serverResult).toMatchObject({ + status: 'error', + error, + isError: true, + }) + }) + }) + + describe('Integration with useSyncExternalStore', () => { + test('should provide different snapshots for server and client', () => { + const key = queryKey() + + queryClient.setQueryData(key, 'hydrated') + const cache = queryClient.getQueryCache().find({ queryKey: key }) + if (cache) { + cache.state.dataUpdatedAt = 0 + } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => 'data', + }) + + const getSnapshot = () => observer.getCurrentResult() + const getServerSnapshot = () => observer.getServerResult() + + const clientSnapshot = getSnapshot() + const serverSnapshot = getServerSnapshot() + + expect(clientSnapshot.status).toBe('success') + expect(serverSnapshot.status).toBe('pending') + }) + }) + }) }) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index ac0a3e079a..0749fbbf95 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -264,6 +264,34 @@ export class QueryObserver< return this.#currentResult } + getServerResult(): QueryObserverResult { + const currentResult = this.#currentResult + + if ( + currentResult.status === 'success' && + this.#currentQuery.state.dataUpdatedAt === 0 + ) { + const pendingResult: QueryObserverResult = { + ...currentResult, + status: 'pending', + isPending: true, + isSuccess: false, + isError: false, + isLoading: currentResult.fetchStatus === 'fetching', + isInitialLoading: currentResult.fetchStatus === 'fetching', + data: undefined, + error: null, + isLoadingError: false, + isRefetchError: false, + isPlaceholderData: false, + } as QueryObserverResult + + return pendingResult + } + + return currentResult + } + trackResult( result: QueryObserverResult, onPropTracked?: (key: keyof QueryObserverResult) => void, diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 06690b544f..0cbdfcbf40 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -109,7 +109,7 @@ export function useBaseQuery< [observer, shouldSubscribe], ), () => observer.getCurrentResult(), - () => observer.getCurrentResult(), + () => observer.getServerResult(), ) React.useEffect(() => { From ab2ddadd2a780293d9404d7ab47c316461b34197 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Sun, 17 Aug 2025 18:32:41 +0900 Subject: [PATCH 2/7] fix(react-query): use QueryObserverPendingResult type and clarify field selection --- packages/query-core/src/queryObserver.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 0749fbbf95..bf4475b7be 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -23,6 +23,7 @@ import type { QueryKey, QueryObserverBaseResult, QueryObserverOptions, + QueryObserverPendingResult, QueryObserverResult, QueryOptions, RefetchOptions, @@ -264,29 +265,26 @@ export class QueryObserver< return this.#currentResult } - getServerResult(): QueryObserverResult { + getServerResult(): + | QueryObserverPendingResult + | QueryObserverResult { const currentResult = this.#currentResult - if ( + const isHydratedData = currentResult.status === 'success' && this.#currentQuery.state.dataUpdatedAt === 0 - ) { - const pendingResult: QueryObserverResult = { + + if (isHydratedData) { + return { ...currentResult, - status: 'pending', + status: 'pending' as const, isPending: true, isSuccess: false, - isError: false, + data: undefined, isLoading: currentResult.fetchStatus === 'fetching', isInitialLoading: currentResult.fetchStatus === 'fetching', - data: undefined, - error: null, - isLoadingError: false, - isRefetchError: false, isPlaceholderData: false, - } as QueryObserverResult - - return pendingResult + } } return currentResult From 907def0bd53537d7572b6a97d859d217659f5597 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Sun, 17 Aug 2025 18:44:21 +0900 Subject: [PATCH 3/7] fix(react-query): refactor clarify field selection --- packages/query-core/src/queryObserver.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index bf4475b7be..021ce9770f 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -280,7 +280,12 @@ export class QueryObserver< status: 'pending' as const, isPending: true, isSuccess: false, + isError: false, + error: null, + isLoadingError: false, + isRefetchError: false, data: undefined, + isRefetching: false, isLoading: currentResult.fetchStatus === 'fetching', isInitialLoading: currentResult.fetchStatus === 'fetching', isPlaceholderData: false, From 184be0242667ae4f9a4a7d745646ee56c0b174ce Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Sun, 17 Aug 2025 18:48:33 +0900 Subject: [PATCH 4/7] fix(react-query): ensure complete pending state invariants in getServerResult --- packages/query-core/src/queryObserver.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 021ce9770f..57150e369a 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -289,6 +289,8 @@ export class QueryObserver< isLoading: currentResult.fetchStatus === 'fetching', isInitialLoading: currentResult.fetchStatus === 'fetching', isPlaceholderData: false, + isFetched: false, + isFetchedAfterMount: false, } } From 5f14a668dab48d9576c89f78f392296023269a9f Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Sun, 17 Aug 2025 20:25:45 +0900 Subject: [PATCH 5/7] fix(react-query): ensure complete pending state invariants in getServerResult --- packages/query-core/src/queryObserver.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 57150e369a..2d5bb6f042 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -270,28 +270,26 @@ export class QueryObserver< | QueryObserverResult { const currentResult = this.#currentResult - const isHydratedData = + if ( currentResult.status === 'success' && this.#currentQuery.state.dataUpdatedAt === 0 - - if (isHydratedData) { - return { + ) { + const pendingResult: QueryObserverPendingResult = { ...currentResult, - status: 'pending' as const, + status: 'pending', isPending: true, isSuccess: false, isError: false, + isLoading: currentResult.fetchStatus === 'fetching', + isInitialLoading: currentResult.fetchStatus === 'fetching', + data: undefined, error: null, isLoadingError: false, isRefetchError: false, - data: undefined, - isRefetching: false, - isLoading: currentResult.fetchStatus === 'fetching', - isInitialLoading: currentResult.fetchStatus === 'fetching', isPlaceholderData: false, - isFetched: false, - isFetchedAfterMount: false, } + + return pendingResult } return currentResult From cb50d25b8b099a04e3753d18accee4d4e5e6865b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=ED=9B=88?= Date: Mon, 18 Aug 2025 08:08:03 +0900 Subject: [PATCH 6/7] fix(react-query): add isRefetching field --- packages/query-core/src/queryObserver.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 2d5bb6f042..9542af8d24 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -287,6 +287,7 @@ export class QueryObserver< isLoadingError: false, isRefetchError: false, isPlaceholderData: false, + isRefetching: false, } return pendingResult From 25fc95ed0f6bbf232e9763697bd7e9090d7c2225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=ED=9B=88?= Date: Tue, 19 Aug 2025 10:17:00 +0900 Subject: [PATCH 7/7] fix(react-query): use query state for fetchStatus in SSR hydration masking --- .../src/__tests__/queryObserver.test.tsx | 105 ++++++++++++++++++ packages/query-core/src/queryObserver.ts | 5 +- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index ba7568a632..f3d815e8df 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -1668,5 +1668,110 @@ describe('queryObserver', () => { expect(serverSnapshot.status).toBe('pending') }) }) + + describe('Future-proof status handling', () => { + test('should handle unknown status types gracefully', () => { + const key = queryKey() + + queryClient.getQueryCache().build( + queryClient, + { queryKey: key, queryFn: () => 'data' }, + { + status: 'idle' as any, // Assuming that in future versions such as @tanstack/react-query v6, statuses other than “error,” “pending,” and “success” will be introduced. + data: { amount: 10 }, + dataUpdatedAt: 0, + fetchStatus: 'idle', + dataUpdateCount: 1, + errorUpdateCount: 0, + errorUpdatedAt: 0, + error: null, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + }, + ) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ amount: 10 }), + }) + + const serverResult = observer.getServerResult() + + expect(serverResult.status).toBe('idle') + // Indicates a possible problem where data exists even though the status is “idle.” + expect(serverResult.data).toEqual({ amount: 10 }) + }) + }) + + describe('Field consistency in edge cases', () => { + test('should handle isRefetching consistency', () => { + const key = queryKey() + + queryClient.setQueryData(key, { amount: 10 }) + const cache = queryClient.getQueryCache().find({ queryKey: key }) + if (cache) { + cache.state.dataUpdatedAt = 0 + cache.state.fetchStatus = 'fetching' + } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => ({ amount: 10 }), + }) + + const clientResult = observer.getCurrentResult() + const serverResult = observer.getServerResult() + + expect(clientResult.isRefetching).toBe(true) + expect(clientResult.isPending).toBe(false) + + // Even if the client status is `isRefetching: true` and `isPending: false`, the masking of `isRefetching: false` and `isPending: true` in `getServerResult` must be maintained. + expect(serverResult.isRefetching).toBe(false) + expect(serverResult.isPending).toBe(true) + }) + }) + + describe('Concurrency and race conditions', () => { + test('should handle state changes during hydration', async () => { + const key = queryKey() + + queryClient.setQueryData(key, { amount: 10 }) + const cache = queryClient.getQueryCache().find({ queryKey: key }) + if (cache) { + cache.state.dataUpdatedAt = 0 + cache.state.fetchStatus = 'idle' + } + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => { + await sleep(10) + return { amount: 20 } + }, + }) + + const initialServerResult = observer.getServerResult() + expect(initialServerResult.status).toBe('pending') + + const refetchPromise = observer.refetch() + await Promise.resolve() + + // Verify that hydration masking works correctly even during refetch. + + const midServerResult = observer.getServerResult() + expect(midServerResult.status).toBe('pending') + expect(midServerResult.isLoading).toBe(true) + + await vi.advanceTimersByTimeAsync(10) + + const finalServerResult = observer.getServerResult() + expect(finalServerResult.status).toBe('success') + expect(finalServerResult.data).toEqual({ amount: 20 }) + + await refetchPromise + }) + }) }) }) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 9542af8d24..41a8bdce54 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -269,6 +269,7 @@ export class QueryObserver< | QueryObserverPendingResult | QueryObserverResult { const currentResult = this.#currentResult + const queryState = this.#currentQuery.state if ( currentResult.status === 'success' && @@ -280,8 +281,8 @@ export class QueryObserver< isPending: true, isSuccess: false, isError: false, - isLoading: currentResult.fetchStatus === 'fetching', - isInitialLoading: currentResult.fetchStatus === 'fetching', + isLoading: queryState.fetchStatus === 'fetching', + isInitialLoading: queryState.fetchStatus === 'fetching', data: undefined, error: null, isLoadingError: false,