diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index c1ddefbfa5..f3d815e8df 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -1494,4 +1494,284 @@ 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') + }) + }) + + 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 1137503694..bfe2d96232 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, @@ -259,6 +260,38 @@ export class QueryObserver< return this.#currentResult } + getServerResult(): + | QueryObserverPendingResult + | QueryObserverResult { + const currentResult = this.#currentResult + const queryState = this.#currentQuery.state + + if ( + currentResult.status === 'success' && + this.#currentQuery.state.dataUpdatedAt === 0 + ) { + const pendingResult: QueryObserverPendingResult = { + ...currentResult, + status: 'pending', + isPending: true, + isSuccess: false, + isError: false, + isLoading: queryState.fetchStatus === 'fetching', + isInitialLoading: queryState.fetchStatus === 'fetching', + data: undefined, + error: null, + isLoadingError: false, + isRefetchError: false, + isPlaceholderData: false, + isRefetching: false, + } + + 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(() => {