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() + }) })