From 8626d42eb485af4cff23feade9bce66113611c22 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 15 Dec 2025 02:48:11 +0900 Subject: [PATCH 01/10] fix(react-query/HydrationBoundary): prevent unnecessary refetch during hydration --- .changeset/fix-hydration-double-fetch.md | 6 + docs/framework/react/guides/ssr.md | 2 +- docs/framework/react/reference/hydration.md | 2 + packages/query-core/src/hydration.ts | 5 + packages/query-core/src/index.ts | 1 + packages/query-core/src/queryObserver.ts | 20 +- .../react-query/src/HydrationBoundary.tsx | 18 +- .../src/__tests__/HydrationBoundary.test.tsx | 730 ++++++++++++++++++ 8 files changed, 781 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-hydration-double-fetch.md diff --git a/.changeset/fix-hydration-double-fetch.md b/.changeset/fix-hydration-double-fetch.md new file mode 100644 index 0000000000..1b582cdb9d --- /dev/null +++ b/.changeset/fix-hydration-double-fetch.md @@ -0,0 +1,6 @@ +--- +'@tanstack/query-core': patch +'@tanstack/react-query': patch +--- + +fix(react-query/HydrationBoundary): prevent unnecessary refetch during hydration diff --git a/docs/framework/react/guides/ssr.md b/docs/framework/react/guides/ssr.md index 641c195919..8f8dfaa2d1 100644 --- a/docs/framework/react/guides/ssr.md +++ b/docs/framework/react/guides/ssr.md @@ -536,7 +536,7 @@ This is much better, but if we want to improve this further we can flatten this A query is considered stale depending on when it was `dataUpdatedAt`. A caveat here is that the server needs to have the correct time for this to work properly, but UTC time is used, so timezones do not factor into this. -Because `staleTime` defaults to `0`, queries will be refetched in the background on page load by default. You might want to use a higher `staleTime` to avoid this double fetching, especially if you don't cache your markup. +Because `staleTime` defaults to `0`, queries will be refetched in the background on page load by default. However, when using `HydrationBoundary`, React Query automatically prevents this unnecessary refetching during hydration (unless `refetchOnMount` is explicitly set to `'always'`). For other approaches like `initialData`, you might want to use a higher `staleTime` to avoid this double fetching, especially if you don't cache your markup. This refetching of stale queries is a perfect match when caching markup in a CDN! You can set the cache time of the page itself decently high to avoid having to re-render pages on the server, but configure the `staleTime` of the queries lower to make sure data is refetched in the background as soon as a user visits the page. Maybe you want to cache the pages for a week, but refetch the data automatically on page load if it's older than a day? diff --git a/docs/framework/react/reference/hydration.md b/docs/framework/react/reference/hydration.md index 6f2a3346ad..0aca717c8a 100644 --- a/docs/framework/react/reference/hydration.md +++ b/docs/framework/react/reference/hydration.md @@ -114,6 +114,8 @@ function App() { > Note: Only `queries` can be dehydrated with an `HydrationBoundary`. +> Note: `HydrationBoundary` automatically prevents unnecessary refetching during hydration. Queries being hydrated will not trigger a refetch on mount, unless `refetchOnMount` is explicitly set to `'always'`. + **Options** - `state: DehydratedState` diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index c75d8ee332..7e237e2f0a 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -14,6 +14,11 @@ import type { QueryClient } from './queryClient' import type { Query, QueryState } from './query' import type { Mutation, MutationState } from './mutation' +// WeakSet to track queries that are pending hydration +// Used to prevent double-fetching when HydrationBoundary defers hydration to useEffect +export const pendingHydrationQueries: WeakSet> = + new WeakSet() + // TYPES type TransformerFn = (data: any) => any function defaultTransformerFn(data: any): any { diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index a7763cf648..0cca35c5a9 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -6,6 +6,7 @@ export { defaultShouldDehydrateQuery, dehydrate, hydrate, + pendingHydrationQueries, } from './hydration' export { InfiniteQueryObserver } from './infiniteQueryObserver' export { MutationCache } from './mutationCache' diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 92978673f6..b55a6f6ac7 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -1,4 +1,5 @@ import { focusManager } from './focusManager' +import { pendingHydrationQueries } from './hydration' import { notifyManager } from './notifyManager' import { fetchState } from './query' import { Subscribable } from './subscribable' @@ -97,7 +98,24 @@ export class QueryObserver< if (this.listeners.size === 1) { this.#currentQuery.addObserver(this) - if (shouldFetchOnMount(this.#currentQuery, this.options)) { + // Check if this query is pending hydration + // If so, skip fetch unless refetchOnMount is explicitly 'always' + const hasPendingHydration = pendingHydrationQueries.has( + this.#currentQuery, + ) + + const resolvedRefetchOnMount = + typeof this.options.refetchOnMount === 'function' + ? this.options.refetchOnMount(this.#currentQuery) + : this.options.refetchOnMount + + const shouldSkipFetchForHydration = + hasPendingHydration && resolvedRefetchOnMount !== 'always' + + if ( + shouldFetchOnMount(this.#currentQuery, this.options) && + !shouldSkipFetchForHydration + ) { this.#executeFetch() } else { this.updateResult() diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index 901c8e9686..5b5489dcc3 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { hydrate } from '@tanstack/query-core' +import { hydrate, pendingHydrationQueries } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' import type { DehydratedState, @@ -95,6 +95,14 @@ export const HydrationBoundary = ({ hydrate(client, { queries: newQueries }, optionsRef.current) } if (existingQueries.length > 0) { + // Mark existing queries as pending hydration to prevent double-fetching + // The flag will be cleared in useEffect after hydration completes + for (const dehydratedQuery of existingQueries) { + const query = queryCache.get(dehydratedQuery.queryHash) + if (query) { + pendingHydrationQueries.add(query) + } + } return existingQueries } } @@ -104,6 +112,14 @@ export const HydrationBoundary = ({ React.useEffect(() => { if (hydrationQueue) { hydrate(client, { queries: hydrationQueue }, optionsRef.current) + // Clear pending hydration flags after hydration completes + const queryCache = client.getQueryCache() + for (const dehydratedQuery of hydrationQueue) { + const query = queryCache.get(dehydratedQuery.queryHash) + if (query) { + pendingHydrationQueries.delete(query) + } + } } }, [client, hydrationQueue]) diff --git a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx index 8611c4c40d..1b85491dfa 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -480,4 +480,734 @@ describe('React hydration', () => { prefetchQueryClient.clear() clientQueryClient.clear() }) + + test('should not double fetch when hydrating existing query with fresh data on subsequent visits', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'initial-data')) + + const queryClient = new QueryClient() + + // First, prefetch to populate the cache (simulating initial page visit) + queryClient.prefetchQuery({ + queryKey: ['revisit-test'], + queryFn, + }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + function Page() { + const { data } = useQuery({ + queryKey: ['revisit-test'], + queryFn, + staleTime: 0, + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server prefetch (like React Router loader on subsequent visit) + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['revisit-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn.mockClear() + + // Render with HydrationBoundary containing fresh data + // The existing query in cache should be marked as pending hydration + // and should NOT refetch + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Should NOT refetch because we're hydrating fresh data + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should not refetch when refetchOnMount is true during hydration', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'new-data')) + + const queryClient = new QueryClient() + + // First, prefetch to populate the cache (simulating initial page visit) + queryClient.prefetchQuery({ + queryKey: ['value-true-test'], + queryFn, + }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + function Page() { + const { data } = useQuery({ + queryKey: ['value-true-test'], + queryFn, + staleTime: 0, + refetchOnMount: true, + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['value-true-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn.mockClear() + + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Should NOT refetch because refetchOnMount is true (not 'always') + // and hydration is pending + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should not refetch when refetchOnMount function returns true during hydration', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'new-data')) + + const queryClient = new QueryClient() + + // First, prefetch to populate the cache (simulating initial page visit) + queryClient.prefetchQuery({ + queryKey: ['function-true-test'], + queryFn, + }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + function Page() { + const { data } = useQuery({ + queryKey: ['function-true-test'], + queryFn, + staleTime: 0, + refetchOnMount: () => true, + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['function-true-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn.mockClear() + + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Should NOT refetch because refetchOnMount returns true (not 'always') + // and hydration is pending + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should not refetch when refetchOnMount is false during hydration', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'new-data')) + + const queryClient = new QueryClient() + + // First, prefetch to populate the cache (simulating initial page visit) + queryClient.prefetchQuery({ + queryKey: ['value-false-test'], + queryFn, + }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + function Page() { + const { data } = useQuery({ + queryKey: ['value-false-test'], + queryFn, + staleTime: 0, + refetchOnMount: false, + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['value-false-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn.mockClear() + + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Should NOT refetch because refetchOnMount is false + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should not refetch when refetchOnMount function returns false during hydration', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'new-data')) + + const queryClient = new QueryClient() + + // First, prefetch to populate the cache (simulating initial page visit) + queryClient.prefetchQuery({ + queryKey: ['function-false-test'], + queryFn, + }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + function Page() { + const { data } = useQuery({ + queryKey: ['function-false-test'], + queryFn, + staleTime: 0, + refetchOnMount: () => false, + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['function-false-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn.mockClear() + + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Should NOT refetch because refetchOnMount function returns false + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should still refetch when refetchOnMount is explicitly set to "always" despite hydration', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'new-data')) + + const queryClient = new QueryClient() + + // First, prefetch to populate the cache (simulating initial page visit) + queryClient.prefetchQuery({ + queryKey: ['always-refetch-test'], + queryFn, + }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + function Page() { + const { data } = useQuery({ + queryKey: ['always-refetch-test'], + queryFn, + staleTime: 0, + refetchOnMount: 'always', + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server prefetch (like React Router loader on subsequent visit) + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['always-refetch-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn.mockClear() + + // Render with HydrationBoundary containing fresh data + // Even though hydration is pending, refetchOnMount: 'always' should trigger refetch + const rendered = render( + + + + + , + ) + + // Initially shows cached data (from first prefetch) + expect(rendered.getByText('new-data')).toBeInTheDocument() + + // Allow useEffect to run (hydration happens here) and wait for refetch to complete + // refetchOnMount: 'always' triggers refetch even during hydration + await vi.advanceTimersByTimeAsync(10) + + // Should refetch because refetchOnMount is 'always' + expect(queryFn).toHaveBeenCalledTimes(1) + // Hydration data is shown because useEffect runs after refetch starts + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should still refetch when refetchOnMount function returns "always" despite hydration', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'new-data')) + + const queryClient = new QueryClient() + + // First, prefetch to populate the cache (simulating initial page visit) + queryClient.prefetchQuery({ + queryKey: ['function-refetch-test'], + queryFn, + }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + function Page() { + const { data } = useQuery({ + queryKey: ['function-refetch-test'], + queryFn, + staleTime: 0, + refetchOnMount: () => 'always', + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['function-refetch-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn.mockClear() + + const rendered = render( + + + + + , + ) + + expect(rendered.getByText('new-data')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(10) + + // Should refetch because refetchOnMount function returns 'always' + expect(queryFn).toHaveBeenCalledTimes(1) + // Hydration data is shown because useEffect runs after refetch starts + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should not double fetch for multiple queries when hydrating', async () => { + const queryFn1 = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'data-1')) + const queryFn2 = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'data-2')) + + const queryClient = new QueryClient() + + // First, prefetch multiple queries + queryClient.prefetchQuery({ queryKey: ['multi-1'], queryFn: queryFn1 }) + queryClient.prefetchQuery({ queryKey: ['multi-2'], queryFn: queryFn2 }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn1).toHaveBeenCalledTimes(1) + expect(queryFn2).toHaveBeenCalledTimes(1) + + function Page() { + const query1 = useQuery({ + queryKey: ['multi-1'], + queryFn: queryFn1, + staleTime: 0, + }) + const query2 = useQuery({ + queryKey: ['multi-2'], + queryFn: queryFn2, + staleTime: 0, + }) + return ( +
+

{query1.data}

+

{query2.data}

+
+ ) + } + + // Simulate server prefetch for multiple queries + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['multi-1'], + queryFn: () => sleep(10).then(() => 'server-1'), + }) + serverQueryClient.prefetchQuery({ + queryKey: ['multi-2'], + queryFn: () => sleep(10).then(() => 'server-2'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn1.mockClear() + queryFn2.mockClear() + + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Neither query should refetch + expect(queryFn1).toHaveBeenCalledTimes(0) + expect(queryFn2).toHaveBeenCalledTimes(0) + expect(rendered.getByText('server-1')).toBeInTheDocument() + expect(rendered.getByText('server-2')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should hydrate new queries immediately without pending flag', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'client-data')) + + // Client has no existing query (empty cache) + const queryClient = new QueryClient() + + function Page() { + const { data } = useQuery({ + queryKey: ['new-query-test'], + queryFn, + staleTime: Infinity, + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['new-query-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // New queries are hydrated immediately in useMemo (not queued for useEffect) + // This verifies our pendingHydrationQueries logic doesn't break existing behavior + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should not hydrate when server data is older than client data', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'new-data')) + + const queryClient = new QueryClient() + + // First, prefetch to populate the cache with newer data + queryClient.prefetchQuery({ + queryKey: ['older-data-test'], + queryFn: () => sleep(10).then(() => 'newer-client-data'), + }) + await vi.advanceTimersByTimeAsync(10) + + function Page() { + const { data } = useQuery({ + queryKey: ['older-data-test'], + queryFn, + staleTime: Infinity, + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server with OLDER data (dataUpdatedAt is earlier) + const serverQueryClient = new QueryClient() + // Manually set older data by setting dataUpdatedAt to past + serverQueryClient.setQueryData(['older-data-test'], 'older-server-data', { + updatedAt: Date.now() - 10000, // 10 seconds ago + }) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn.mockClear() + + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Should NOT refetch and should keep client data (server data is older) + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('newer-client-data')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should handle gracefully when query is removed from cache during hydration in useMemo', async () => { + const queryClient = new QueryClient() + + // First, prefetch to populate the cache + queryClient.prefetchQuery({ + queryKey: ['removed-query-test-memo'], + queryFn: () => sleep(10).then(() => 'initial-data'), + }) + await vi.advanceTimersByTimeAsync(10) + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['removed-query-test-memo'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + // Mock queryCache.get to return undefined on second call within useMemo + // First call: existingQuery check (line 70) - returns query + // Second call: pendingHydrationQueries.add (line 101) - returns undefined + const queryCache = queryClient.getQueryCache() + const originalGet = queryCache.get.bind(queryCache) + let callCount = 0 + vi.spyOn(queryCache, 'get').mockImplementation((queryHash) => { + callCount++ + // First call returns the query (for existingQuery check) + // Second call returns undefined (simulates removal before pendingHydrationQueries.add) + if (callCount === 1) { + return originalGet(queryHash) + } + return undefined + }) + + function Page() { + const { data } = useQuery({ + queryKey: ['removed-query-test-memo'], + queryFn: () => sleep(10).then(() => 'new-data'), + staleTime: Infinity, + }) + return ( +
+

{data ?? 'loading'}

+
+ ) + } + + // This should not throw even if query is removed during hydration + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // The component should render without crashing + expect(rendered.container).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should handle gracefully when query is removed from cache during hydration in useEffect', async () => { + const queryClient = new QueryClient() + + // First, prefetch to populate the cache + queryClient.prefetchQuery({ + queryKey: ['removed-query-test-effect'], + queryFn: () => sleep(10).then(() => 'initial-data'), + }) + await vi.advanceTimersByTimeAsync(10) + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['removed-query-test-effect'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + // Mock queryCache.get to return undefined on third call (in useEffect) + // First call: existingQuery check (line 70) - returns query + // Second call: pendingHydrationQueries.add (line 101) - returns query + // Third call: useEffect pendingHydrationQueries.delete (line 118) - returns undefined + const queryCache = queryClient.getQueryCache() + const originalGet = queryCache.get.bind(queryCache) + let callCount = 0 + vi.spyOn(queryCache, 'get').mockImplementation((queryHash) => { + callCount++ + // First two calls return the query + // Third call returns undefined (simulates removal before useEffect cleanup) + if (callCount <= 2) { + return originalGet(queryHash) + } + return undefined + }) + + function Page() { + const { data } = useQuery({ + queryKey: ['removed-query-test-effect'], + queryFn: () => sleep(10).then(() => 'new-data'), + staleTime: Infinity, + }) + return ( +
+

