diff --git a/.changeset/lucky-bats-listen.md b/.changeset/lucky-bats-listen.md new file mode 100644 index 00000000000..e730a469ae6 --- /dev/null +++ b/.changeset/lucky-bats-listen.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-query': patch +--- + +fix(react-query/HydrationBoundary): hydrate existing queries during SSR + +`HydrationBoundary` holds back hydration of queries that are already in the cache until after the render phase, so that transitions don't update mounted observers mid-render. There is no effect phase during SSR, so those queries were never hydrated at all. A `useQuery` rendered above the boundary creates a cache entry without fetching, which was enough to make the boundary skip the prefetched data and leave a child `useSuspenseQuery` to fetch again on the server. diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index 901c8e9686c..ce2af5bab6d 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 { environmentManager, hydrate } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' import type { DehydratedState, @@ -50,6 +50,12 @@ 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. + // + // On the server there is no effect phase to hold back until, so holding back + // would mean never hydrating those queries at all. Neither reason for holding + // back applies there either: there are no transitions to abort, and no + // observer can have subscribed yet because subscriptions happen in effects, + // so hydrating in render cannot be an observed side effect. const hydrationQueue: DehydratedState['queries'] | undefined = React.useMemo(() => { if (state) { @@ -83,7 +89,11 @@ export const HydrationBoundary = ({ existingQuery.state.dataUpdatedAt) if (hydrationIsNewer) { - existingQueries.push(dehydratedQuery) + if (environmentManager.isServer()) { + newQueries.push(dehydratedQuery) + } else { + existingQueries.push(dehydratedQuery) + } } } } diff --git a/packages/react-query/src/__tests__/ssr-hydration.test.tsx b/packages/react-query/src/__tests__/ssr-hydration.test.tsx index 587726b3623..993b54dd1f7 100644 --- a/packages/react-query/src/__tests__/ssr-hydration.test.tsx +++ b/packages/react-query/src/__tests__/ssr-hydration.test.tsx @@ -1,15 +1,17 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { hydrateRoot } from 'react-dom/client' -import { act } from 'react' +import { Suspense, act } from 'react' import * as ReactDOMServer from 'react-dom/server' import { queryKey } from '@tanstack/query-test-utils' import { + HydrationBoundary, QueryCache, QueryClient, QueryClientProvider, dehydrate, hydrate, useQuery, + useSuspenseQuery, } from '..' import { setIsServer } from './utils' @@ -270,4 +272,52 @@ describe('Server side rendering with de/rehydration', () => { queryClient.clear() consoleMock.mockRestore() }) + + // https://github.com/TanStack/query/issues/10145 + it('should hydrate a query that a useQuery above the boundary put in the cache', async () => { + const key = queryKey() + const renderQueryFn = vi.fn(() => fetchData('rendered')) + + function Header() { + const result = useQuery({ queryKey: key, queryFn: renderQueryFn }) + return + } + + function Detail() { + const result = useSuspenseQuery({ queryKey: key, queryFn: renderQueryFn }) + return + } + + setIsServer(true) + + const prefetchClient = new QueryClient() + await prefetchClient.prefetchQuery({ + queryKey: key, + queryFn: () => fetchData('prefetched'), + }) + const dehydratedState = dehydrate(prefetchClient) + + // `Header` renders before the boundary and puts a pending query for the + // same key in the cache, even though it never fetches on the server. + const renderClient = new QueryClient() + const markup = ReactDOMServer.renderToString( + +
+ + + + + + , + ) + + prefetchClient.clear() + renderClient.clear() + setIsServer(false) + + expect(markup).toContain( + 'Detail - status:success fetching:false data:prefetched', + ) + expect(renderQueryFn).not.toHaveBeenCalled() + }) })