Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lucky-bats-listen.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 12 additions & 2 deletions packages/react-query/src/HydrationBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
Expand Down
52 changes: 51 additions & 1 deletion packages/react-query/src/__tests__/ssr-hydration.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 <PrintStateComponent componentName="Header" result={result} />
}

function Detail() {
const result = useSuspenseQuery({ queryKey: key, queryFn: renderQueryFn })
return <PrintStateComponent componentName="Detail" result={result} />
}

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(
<QueryClientProvider client={renderClient}>
<Header />
<HydrationBoundary state={dehydratedState}>
<Suspense fallback="loading">
<Detail />
</Suspense>
</HydrationBoundary>
</QueryClientProvider>,
)

prefetchClient.clear()
renderClient.clear()
setIsServer(false)

expect(markup).toContain(
'Detail - status:success fetching:false data:prefetched',
)
expect(renderQueryFn).not.toHaveBeenCalled()
})
})