{data ?? 'loading'}

+
+ ) + } + + // This should not throw even if query is removed during hydration + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // The component should render without crashing + expect(rendered.container).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) }) From 695fa28ed59f3bd94d4bfd1f32bdce4bea192a34 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Sat, 27 Dec 2025 23:36:07 +0900 Subject: [PATCH 02/10] chore(.changeset): change version bump from 'patch' to 'minor' --- .changeset/fix-hydration-double-fetch.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/fix-hydration-double-fetch.md b/.changeset/fix-hydration-double-fetch.md index 1b582cdb9d..26b16031a9 100644 --- a/.changeset/fix-hydration-double-fetch.md +++ b/.changeset/fix-hydration-double-fetch.md @@ -1,6 +1,6 @@ --- -'@tanstack/query-core': patch -'@tanstack/react-query': patch +'@tanstack/query-core': minor +'@tanstack/react-query': minor --- fix(react-query/HydrationBoundary): prevent unnecessary refetch during hydration From 2738c99efff791ef101eb167d313666e1d70fe81 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Sun, 28 Dec 2025 02:54:01 +0900 Subject: [PATCH 03/10] refactor(react-query/HydrationBoundary): add useEffect cleanup and extract 'clearPendingQueries' function --- .../react-query/src/HydrationBoundary.tsx | 22 +++- .../src/__tests__/HydrationBoundary.test.tsx | 116 ++++++++++++++++++ 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index 5b5489dcc3..a6a51c2219 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -112,15 +112,25 @@ export const HydrationBoundary = ({ React.useEffect(() => { if (hydrationQueue) { hydrate(client, { queries: hydrationQueue }, optionsRef.current) - // Clear pending hydration flags after hydration completes - const queryCache = client.getQueryCache() - for (const dehydratedQuery of hydrationQueue) { - const query = queryCache.get(dehydratedQuery.queryHash) - if (query) { - pendingHydrationQueries.delete(query) + } + + const clearPendingQueries = () => { + if (hydrationQueue) { + const queryCache = client.getQueryCache() + for (const dehydratedQuery of hydrationQueue) { + const query = queryCache.get(dehydratedQuery.queryHash) + if (query) { + pendingHydrationQueries.delete(query) + } } } } + + // Clear pending hydration flags after hydration completes + clearPendingQueries() + + // Cleanup: also clear on unmount in case component unmounts before effect runs + return clearPendingQueries }, [client, hydrationQueue]) return children as React.ReactElement diff --git a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx index 39cc73e752..6de7803657 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import * as React from 'react' import { render } from '@testing-library/react' import * as coreModule from '@tanstack/query-core' +import { pendingHydrationQueries } from '@tanstack/query-core' import { sleep } from '@tanstack/query-test-utils' import { HydrationBoundary, @@ -1270,4 +1271,119 @@ describe('React hydration', () => { queryClient.clear() serverQueryClient.clear() }) + + test('should clear pending hydration flags on unmount', async () => { + const queryClient = new QueryClient() + + // First, prefetch to populate the cache + queryClient.prefetchQuery({ + queryKey: ['unmount-cleanup-test'], + queryFn: () => sleep(10).then(() => 'initial-data'), + }) + await vi.advanceTimersByTimeAsync(10) + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['unmount-cleanup-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + function Page() { + const { data } = useQuery({ + queryKey: ['unmount-cleanup-test'], + queryFn: () => sleep(10).then(() => 'new-data'), + staleTime: Infinity, + }) + return ( +
+

