diff --git a/.changeset/fix-hydration-double-fetch.md b/.changeset/fix-hydration-double-fetch.md new file mode 100644 index 00000000000..1b582cdb9d7 --- /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 066f6e94e3d..42e5b6165cc 100644 --- a/docs/framework/react/guides/ssr.md +++ b/docs/framework/react/guides/ssr.md @@ -538,7 +538,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 6f2a3346adb..f4c49251b6f 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 954c969d548..0df46db77ce 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() @@ -461,7 +479,13 @@ export class QueryObserver< if (options._optimisticResults) { const mounted = this.hasListeners() - const fetchOnMount = !mounted && shouldFetchOnMount(query, options) + // While a query is hydrating, the first render must match the server, + // which always rendered the dehydrated query as idle (not fetching), + // regardless of staleness. Suppress the optimistic fetch state so we + // don't introduce a hydration mismatch. The actual refetch (if the data + // is stale) is still triggered afterwards in `onSubscribe`. + const fetchOnMount = + !mounted && !options._isHydrating && shouldFetchOnMount(query, options) const fetchOptionally = mounted && shouldFetchOptionally(query, prevQuery, options, prevOptions) diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index b2e80f2a144..0c5aeddd586 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -430,6 +430,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 901c8e9686c..daafe01aa91 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, @@ -10,6 +11,66 @@ import type { QueryClient, } from '@tanstack/query-core' +// Stable empty set shared as the context value once hydration has completed, +// so consumers never see stale pending flags and we avoid allocating per render. +const EMPTY_HYDRATING_QUERIES: ReadonlySet = new Set() + +// Splits the dehydrated queries into those that are new to the cache and those +// that already exist but carry newer data, and derives the pending set from the +// latter. Reads the cache but performs no mutation, so it is a pure function of +// its arguments and safe to call during render. +const classifyQueries = ( + client: QueryClient, + state: DehydratedState | null | undefined, +) => { + if (!state || typeof state !== 'object') { + return { + newQueries: [] as DehydratedState['queries'], + existingQueries: [] as DehydratedState['queries'], + pendingQueries: EMPTY_HYDRATING_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) + } + } + } + + // Reuse a stable empty set when nothing is pending so the context value keeps + // a stable reference and consumers don't recompute needlessly. + const pendingQueries = + existingQueries.length > 0 + ? new Set(existingQueries.map((query) => query.queryHash)) + : EMPTY_HYDRATING_QUERIES + + return { newQueries, existingQueries, pendingQueries } +} + export interface HydrationBoundaryProps { state: DehydratedState | null | undefined options?: OmitKeyof & { @@ -35,13 +96,9 @@ export const HydrationBoundary = ({ optionsRef.current = options }) - // This useMemo is for performance reasons only, everything inside it must - // be safe to run in every render and code here should be read as "in render". - // - // This code needs to happen during the render phase, because after initial - // SSR, hydration needs to happen _before_ children render. Also, if hydrating - // during a transition, we want to hydrate as much as is safe in render so - // we can prerender as much as possible. + // Side effects that must only run when `state` changes, not on every render: + // hydrate brand new queries immediately (in render, before children render, so + // they can prerender) and defer existing queries to the effect below. // // For any queries that already exist in the cache, we want to hold back on // hydrating until _after_ the render phase. The reason for this is that during @@ -50,56 +107,25 @@ 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 - } - - 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 (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 - } - } - return undefined - }, [client, state]) + const hydrationQueue = React.useMemo(() => { + const { newQueries, existingQueries } = classifyQueries(client, state) + + 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) + } + + return existingQueries.length > 0 ? existingQueries : undefined + }, [client, state]) + + // The pending set drives `_isHydrating` for consumers, which is read per + // observer at mount. It must be recomputed on every render (not memoized on + // `state` identity) so that once the deferred hydration lands and the cache + // catches up, the query is no longer classified as newer and the flag clears + // for any observer mounting afterwards. + const { pendingQueries } = classifyQueries(client, state) React.useEffect(() => { if (hydrationQueue) { @@ -107,5 +133,7 @@ export const HydrationBoundary = ({ } }, [client, hydrationQueue]) - return children as React.ReactElement + return ( + {children} + ) } diff --git a/packages/react-query/src/IsHydratingProvider.ts b/packages/react-query/src/IsHydratingProvider.ts new file mode 100644 index 00000000000..3a2be0e7f0a --- /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 25f481a22d0..c5b858b846f 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -8,6 +8,7 @@ import { QueryClient, QueryClientProvider, dehydrate, + useIsHydrating, useQuery, } from '..' import type { hydrate } from '@tanstack/query-core' @@ -540,4 +541,1014 @@ describe('React hydration', () => { queryClient.clear() }) + + it('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() + }) + + it('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() + }) + + it('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() + }) + + it('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() + }) + + it('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() + }) + + it('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( + + + + + , + ) + + // The first render matches the server (not fetching), then the hydration + // effect surfaces the hydrated data. + expect(rendered.getByText('fresh-from-server')).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) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + it('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( + + + + + , + ) + + // The first render matches the server (not fetching), then the hydration + // effect surfaces the hydrated data. + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(10) + + // Should refetch because refetchOnMount function returns 'always' + expect(queryFn).toHaveBeenCalledTimes(1) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) + + it('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() + }) + + it('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() + }) + + it('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() + }) + + it('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() + }) + + it('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() + }) + + it('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() + }) + + it('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() + }) + + it('should clear the pending hydration flag once hydration has completed', async () => { + const queryClient = new QueryClient() + + // Populate the cache so the dehydrated query is treated as "existing". + queryClient.prefetchQuery({ + queryKey: ['self-clear-test'], + queryFn: () => sleep(10).then(() => 'cached'), + }) + await vi.advanceTimersByTimeAsync(10) + + // Advance time so the server prefetch below produces a newer `dataUpdatedAt` + // than the cached entry, ensuring the query is treated as pending hydration. + await vi.advanceTimersByTimeAsync(10) + + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['self-clear-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + // The same dehydrated state object is reused across renders, matching how it + // is typically passed once from the server. + const dehydratedState = dehydrate(serverQueryClient) + + const queryHash = queryClient.defaultQueryOptions({ + queryKey: ['self-clear-test'], + }).queryHash + const isHydratingLog: Array = [] + + function Probe() { + isHydratingLog.push(useIsHydrating().has(queryHash)) + const { data } = useQuery({ + queryKey: ['self-clear-test'], + queryFn: () => sleep(10).then(() => 'new-data'), + staleTime: Infinity, + }) + return

{data}

+ } + + const rendered = render( + + + + + , + ) + + // The first render happens before the deferred hydration effect, so the + // query is flagged as pending. + expect(isHydratingLog[0]).toBe(true) + + // Let the hydration effect run so the cache catches up to the server data. + await vi.advanceTimersByTimeAsync(0) + + // Re-render with the same `state` reference (as SSR would keep it stable). + // The pending flag must have cleared, because the cache now matches the + // hydrated data and the query is no longer classified as newer. + isHydratingLog.length = 0 + rendered.rerender( + + + + + , + ) + + expect(isHydratingLog).toContain(false) + expect(isHydratingLog).not.toContain(true) + + queryClient.clear() + serverQueryClient.clear() + }) + + it('should render as not fetching on the first hydrating render even when the data is stale', async () => { + const queryClient = new QueryClient() + + // Populate the cache with older data (simulating a first page visit). + queryClient.prefetchQuery({ + queryKey: ['mismatch-test'], + queryFn: () => sleep(10).then(() => 'cached'), + }) + await vi.advanceTimersByTimeAsync(10) + + // Advance time so the server prefetch below is newer than the cache. + await vi.advanceTimersByTimeAsync(10) + + const serverQueryClient = new QueryClient() + serverQueryClient.prefetchQuery({ + queryKey: ['mismatch-test'], + queryFn: () => sleep(10).then(() => 'fresh-from-server'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(serverQueryClient) + + const fetchingLog: Array = [] + function Page() { + const { data, isFetching } = useQuery({ + queryKey: ['mismatch-test'], + queryFn: () => sleep(10).then(() => 'new-data'), + // Immediately stale, so without the hydration guard the optimistic + // first render would flip to fetching and mismatch the server. + staleTime: 0, + }) + fetchingLog.push(isFetching) + return

