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 b399f95c6c..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. 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 6f2a3346ad..f4c49251b6 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` 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** - `state: DehydratedState` diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 463407a073..59a20ff432 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -96,7 +96,25 @@ 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 via options._isHydrating + // 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' && + !isStale(this.#currentQuery, this.options) + + if ( + shouldFetchOnMount(this.#currentQuery, this.options) && + !shouldSkipFetchForHydration + ) { this.#executeFetch() } else { this.updateResult() 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 901c8e9686..b826d6df2a 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import { hydrate } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' +import { IsHydratingProvider } from './IsHydratingProvider' import type { DehydratedState, HydrateOptions, @@ -50,62 +51,92 @@ 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 newQueries: DehydratedState['queries'] = [] + const existingQueries: DehydratedState['queries'] = [] + for (const dehydratedQuery of queries) { + const existingQuery = queryCache.get(dehydratedQuery.queryHash) - 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) - } + 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) { - 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) { hydrate(client, { queries: hydrationQueue }, optionsRef.current) } + + const clearPendingQueries = () => { + if (hydrationQueue) { + for (const dehydratedQuery of hydrationQueue) { + hydratingQueriesRef.current.delete(dehydratedQuery.queryHash) + } + } + } + + // 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 + // 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 new file mode 100644 index 0000000000..910cf53406 --- /dev/null +++ b/packages/react-query/src/IsHydratingProvider.ts @@ -0,0 +1,7 @@ +'use client' +import * as React from 'react' + +const IsHydratingContext = React.createContext>(new Set()) + +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 67b409c6ad..adc0b42525 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -540,4 +540,886 @@ describe('React hydration', () => { queryClient.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, + // Use staleTime to prevent refetch during hydration + // When data is not stale, hydration should skip refetch + staleTime: Infinity, + }) + 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 because data is not stale (staleTime: Infinity) + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Should NOT refetch because data is not stale + 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, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, + 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 data is not stale + 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, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, + 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 data is not stale + 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 + // refetchOnMount: 'always' should trigger refetch even during hydration + const rendered = render( + + + + + , + ) + + // Initially shows cached data (from first prefetch) + expect(rendered.getByText('new-data')).toBeInTheDocument() + + // refetchOnMount: 'always' should trigger refetch even during hydration + // Wait for the refetch to complete + await vi.advanceTimersByTimeAsync(10) + + // 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() + + 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 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() + .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, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, + }) + const query2 = useQuery({ + queryKey: ['multi-2'], + queryFn: queryFn2, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, + }) + 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 - 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 adding to hydrating set) + 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 - 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 + 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() + }) + + 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, + }) + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + // 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) + + queryFn.mockClear() + + function Page() { + const { data } = useQuery({ + queryKey: ['unmount-cleanup-test'], + queryFn, + // Data is not stale, so hydration should skip refetch + staleTime: Infinity, + }) + return ( +
+

{data ?? 'loading'}

+
+ ) + } + + const rendered = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // Should not refetch during hydration because data is not stale + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + // Unmount + rendered.unmount() + + // 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 dehydratedState2 = dehydrate(serverQueryClient2) + + // Remounting with new hydration state + const rendered2 = render( + + + + + , + ) + + await vi.advanceTimersByTimeAsync(0) + + // 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..20b6885990 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,16 @@ export function useBaseQuery< } const isRestoring = useIsRestoring() + const hydratingQueries = useIsHydrating() const errorResetBoundary = useQueryErrorResetBoundary() const client = useQueryClient(queryClient) const defaultedOptions = client.defaultQueryOptions(options) + + // Check if this query is pending hydration + if (hydratingQueries.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..54526514e7 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 hydratingQueries = useIsHydrating() const errorResetBoundary = useQueryErrorResetBoundary() const defaultedQueries = React.useMemo( @@ -237,8 +239,15 @@ export function useQueries< ? 'isRestoring' : 'optimistic' + // Check if this query is pending hydration + if (hydratingQueries.has(defaultedOptions.queryHash)) { + defaultedOptions._isHydrating = true + } + return defaultedOptions }), + // 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], )