{data ?? 'loading'}

+
+ ) + } + + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Get the query to check pending status + const query = queryClient.getQueryCache().find({ + queryKey: ['unmount-cleanup-test'], + }) + expect(query).toBeDefined() + + // After useEffect runs, the pending flag should be cleared + expect(pendingHydrationQueries.has(query!)).toBe(false) + + // Unmount and verify cleanup + rendered.unmount() + + // After unmount, the pending flag should still be cleared + expect(pendingHydrationQueries.has(query!)).toBe(false) + + queryClient.clear() + serverQueryClient.clear() + }) + + test('should clear pending hydration flags when component unmounts before hydration completes', async () => { + const queryClient = new QueryClient() + + // First, prefetch to populate the cache + queryClient.prefetchQuery({ + queryKey: ['early-unmount-test'], + queryFn: () => sleep(10).then(() => 'initial-data'), + }) + await vi.advanceTimersByTimeAsync(10) + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['early-unmount-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + function ConditionalHydrationBoundary({ show }: { show: boolean }) { + if (!show) return null + return ( + +
content
+
+ ) + } + + const rendered = render( + + + , + ) + + // Get the query - at this point it should be marked as pending hydration + const query = queryClient.getQueryCache().find({ + queryKey: ['early-unmount-test'], + }) + expect(query).toBeDefined() + + // Unmount before useEffect has a chance to run hydration + rendered.rerender( + + + , + ) + + // After unmount cleanup, the pending flag should be cleared + expect(pendingHydrationQueries.has(query!)).toBe(false) + + queryClient.clear() + serverQueryClient.clear() + }) }) From bcd214e2a5a200511fb4c645797e3b5443997c67 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Tue, 30 Dec 2025 01:00:58 +0900 Subject: [PATCH 04/10] chore(.changeset): change version bump from 'minor' to 'patch' --- .changeset/fix-hydration-double-fetch.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/fix-hydration-double-fetch.md b/.changeset/fix-hydration-double-fetch.md index 26b16031a9..1b582cdb9d 100644 --- a/.changeset/fix-hydration-double-fetch.md +++ b/.changeset/fix-hydration-double-fetch.md @@ -1,6 +1,6 @@ --- -'@tanstack/query-core': minor -'@tanstack/react-query': minor +'@tanstack/query-core': patch +'@tanstack/react-query': patch --- fix(react-query/HydrationBoundary): prevent unnecessary refetch during hydration From 058fee003413da1f56a969142f1ed51501da6774 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Wed, 21 Jan 2026 09:56:12 +0900 Subject: [PATCH 05/10] refactor(react-query): replace global 'pendingHydrationQueries' with React Context --- packages/query-core/src/hydration.ts | 5 - packages/query-core/src/index.ts | 1 - packages/query-core/src/queryObserver.ts | 9 +- packages/query-core/src/types.ts | 6 + .../react-query/src/HydrationBoundary.tsx | 130 ++++++++++-------- .../react-query/src/IsHydratingProvider.ts | 15 ++ .../src/__tests__/HydrationBoundary.test.tsx | 113 ++++++--------- packages/react-query/src/index.ts | 1 + packages/react-query/src/useBaseQuery.ts | 9 ++ packages/react-query/src/useQueries.ts | 9 ++ 10 files changed, 154 insertions(+), 144 deletions(-) create mode 100644 packages/react-query/src/IsHydratingProvider.ts diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index 7e237e2f0a..c75d8ee332 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -14,11 +14,6 @@ import type { QueryClient } from './queryClient' import type { Query, QueryState } from './query' import type { Mutation, MutationState } from './mutation' -// WeakSet to track queries that are pending hydration -// Used to prevent double-fetching when HydrationBoundary defers hydration to useEffect -export const pendingHydrationQueries: WeakSet> = - new WeakSet() - // TYPES type TransformerFn = (data: any) => any function defaultTransformerFn(data: any): any { diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 0cca35c5a9..a7763cf648 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -6,7 +6,6 @@ export { defaultShouldDehydrateQuery, dehydrate, hydrate, - pendingHydrationQueries, } from './hydration' export { InfiniteQueryObserver } from './infiniteQueryObserver' export { MutationCache } from './mutationCache' diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index ceda2ede12..b1f14a5736 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -1,5 +1,4 @@ import { focusManager } from './focusManager' -import { pendingHydrationQueries } from './hydration' import { notifyManager } from './notifyManager' import { fetchState } from './query' import { Subscribable } from './subscribable' @@ -97,19 +96,15 @@ export class QueryObserver< if (this.listeners.size === 1) { this.#currentQuery.addObserver(this) - // Check if this query is pending hydration + // Check if this query is pending hydration via options._isHydrating // If so, skip fetch unless refetchOnMount is explicitly 'always' - const hasPendingHydration = pendingHydrationQueries.has( - this.#currentQuery, - ) - const resolvedRefetchOnMount = typeof this.options.refetchOnMount === 'function' ? this.options.refetchOnMount(this.#currentQuery) : this.options.refetchOnMount const shouldSkipFetchForHydration = - hasPendingHydration && resolvedRefetchOnMount !== 'always' + this.options._isHydrating && resolvedRefetchOnMount !== 'always' if ( shouldFetchOnMount(this.#currentQuery, this.options) && diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index 4f3f4caed2..569cef38ed 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -434,6 +434,12 @@ export interface QueryObserverOptions< _optimisticResults?: 'optimistic' | 'isRestoring' + /** + * Internal flag to indicate this query is pending hydration. + * When true, the observer will skip fetching on mount unless refetchOnMount is 'always'. + */ + _isHydrating?: boolean + /** * Enable prefetching during rendering */ diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index a6a51c2219..bddf8b6460 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -1,8 +1,9 @@ 'use client' import * as React from 'react' -import { hydrate, pendingHydrationQueries } from '@tanstack/query-core' +import { hydrate } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' +import { IsHydratingProvider } from './IsHydratingProvider' import type { DehydratedState, HydrateOptions, @@ -50,64 +51,66 @@ export const HydrationBoundary = ({ // If the transition is aborted, we will have hydrated any _new_ queries, but // we throw away the fresh data for any existing ones to avoid unexpectedly // updating the UI. - const hydrationQueue: DehydratedState['queries'] | undefined = - React.useMemo(() => { - if (state) { - if (typeof state !== 'object') { - return - } + // Create a mutable ref object to hold pending query hashes + // This allows us to add/remove hashes without triggering re-renders + const hydratingQueriesRef = React.useRef>(new Set()) + + const hydrationQueue = React.useMemo(() => { + if (state) { + if (typeof state !== 'object') { + return undefined + } + + const queryCache = client.getQueryCache() + // State is supplied from the outside and we might as well fail + // gracefully if it has the wrong shape, so while we type `queries` + // as required, we still provide a fallback. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const queries = state.queries || [] - const queryCache = client.getQueryCache() - // State is supplied from the outside and we might as well fail - // gracefully if it has the wrong shape, so while we type `queries` - // as required, we still provide a fallback. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const queries = state.queries || [] - - const newQueries: DehydratedState['queries'] = [] - const existingQueries: DehydratedState['queries'] = [] - for (const dehydratedQuery of queries) { - const existingQuery = queryCache.get(dehydratedQuery.queryHash) - - if (!existingQuery) { - newQueries.push(dehydratedQuery) - } else { - const hydrationIsNewer = - dehydratedQuery.state.dataUpdatedAt > - existingQuery.state.dataUpdatedAt || - (dehydratedQuery.promise && - existingQuery.state.status !== 'pending' && - existingQuery.state.fetchStatus !== 'fetching' && - dehydratedQuery.dehydratedAt !== undefined && - dehydratedQuery.dehydratedAt > - existingQuery.state.dataUpdatedAt) - - if (hydrationIsNewer) { - existingQueries.push(dehydratedQuery) - } + const newQueries: DehydratedState['queries'] = [] + const existingQueries: DehydratedState['queries'] = [] + for (const dehydratedQuery of queries) { + const existingQuery = queryCache.get(dehydratedQuery.queryHash) + + if (!existingQuery) { + newQueries.push(dehydratedQuery) + } else { + const hydrationIsNewer = + dehydratedQuery.state.dataUpdatedAt > + existingQuery.state.dataUpdatedAt || + (dehydratedQuery.promise && + existingQuery.state.status !== 'pending' && + existingQuery.state.fetchStatus !== 'fetching' && + dehydratedQuery.dehydratedAt !== undefined && + dehydratedQuery.dehydratedAt > + existingQuery.state.dataUpdatedAt) + + if (hydrationIsNewer) { + existingQueries.push(dehydratedQuery) } } + } - if (newQueries.length > 0) { - // It's actually fine to call this with queries/state that already exists - // in the cache, or is older. hydrate() is idempotent for queries. - // eslint-disable-next-line react-hooks/refs - hydrate(client, { queries: newQueries }, optionsRef.current) - } - if (existingQueries.length > 0) { - // Mark existing queries as pending hydration to prevent double-fetching - // The flag will be cleared in useEffect after hydration completes - for (const dehydratedQuery of existingQueries) { - const query = queryCache.get(dehydratedQuery.queryHash) - if (query) { - pendingHydrationQueries.add(query) - } - } - return existingQueries + if (newQueries.length > 0) { + // It's actually fine to call this with queries/state that already exists + // in the cache, or is older. hydrate() is idempotent for queries. + // eslint-disable-next-line react-hooks/refs + hydrate(client, { queries: newQueries }, optionsRef.current) + } + + if (existingQueries.length > 0) { + // Add pending hashes to the mutable ref + /* eslint-disable react-hooks/refs */ + for (const dehydratedQuery of existingQueries) { + hydratingQueriesRef.current.add(dehydratedQuery.queryHash) } + /* eslint-enable react-hooks/refs */ + return existingQueries } - return undefined - }, [client, state]) + } + return undefined + }, [client, state]) React.useEffect(() => { if (hydrationQueue) { @@ -116,12 +119,8 @@ export const HydrationBoundary = ({ const clearPendingQueries = () => { if (hydrationQueue) { - const queryCache = client.getQueryCache() for (const dehydratedQuery of hydrationQueue) { - const query = queryCache.get(dehydratedQuery.queryHash) - if (query) { - pendingHydrationQueries.delete(query) - } + hydratingQueriesRef.current.delete(dehydratedQuery.queryHash) } } } @@ -133,5 +132,18 @@ export const HydrationBoundary = ({ return clearPendingQueries }, [client, hydrationQueue]) - return children as React.ReactElement + // Provide the mutable ref to children + // Children can check ref.current.has(hash) without needing re-renders + /* eslint-disable react-hooks/refs */ + const contextValue = React.useMemo( + () => ({ current: hydratingQueriesRef.current }), + [], + ) + /* eslint-enable react-hooks/refs */ + + return ( + + {children} + + ) } diff --git a/packages/react-query/src/IsHydratingProvider.ts b/packages/react-query/src/IsHydratingProvider.ts new file mode 100644 index 0000000000..1b46729096 --- /dev/null +++ b/packages/react-query/src/IsHydratingProvider.ts @@ -0,0 +1,15 @@ +'use client' +import * as React from 'react' + +// Mutable set of query hashes that are pending hydration +// Using a ref-like object so we can mutate it without re-rendering +interface HydratingQueriesRef { + current: Set +} + +const defaultRef: HydratingQueriesRef = { current: new Set() } + +const IsHydratingContext = React.createContext(defaultRef) + +export const useIsHydrating = () => React.useContext(IsHydratingContext) +export const IsHydratingProvider = IsHydratingContext.Provider diff --git a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx index 6de7803657..1e7567266f 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import * as React from 'react' import { render } from '@testing-library/react' import * as coreModule from '@tanstack/query-core' -import { pendingHydrationQueries } from '@tanstack/query-core' import { sleep } from '@tanstack/query-test-utils' import { HydrationBoundary, @@ -877,7 +876,7 @@ describe('React hydration', () => { queryFn.mockClear() // Render with HydrationBoundary containing fresh data - // Even though hydration is pending, refetchOnMount: 'always' should trigger refetch + // refetchOnMount: 'always' should trigger refetch even during hydration const rendered = render( @@ -889,11 +888,11 @@ describe('React hydration', () => { // Initially shows cached data (from first prefetch) expect(rendered.getByText('new-data')).toBeInTheDocument() - // Allow useEffect to run (hydration happens here) and wait for refetch to complete - // refetchOnMount: 'always' triggers refetch even during hydration + // refetchOnMount: 'always' should trigger refetch even during hydration + // Wait for the refetch to complete await vi.advanceTimersByTimeAsync(10) - // Should refetch because refetchOnMount is 'always' + // Should refetch because refetchOnMount is 'always' bypasses hydration skip expect(queryFn).toHaveBeenCalledTimes(1) // Hydration data is shown because useEffect runs after refetch starts expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() @@ -1159,15 +1158,15 @@ describe('React hydration', () => { const dehydratedState = dehydrate(serverQueryClient) // Mock queryCache.get to return undefined on second call within useMemo - // First call: existingQuery check (line 70) - returns query - // Second call: pendingHydrationQueries.add (line 101) - returns undefined + // First call: existingQuery check - returns query + // Second call: adding to hydrating set - returns undefined const queryCache = queryClient.getQueryCache() const originalGet = queryCache.get.bind(queryCache) let callCount = 0 vi.spyOn(queryCache, 'get').mockImplementation((queryHash) => { callCount++ // First call returns the query (for existingQuery check) - // Second call returns undefined (simulates removal before pendingHydrationQueries.add) + // Second call returns undefined (simulates removal before adding to hydrating set) if (callCount === 1) { return originalGet(queryHash) } @@ -1225,9 +1224,9 @@ describe('React hydration', () => { const dehydratedState = dehydrate(serverQueryClient) // Mock queryCache.get to return undefined on third call (in useEffect) - // First call: existingQuery check (line 70) - returns query - // Second call: pendingHydrationQueries.add (line 101) - returns query - // Third call: useEffect pendingHydrationQueries.delete (line 118) - returns undefined + // First call: existingQuery check - returns query + // Second call: adding to hydrating set - returns query + // Third call: useEffect cleanup - returns undefined const queryCache = queryClient.getQueryCache() const originalGet = queryCache.get.bind(queryCache) let callCount = 0 @@ -1272,15 +1271,20 @@ describe('React hydration', () => { serverQueryClient.clear() }) - test('should clear pending hydration flags on unmount', async () => { + test('should not refetch after unmount and remount during hydration', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'new-data')) + const queryClient = new QueryClient() // First, prefetch to populate the cache queryClient.prefetchQuery({ queryKey: ['unmount-cleanup-test'], - queryFn: () => sleep(10).then(() => 'initial-data'), + queryFn, }) await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) // Simulate server prefetch const serverQueryClient = new QueryClient() @@ -1291,11 +1295,13 @@ describe('React hydration', () => { await vi.advanceTimersByTimeAsync(10) const dehydratedState = dehydrate(serverQueryClient) + queryFn.mockClear() + function Page() { const { data } = useQuery({ queryKey: ['unmount-cleanup-test'], - queryFn: () => sleep(10).then(() => 'new-data'), - staleTime: Infinity, + queryFn, + staleTime: 0, }) return (
@@ -1314,76 +1320,39 @@ describe('React hydration', () => { await vi.advanceTimersByTimeAsync(0) - // Get the query to check pending status - const query = queryClient.getQueryCache().find({ - queryKey: ['unmount-cleanup-test'], - }) - expect(query).toBeDefined() - - // After useEffect runs, the pending flag should be cleared - expect(pendingHydrationQueries.has(query!)).toBe(false) + // Should not refetch during hydration + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() - // Unmount and verify cleanup + // Unmount rendered.unmount() - // After unmount, the pending flag should still be cleared - expect(pendingHydrationQueries.has(query!)).toBe(false) - - queryClient.clear() - serverQueryClient.clear() - }) - - test('should clear pending hydration flags when component unmounts before hydration completes', async () => { - const queryClient = new QueryClient() - - // First, prefetch to populate the cache - queryClient.prefetchQuery({ - queryKey: ['early-unmount-test'], - queryFn: () => sleep(10).then(() => 'initial-data'), - }) - await vi.advanceTimersByTimeAsync(10) - - // Simulate server prefetch - const serverQueryClient = new QueryClient() - serverQueryClient.prefetchQuery({ - queryKey: ['early-unmount-test'], - queryFn: () => sleep(10).then(() => 'fresh-from-server'), + // Create a new dehydrated state with newer data for second mount + const serverQueryClient2 = new QueryClient() + serverQueryClient2.prefetchQuery({ + queryKey: ['unmount-cleanup-test'], + queryFn: () => sleep(10).then(() => 'second-server-data'), }) await vi.advanceTimersByTimeAsync(10) - const dehydratedState = dehydrate(serverQueryClient) - - function ConditionalHydrationBoundary({ show }: { show: boolean }) { - if (!show) return null - return ( - -
content
-
- ) - } + const dehydratedState2 = dehydrate(serverQueryClient2) - const rendered = render( + // Remounting with new hydration state + const rendered2 = render( - + + + , ) - // Get the query - at this point it should be marked as pending hydration - const query = queryClient.getQueryCache().find({ - queryKey: ['early-unmount-test'], - }) - expect(query).toBeDefined() - - // Unmount before useEffect has a chance to run hydration - rendered.rerender( - - - , - ) + await vi.advanceTimersByTimeAsync(0) - // After unmount cleanup, the pending flag should be cleared - expect(pendingHydrationQueries.has(query!)).toBe(false) + // Should show new hydrated data and not refetch + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered2.getByText('second-server-data')).toBeInTheDocument() queryClient.clear() serverQueryClient.clear() + serverQueryClient2.clear() }) }) diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index 36ea8da7af..cd57f5ff6d 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -54,3 +54,4 @@ export { useMutation } from './useMutation' export { mutationOptions } from './mutationOptions' export { useInfiniteQuery } from './useInfiniteQuery' export { useIsRestoring, IsRestoringProvider } from './IsRestoringProvider' +export { useIsHydrating, IsHydratingProvider } from './IsHydratingProvider' diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 2a151fe113..c5a1652446 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -9,6 +9,7 @@ import { getHasError, useClearResetErrorBoundary, } from './errorBoundaryUtils' +import { useIsHydrating } from './IsHydratingProvider' import { useIsRestoring } from './IsRestoringProvider' import { ensureSuspenseTimers, @@ -50,9 +51,17 @@ export function useBaseQuery< } const isRestoring = useIsRestoring() + const hydratingQueriesRef = useIsHydrating() const errorResetBoundary = useQueryErrorResetBoundary() const client = useQueryClient(queryClient) const defaultedOptions = client.defaultQueryOptions(options) + + // Check if this query is pending hydration (using mutable ref) + // eslint-disable-next-line react-hooks/refs + if (hydratingQueriesRef.current.has(defaultedOptions.queryHash)) { + defaultedOptions._isHydrating = true + } + ;(client.getDefaultOptions().queries as any)?._experimental_beforeQuery?.( defaultedOptions, ) diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 6eabef4060..26c35d39de 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -8,6 +8,7 @@ import { notifyManager, } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' +import { useIsHydrating } from './IsHydratingProvider' import { useIsRestoring } from './IsRestoringProvider' import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary' import { @@ -223,6 +224,7 @@ export function useQueries< ): TCombinedResult { const client = useQueryClient(queryClient) const isRestoring = useIsRestoring() + const hydratingQueriesRef = useIsHydrating() const errorResetBoundary = useQueryErrorResetBoundary() const defaultedQueries = React.useMemo( @@ -237,8 +239,15 @@ export function useQueries< ? 'isRestoring' : 'optimistic' + // Check if this query is pending hydration (using mutable ref) + if (hydratingQueriesRef.current.has(defaultedOptions.queryHash)) { + defaultedOptions._isHydrating = true + } + return defaultedOptions }), + // Note: hydratingQueriesRef is a stable ref object, so we don't include it in deps + // eslint-disable-next-line react-hooks/exhaustive-deps [queries, client, isRestoring], ) From cdbc754a017b7d77f0d2505a9dafdb082daa8385 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 21 Jan 2026 00:57:36 +0000 Subject: [PATCH 06/10] ci: apply automated fixes --- packages/react-query/src/HydrationBoundary.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index bddf8b6460..8b360806c3 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -83,8 +83,7 @@ export const HydrationBoundary = ({ existingQuery.state.status !== 'pending' && existingQuery.state.fetchStatus !== 'fetching' && dehydratedQuery.dehydratedAt !== undefined && - dehydratedQuery.dehydratedAt > - existingQuery.state.dataUpdatedAt) + dehydratedQuery.dehydratedAt > existingQuery.state.dataUpdatedAt) if (hydrationIsNewer) { existingQueries.push(dehydratedQuery) @@ -142,8 +141,6 @@ export const HydrationBoundary = ({ /* eslint-enable react-hooks/refs */ return ( - - {children} - + {children} ) } From f840a0aecb36d86fa41cd181aaf6e1b1ad3b26ab Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Wed, 21 Jan 2026 10:00:49 +0900 Subject: [PATCH 07/10] fix(query-core): refetch stale data during hydration for cached markup scenario --- packages/query-core/src/queryObserver.ts | 9 +- .../src/__tests__/HydrationBoundary.test.tsx | 89 ++++++++++++++++--- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index b1f14a5736..59a20ff432 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -97,14 +97,19 @@ export class QueryObserver< this.#currentQuery.addObserver(this) // Check if this query is pending hydration via options._isHydrating - // If so, skip fetch unless refetchOnMount is explicitly 'always' + // If so, skip fetch unless: + // - refetchOnMount is explicitly 'always', or + // - the hydrated data is stale (e.g., cached markup scenario where + // server fetch happened long ago) const resolvedRefetchOnMount = typeof this.options.refetchOnMount === 'function' ? this.options.refetchOnMount(this.#currentQuery) : this.options.refetchOnMount const shouldSkipFetchForHydration = - this.options._isHydrating && resolvedRefetchOnMount !== 'always' + this.options._isHydrating && + resolvedRefetchOnMount !== 'always' && + !isStale(this.#currentQuery, this.options) if ( shouldFetchOnMount(this.#currentQuery, this.options) && diff --git a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx index 1e7567266f..adc0b42525 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -560,7 +560,9 @@ describe('React hydration', () => { const { data } = useQuery({ queryKey: ['revisit-test'], queryFn, - staleTime: 0, + // Use staleTime to prevent refetch during hydration + // When data is not stale, hydration should skip refetch + staleTime: Infinity, }) return (
@@ -582,7 +584,7 @@ describe('React hydration', () => { // Render with HydrationBoundary containing fresh data // The existing query in cache should be marked as pending hydration - // and should NOT refetch + // and should NOT refetch because data is not stale (staleTime: Infinity) const rendered = render( @@ -593,7 +595,7 @@ describe('React hydration', () => { await vi.advanceTimersByTimeAsync(0) - // Should NOT refetch because we're hydrating fresh data + // Should NOT refetch because data is not stale expect(queryFn).toHaveBeenCalledTimes(0) expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() @@ -620,7 +622,8 @@ describe('React hydration', () => { const { data } = useQuery({ queryKey: ['value-true-test'], queryFn, - staleTime: 0, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, refetchOnMount: true, }) return ( @@ -652,7 +655,7 @@ describe('React hydration', () => { await vi.advanceTimersByTimeAsync(0) // Should NOT refetch because refetchOnMount is true (not 'always') - // and hydration is pending + // and data is not stale expect(queryFn).toHaveBeenCalledTimes(0) expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() @@ -679,7 +682,8 @@ describe('React hydration', () => { const { data } = useQuery({ queryKey: ['function-true-test'], queryFn, - staleTime: 0, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, refetchOnMount: () => true, }) return ( @@ -711,7 +715,7 @@ describe('React hydration', () => { await vi.advanceTimersByTimeAsync(0) // Should NOT refetch because refetchOnMount returns true (not 'always') - // and hydration is pending + // and data is not stale expect(queryFn).toHaveBeenCalledTimes(0) expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() @@ -962,6 +966,66 @@ describe('React hydration', () => { serverQueryClient.clear() }) + test('should refetch when hydrated data is stale (cached markup scenario)', async () => { + const queryFn = vi + .fn() + .mockImplementation(() => sleep(10).then(() => 'new-data')) + + const queryClient = new QueryClient() + + // First, prefetch to populate the cache + queryClient.prefetchQuery({ + queryKey: ['stale-hydration-test'], + queryFn, + }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + function Page() { + const { data } = useQuery({ + queryKey: ['stale-hydration-test'], + queryFn, + // staleTime: 0 means data is immediately stale + // This simulates cached markup scenario where server fetch was long ago + staleTime: 0, + }) + return ( +
+

{data}

+
+ ) + } + + // Simulate server prefetch + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['stale-hydration-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + queryFn.mockClear() + + render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Should refetch because data is stale (staleTime: 0) + // This is the "cached markup scenario" - when hydrated data is old, + // we should still refetch to get fresh data + expect(queryFn).toHaveBeenCalledTimes(1) + + queryClient.clear() + serverQueryClient.clear() + }) + test('should not double fetch for multiple queries when hydrating', async () => { const queryFn1 = vi .fn() @@ -983,12 +1047,14 @@ describe('React hydration', () => { const query1 = useQuery({ queryKey: ['multi-1'], queryFn: queryFn1, - staleTime: 0, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, }) const query2 = useQuery({ queryKey: ['multi-2'], queryFn: queryFn2, - staleTime: 0, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, }) return (
@@ -1301,7 +1367,8 @@ describe('React hydration', () => { const { data } = useQuery({ queryKey: ['unmount-cleanup-test'], queryFn, - staleTime: 0, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, }) return (
@@ -1320,7 +1387,7 @@ describe('React hydration', () => { await vi.advanceTimersByTimeAsync(0) - // Should not refetch during hydration + // Should not refetch during hydration because data is not stale expect(queryFn).toHaveBeenCalledTimes(0) expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() From bb9cced866384397b1cf86f2962380d1366715e3 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Wed, 21 Jan 2026 10:04:24 +0900 Subject: [PATCH 08/10] docs(react): update hydration docs to reflect stale data refetch behavior --- docs/framework/react/guides/ssr.md | 2 +- docs/framework/react/reference/hydration.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/framework/react/guides/ssr.md b/docs/framework/react/guides/ssr.md index 5fbbd3b8a0..d0b35e0b60 100644 --- a/docs/framework/react/guides/ssr.md +++ b/docs/framework/react/guides/ssr.md @@ -536,7 +536,7 @@ This is much better, but if we want to improve this further we can flatten this A query is considered stale depending on when it was `dataUpdatedAt`. A caveat here is that the server needs to have the correct time for this to work properly, but UTC time is used, so timezones do not factor into this. -Because `staleTime` defaults to `0`, queries will be refetched in the background on page load by default. However, when using `HydrationBoundary`, React Query automatically prevents this unnecessary refetching during hydration (unless `refetchOnMount` is explicitly set to `'always'`). For other approaches like `initialData`, you might want to use a higher `staleTime` to avoid this double fetching, especially if you don't cache your markup. +Because `staleTime` defaults to `0`, queries will be refetched in the background on page load by default. When using `HydrationBoundary`, React Query intelligently handles this: if the hydrated data is still fresh (within `staleTime`), it prevents unnecessary refetching during hydration. However, if the hydrated data is stale (e.g., from cached markup where the server fetch happened long ago), a refetch will be triggered. You can always force a refetch by setting `refetchOnMount` to `'always'`. For other approaches like `initialData`, you might want to use a higher `staleTime` to avoid double fetching. This refetching of stale queries is a perfect match when caching markup in a CDN! You can set the cache time of the page itself decently high to avoid having to re-render pages on the server, but configure the `staleTime` of the queries lower to make sure data is refetched in the background as soon as a user visits the page. Maybe you want to cache the pages for a week, but refetch the data automatically on page load if it's older than a day? diff --git a/docs/framework/react/reference/hydration.md b/docs/framework/react/reference/hydration.md index 0aca717c8a..f4c49251b6 100644 --- a/docs/framework/react/reference/hydration.md +++ b/docs/framework/react/reference/hydration.md @@ -114,7 +114,7 @@ function App() { > Note: Only `queries` can be dehydrated with an `HydrationBoundary`. -> Note: `HydrationBoundary` automatically prevents unnecessary refetching during hydration. Queries being hydrated will not trigger a refetch on mount, unless `refetchOnMount` is explicitly set to `'always'`. +> Note: `HydrationBoundary` intelligently prevents unnecessary refetching during hydration. Queries being hydrated will not trigger a refetch on mount if the data is still fresh (within `staleTime`). However, if the hydrated data is stale (e.g., from cached markup), a refetch will be triggered. You can always force a refetch by setting `refetchOnMount` to `'always'`. **Options** From b63f6ec0ab0092f7a9a521d5a74c1bd27f78d597 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Wed, 21 Jan 2026 14:06:30 +0900 Subject: [PATCH 09/10] refactor(react-query/IsHydratingProvider): simplify context definition --- packages/react-query/src/IsHydratingProvider.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/react-query/src/IsHydratingProvider.ts b/packages/react-query/src/IsHydratingProvider.ts index 1b46729096..a32e295b1b 100644 --- a/packages/react-query/src/IsHydratingProvider.ts +++ b/packages/react-query/src/IsHydratingProvider.ts @@ -1,15 +1,9 @@ 'use client' import * as React from 'react' -// Mutable set of query hashes that are pending hydration -// Using a ref-like object so we can mutate it without re-rendering -interface HydratingQueriesRef { - current: Set -} - -const defaultRef: HydratingQueriesRef = { current: new Set() } - -const IsHydratingContext = React.createContext(defaultRef) +const IsHydratingContext = React.createContext<{ current: Set }>({ + current: new Set(), +}) export const useIsHydrating = () => React.useContext(IsHydratingContext) export const IsHydratingProvider = IsHydratingContext.Provider From c4ad6205c7d13f9338fabce0886c03dd3def8d29 Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Wed, 21 Jan 2026 14:10:55 +0900 Subject: [PATCH 10/10] refactor(react-query): simplify hydration context from '{ current: Set }' to 'Set' --- packages/react-query/src/HydrationBoundary.tsx | 12 ++++-------- packages/react-query/src/IsHydratingProvider.ts | 4 +--- packages/react-query/src/useBaseQuery.ts | 7 +++---- packages/react-query/src/useQueries.ts | 8 ++++---- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index 8b360806c3..b826d6df2a 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -131,14 +131,10 @@ export const HydrationBoundary = ({ return clearPendingQueries }, [client, hydrationQueue]) - // Provide the mutable ref to children - // Children can check ref.current.has(hash) without needing re-renders - /* eslint-disable react-hooks/refs */ - const contextValue = React.useMemo( - () => ({ current: hydratingQueriesRef.current }), - [], - ) - /* eslint-enable react-hooks/refs */ + // Provide the mutable Set to children + // Children can check set.has(hash) without needing re-renders + // eslint-disable-next-line react-hooks/refs + const contextValue = React.useMemo(() => hydratingQueriesRef.current, []) return ( {children} diff --git a/packages/react-query/src/IsHydratingProvider.ts b/packages/react-query/src/IsHydratingProvider.ts index a32e295b1b..910cf53406 100644 --- a/packages/react-query/src/IsHydratingProvider.ts +++ b/packages/react-query/src/IsHydratingProvider.ts @@ -1,9 +1,7 @@ 'use client' import * as React from 'react' -const IsHydratingContext = React.createContext<{ current: Set }>({ - current: new Set(), -}) +const IsHydratingContext = React.createContext>(new Set()) export const useIsHydrating = () => React.useContext(IsHydratingContext) export const IsHydratingProvider = IsHydratingContext.Provider diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index c5a1652446..20b6885990 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -51,14 +51,13 @@ export function useBaseQuery< } const isRestoring = useIsRestoring() - const hydratingQueriesRef = useIsHydrating() + const hydratingQueries = useIsHydrating() const errorResetBoundary = useQueryErrorResetBoundary() const client = useQueryClient(queryClient) const defaultedOptions = client.defaultQueryOptions(options) - // Check if this query is pending hydration (using mutable ref) - // eslint-disable-next-line react-hooks/refs - if (hydratingQueriesRef.current.has(defaultedOptions.queryHash)) { + // Check if this query is pending hydration + if (hydratingQueries.has(defaultedOptions.queryHash)) { defaultedOptions._isHydrating = true } diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 26c35d39de..54526514e7 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -224,7 +224,7 @@ export function useQueries< ): TCombinedResult { const client = useQueryClient(queryClient) const isRestoring = useIsRestoring() - const hydratingQueriesRef = useIsHydrating() + const hydratingQueries = useIsHydrating() const errorResetBoundary = useQueryErrorResetBoundary() const defaultedQueries = React.useMemo( @@ -239,14 +239,14 @@ export function useQueries< ? 'isRestoring' : 'optimistic' - // Check if this query is pending hydration (using mutable ref) - if (hydratingQueriesRef.current.has(defaultedOptions.queryHash)) { + // Check if this query is pending hydration + if (hydratingQueries.has(defaultedOptions.queryHash)) { defaultedOptions._isHydrating = true } return defaultedOptions }), - // Note: hydratingQueriesRef is a stable ref object, so we don't include it in deps + // Note: hydratingQueries is a stable Set object, so we don't include it in deps // eslint-disable-next-line react-hooks/exhaustive-deps [queries, client, isRestoring], )