Skip to content
Draft
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
211 changes: 210 additions & 1 deletion packages/react-query-5/src/QueriesHydration.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { QueryClient, QueryClientProvider, dehydrate, infiniteQueryOptions } from '@tanstack/react-query'
import {
QueryClient,
QueryClientProvider,
defaultShouldDehydrateQuery,
dehydrate,
infiniteQueryOptions,
} from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import type { ComponentProps, ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -543,4 +549,207 @@ describe('<QueriesHydration/>', () => {
// skipSsrOnError is false, so it should render HydrationBoundary, not ClientOnly
expect(result.type).not.toEqual(expect.objectContaining({ name: 'ClientOnly' }))
})

describe('shouldDehydratePromise', () => {
it('should start queries without awaiting and include pending queries in dehydrated state', async () => {
const queryClient = new QueryClient()
let resolveQuery!: (value: unknown) => void
const mockQueryFn = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveQuery = resolve
})
)

const queries = [
{
queryKey: ['pending-query'],
queryFn: mockQueryFn,
},
]

const result = await QueriesHydration({
queries,
queryClient,
shouldDehydratePromise: true,
children: <div>Test Children</div>,
})

expect(mockQueryFn).toHaveBeenCalledTimes(1)
expect(result).toBeDefined()

// Query is still pending - dehydrated state should include the pending query
const dehydratedState = dehydrate(queryClient, {
shouldDehydrateQuery: (query) => query.state.status === 'pending',
})
expect(dehydratedState.queries).toHaveLength(1)
expect(dehydratedState.queries[0].queryKey).toEqual(['pending-query'])

// Cleanup - resolve the promise to avoid dangling promise
resolveQuery({ data: 'resolved' })
})

it('should return HydrationBoundary without waiting for queries to resolve', async () => {
const queryClient = new QueryClient()
const queryDelayMs = 200
const mockQueryFn = vi
.fn()
.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ data: 'delayed' }), queryDelayMs))
)

const queries = [
{
queryKey: ['delayed-query'],
queryFn: mockQueryFn,
},
]

const startTime = Date.now()
const result = await QueriesHydration({
queries,
queryClient,
shouldDehydratePromise: true,
children: <div>Test Children</div>,
})
const elapsed = Date.now() - startTime

// Should return immediately without waiting for the query
expect(elapsed).toBeLessThan(queryDelayMs)
expect(result).toBeDefined()
expect(mockQueryFn).toHaveBeenCalledTimes(1)
})

it('should include successfully resolved queries in dehydrated state when shouldDehydratePromise is true', async () => {
const queryClient = new QueryClient()
const mockData = { data: 'resolved-data' }
const mockQueryFn = vi.fn().mockResolvedValue(mockData)

const queries = [
{
queryKey: ['resolved-query'],
queryFn: mockQueryFn,
},
]

await QueriesHydration({
queries,
queryClient,
shouldDehydratePromise: true,
children: <div>Test Children</div>,
})

// Wait for the microtasks to settle so the query has time to resolve
await Promise.resolve()

const dehydratedState = dehydrate(queryClient)
expect(dehydratedState.queries).toHaveLength(1)
expect(dehydratedState.queries[0].queryKey).toEqual(['resolved-query'])
})

it('should not cancel queries when shouldDehydratePromise is true', async () => {
const queryClient = new QueryClient()
const cancelQueriesSpy = vi.spyOn(queryClient, 'cancelQueries')
let resolveQuery!: (value: unknown) => void
const mockQueryFn = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveQuery = resolve
})
)

const queries = [
{
queryKey: ['pending-query'],
queryFn: mockQueryFn,
},
]

await QueriesHydration({
queries,
queryClient,
shouldDehydratePromise: true,
children: <div>Test Children</div>,
})

expect(cancelQueriesSpy).not.toHaveBeenCalled()

// Cleanup
resolveQuery({ data: 'resolved' })
})

it('should handle infiniteQueryOptions with shouldDehydratePromise', async () => {
const queryClient = new QueryClient()
let resolveQuery!: (value: unknown) => void
const mockInfiniteQueryFn = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveQuery = resolve
})
)

const infiniteOptions = infiniteQueryOptions({
queryKey: ['pending-infinite-query'],
queryFn: mockInfiniteQueryFn,
initialPageParam: 0,
getNextPageParam: () => null,
})

const result = await QueriesHydration({
queries: [infiniteOptions],
queryClient,
shouldDehydratePromise: true,
children: <div>Test Children</div>,
})

expect(mockInfiniteQueryFn).toHaveBeenCalledTimes(1)
expect(result).toBeDefined()

// Cleanup
resolveQuery({ data: 'page-1' })
})

it('should include both resolved and pending queries in dehydrated state', async () => {
const queryClient = new QueryClient()
const resolvedData = { data: 'resolved' }
let resolvePending!: (value: unknown) => void

const queries = [
{
queryKey: ['resolved-query'],
queryFn: vi.fn().mockResolvedValue(resolvedData),
},
{
queryKey: ['pending-query'],
queryFn: vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolvePending = resolve
})
),
},
]

await QueriesHydration({
queries,
queryClient,
shouldDehydratePromise: true,
children: <div>Test Children</div>,
})

// Allow microtasks to settle so the resolved query updates its state
await Promise.resolve()

const dehydratedState = dehydrate(queryClient, {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
})
expect(dehydratedState.queries).toHaveLength(2)
const keys = dehydratedState.queries.map((q) => q.queryKey[0])
expect(keys).toContain('resolved-query')
expect(keys).toContain('pending-query')