{data}

+ } + + const rendered = render( + + + + + , + ) + + // The first render must match the server, which rendered the dehydrated + // query as idle (not fetching), even though the data is now stale. + expect(fetchingLog[0]).toBe(false) + + // Hydration lands the server data into the cache after the first render. + await vi.advanceTimersByTimeAsync(0) + expect(rendered.getByText('fresh-from-server')).toBeInTheDocument() + + queryClient.clear() + serverQueryClient.clear() + }) }) diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index 36ea8da7af8..cd57f5ff6d9 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 eb6a40d9a23..b1e764c5da5 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 de179837a5f..f8f221fdcc7 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 { @@ -222,6 +223,7 @@ export function useQueries< ): TCombinedResult { const client = useQueryClient(queryClient) const isRestoring = useIsRestoring() + const hydratingQueries = useIsHydrating() const errorResetBoundary = useQueryErrorResetBoundary() const defaultedQueries = React.useMemo( @@ -236,9 +238,14 @@ export function useQueries< ? 'isRestoring' : 'optimistic' + // Check if this query is pending hydration + if (hydratingQueries.has(defaultedOptions.queryHash)) { + defaultedOptions._isHydrating = true + } + return defaultedOptions }), - [queries, client, isRestoring], + [queries, client, isRestoring, hydratingQueries], ) defaultedQueries.forEach((queryOptions) => {