// Cleanup
resolvePending({ data: 'done' })
})
})
})
24 changes: 24 additions & 0 deletions packages/react-query-5/src/QueriesHydration.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,29 @@ describe('<QueriesHydration/>', () => {
children: <></>,
})
).toEqualTypeOf<Promise<React.JSX.Element>>()

// Should accept shouldDehydratePromise: true without skipSsrOnError or timeout
void (async () =>
await QueriesHydration({
queries: [options1],
shouldDehydratePromise: true,
children: <></>,
}))()

// @ts-expect-error skipSsrOnError is not allowed when shouldDehydratePromise is true
void QueriesHydration({
queries: [options1],
shouldDehydratePromise: true,
skipSsrOnError: true,
children: <></>,
})

// @ts-expect-error timeout is not allowed when shouldDehydratePromise is true
void QueriesHydration({
queries: [options1],
shouldDehydratePromise: true,
timeout: 5000,
children: <></>,
})
})
})
115 changes: 88 additions & 27 deletions packages/react-query-5/src/QueriesHydration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,29 @@ import {
type QueryOptions,
type UseInfiniteQueryOptions,
type WithRequired,
defaultShouldDehydrateQuery,
dehydrate,
} from '@tanstack/react-query'
import type { ReactNode } from 'react'
import type { JSX, ReactNode } from 'react'
import { ClientOnly } from './components/ClientOnly'

type QueriesHydrationQueries = (
| WithRequired<QueryOptions<any, any, any, any>, 'queryKey'>
| WithRequired<UseInfiniteQueryOptions<any, any, any, any, any>, 'queryKey'>
)[]

type QueriesHydrationBaseProps = {
/**
* An array of query options or infinite query options to be fetched on the server. Each query must include a `queryKey`.
* You can mix regular queries and infinite queries in the same array.
*/
queries: QueriesHydrationQueries
/**
* The QueryClient instance to use for fetching queries.
*/
queryClient?: QueryClient
} & OmitKeyof<HydrationBoundaryProps, 'state'>

/**
* A server component that fetches multiple queries on the server and hydrates them to the client.
*
Expand Down Expand Up @@ -73,42 +91,85 @@ import { ClientOnly } from './components/ClientOnly'
* </Suspense>
* ```
*
* @example
* ```tsx
* // With streaming SSR — pending promises are passed to the client
* <Suspense fallback={<div>Loading...</div>}>
* <QueriesHydration
* queries={[userQueryOptions(userId)]}
* shouldDehydratePromise
* >
* <UserProfile />
* </QueriesHydration>
* </Suspense>
* ```
*
* @see {@link https://suspensive.org/docs/react-query/QueriesHydration Documentation}
*/
export async function QueriesHydration(
props: QueriesHydrationBaseProps & {
/**
* When `true`, pending promises are included in the dehydrated state so the client can
* subscribe to them for streaming SSR. Queries are started immediately without being awaited,
* allowing the component to return while fetches are still in-flight.
*/
shouldDehydratePromise: true
}
): Promise<JSX.Element>
export async function QueriesHydration(
props: QueriesHydrationBaseProps & {
shouldDehydratePromise?: false
/**
* Controls error handling behavior:
* - `true` (default): Skips SSR and falls back to client-side rendering when server fetch fails
* - `false`: Proceeds with SSR without hydration (retry fetching on client component server rendering)
* - `{ fallback: ReactNode }`: Skips SSR with custom fallback UI during client-side rendering
*/
skipSsrOnError?:
| boolean
| {
fallback: ReactNode
}
/**
* The timeout in milliseconds for the query.
* If the query takes longer than the timeout, it will be considered as an error.
* When not set, no timeout is applied.
*/
timeout?: number
}
): Promise<JSX.Element>
export async function QueriesHydration({
queries,
children,
queryClient = new QueryClient(),
skipSsrOnError = true,
timeout,
shouldDehydratePromise = false,
...props
}: {
/**
* An array of query options or infinite query options to be fetched on the server. Each query must include a `queryKey`.
* You can mix regular queries and infinite queries in the same array.
*/
queries: (
| WithRequired<QueryOptions<any, any, any, any>, 'queryKey'>
| WithRequired<UseInfiniteQueryOptions<any, any, any, any, any>, 'queryKey'>
)[]
/**
* Controls error handling behavior:
* - `true` (default): Skips SSR and falls back to client-side rendering when server fetch fails
* - `false`: Proceeds with SSR without hydration (retry fetching on client component server rendering)
* - `{ fallback: ReactNode }`: Skips SSR with custom fallback UI during client-side rendering
*/
skipSsrOnError?:
| boolean
| {
fallback: ReactNode
}
/**
* The timeout in milliseconds for the query.
* If the query takes longer than the timeout, it will be considered as an error.
* When not set, no timeout is applied.
*/
}: QueriesHydrationBaseProps & {
shouldDehydratePromise?: boolean
skipSsrOnError?: boolean | { fallback: ReactNode }
timeout?: number
} & OmitKeyof<HydrationBoundaryProps, 'state'>) {
}): Promise<JSX.Element> {
if (shouldDehydratePromise) {
queries.forEach((query) => {
const promise =
'getNextPageParam' in query ? queryClient.fetchInfiniteQuery(query) : queryClient.fetchQuery(query)
// Suppress unhandled rejection – errors are tracked inside the QueryClient cache
promise.catch(() => {})
})
return (
<HydrationBoundary
{...props}
state={dehydrate(queryClient, {
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
})}
>
{children}
</HydrationBoundary>
)
}

const timeoutController = timeout != null && timeout >= 0 ? createTimeoutController(timeout) : undefined
try {
const queriesPromise = Promise.all(
Expand Down
Loading