Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/suspense-placeholder-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-query': minor
---

Add placeholderData support to the React suspense query hooks.
5 changes: 5 additions & 0 deletions .changeset/tasty-ears-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-query': patch
---

react(suspense): switch from throwing promises to use()
4 changes: 3 additions & 1 deletion docs/framework/react/guides/suspense.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ This works nicely in TypeScript, because `data` is guaranteed to be defined (as

On the flip side, you therefore can't conditionally enable / disable the Query. This generally shouldn't be necessary for dependent Queries because with suspense, all your Queries inside one component are fetched in serial.

`placeholderData` also doesn't exist for this Query. To prevent the UI from being replaced by a fallback during an update, wrap your updates that change the QueryKey into [startTransition](https://react.dev/reference/react/Suspense#preventing-unwanted-fallbacks).
`placeholderData` is supported. When it returns defined data, the Query renders that data in `success` state and fetches the full data in the background without showing the Suspense fallback. This is useful when a detail Query can show preview data from a cached list Query.

You can also use `placeholderData: keepPreviousData` to keep data from the previous QueryKey. If keeping the currently rendered screen is your only goal, prefer wrapping updates that change the QueryKey in [startTransition](https://react.dev/reference/react/Suspense#preventing-unwanted-fallbacks) or using `useDeferredValue`. These React APIs keep the complete current screen in place and do not require placeholder data.

### throwOnError default

Expand Down
4 changes: 2 additions & 2 deletions docs/framework/react/reference/useSuspenseInfiniteQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ The same as for [useInfiniteQuery](./useInfiniteQuery.md), except for:

- `throwOnError`
- `enabled`
- `placeholderData`

**Returns**

Same object as [useInfiniteQuery](./useInfiniteQuery.md), except that:

- `data` is guaranteed to be defined
- `isPlaceholderData` is missing
- `status` is either `success` or `error`
- the derived flags are set accordingly.

When `placeholderData` is defined, the result has `isPlaceholderData: true` until the Query returns its data.

**Caveat**

[Cancellation](../guides/query-cancellation.md) does not work.
6 changes: 4 additions & 2 deletions docs/framework/react/reference/useSuspenseQueries.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ The same as for [useQueries](./useQueries.md), except that each `query` can't ha

- `throwOnError`
- `enabled`
- `placeholderData`

`placeholderData` is supported, but its callback does not receive information from previously rendered Queries. This matches `useQueries`, because the number and order of Queries can change between renders.

> The [`select` typing caveat](./useQueries.md#typescript-typing-the-select-option) for `useQueries` applies here as well: annotate the `select` parameter or use the [`queryOptions`](./queryOptions.md) helper to keep type inference.

Expand All @@ -22,10 +23,11 @@ The same as for [useQueries](./useQueries.md), except that each `query` can't ha
Same structure as [useQueries](./useQueries.md), except that for each `query`:

- `data` is guaranteed to be defined
- `isPlaceholderData` is missing
- `status` is either `success` or `error`
- the derived flags are set accordingly.

Each result includes `isPlaceholderData`.

**Caveats**

Keep in mind that the component will only re-mount after **all queries** have finished loading. Hence, if a query has gone stale in the time it took for all the queries to complete, it will be fetched again at re-mount. To avoid this, make sure to set a high enough `staleTime`.
Expand Down
2 changes: 0 additions & 2 deletions docs/framework/react/reference/useSuspenseQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,12 @@ The same as for [useQuery](./useQuery.md), except for:

- `throwOnError`
- `enabled`
- `placeholderData`

**Returns**

Same object as [useQuery](./useQuery.md), except that:

- `data` is guaranteed to be defined
- `isPlaceholderData` is missing
- `status` is either `success` or `error`
- the derived flags are set accordingly.

Expand Down
42 changes: 26 additions & 16 deletions packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
useSuspenseQueries,
useSuspenseQuery,
} from '..'
import { renderWithClient } from './utils'
import { renderWithClient, renderWithSuspense } from './utils'

describe('QueryErrorResetBoundary', () => {
let queryCache: QueryCache
Expand Down Expand Up @@ -84,7 +84,9 @@ describe('QueryErrorResetBoundary', () => {

succeed = true

fireEvent.click(rendered.getByText('retry'))
await act(async () => {
fireEvent.click(rendered.getByText('retry'))
})
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('data')).toBeInTheDocument()

Expand Down Expand Up @@ -151,7 +153,9 @@ describe('QueryErrorResetBoundary', () => {

succeed = true

fireEvent.click(rendered.getByText('retry'))
await act(async () => {
fireEvent.click(rendered.getByText('retry'))
})
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('status: error')).toBeInTheDocument()

Expand Down Expand Up @@ -219,7 +223,9 @@ describe('QueryErrorResetBoundary', () => {

succeed = true

fireEvent.click(rendered.getByText('retry'))
await act(async () => {
fireEvent.click(rendered.getByText('retry'))
})
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('data')).toBeInTheDocument()

Expand Down Expand Up @@ -593,7 +599,7 @@ describe('QueryErrorResetBoundary', () => {
return <div>{data}</div>
}

const rendered = renderWithClient(
const rendered = await renderWithSuspense(
queryClient,
<QueryErrorResetBoundary>
{({ reset }) => (
Expand All @@ -612,9 +618,7 @@ describe('QueryErrorResetBoundary', () => {
</div>
)}
>
<React.Suspense fallback={<div>loading</div>}>
<Page />
</React.Suspense>
<Page />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>,
Expand All @@ -625,13 +629,17 @@ describe('QueryErrorResetBoundary', () => {
expect(rendered.getByText('error boundary')).toBeInTheDocument()
expect(rendered.getByText('retry')).toBeInTheDocument()

fireEvent.click(rendered.getByText('retry'))
await act(async () => {
fireEvent.click(rendered.getByText('retry'))
})
expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
expect(rendered.getByText('error boundary')).toBeInTheDocument()
expect(rendered.getByText('retry')).toBeInTheDocument()

fireEvent.click(rendered.getByText('retry'))
await act(async () => {
fireEvent.click(rendered.getByText('retry'))
})
expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
expect(rendered.getByText('data')).toBeInTheDocument()
Expand Down Expand Up @@ -722,7 +730,9 @@ describe('QueryErrorResetBoundary', () => {

succeed = true

fireEvent.click(rendered.getByText('retry'))
await act(async () => {
fireEvent.click(rendered.getByText('retry'))
})
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('data')).toBeInTheDocument()

Expand Down Expand Up @@ -902,7 +912,7 @@ describe('QueryErrorResetBoundary', () => {
return <div>{data}</div>
}

const rendered = renderWithClient(
const rendered = await renderWithSuspense(
queryClient,
<QueryErrorResetBoundary>
{({ reset }) => (
Expand All @@ -921,9 +931,7 @@ describe('QueryErrorResetBoundary', () => {
</div>
)}
>
<React.Suspense fallback="loading">
<Page />
</React.Suspense>
<Page />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>,
Expand All @@ -936,7 +944,9 @@ describe('QueryErrorResetBoundary', () => {

succeed = true

fireEvent.click(rendered.getByText('retry'))
await act(async () => {
fireEvent.click(rendered.getByText('retry'))
})
expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
expect(rendered.getByText('data')).toBeInTheDocument()
Expand Down
126 changes: 110 additions & 16 deletions packages/react-query/src/__tests__/suspense.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import { act } from '@testing-library/react'
import { Suspense } from 'react'
import { QueryObserver } from '@tanstack/query-core'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { QueryClient, QueryClientProvider, useSuspenseQuery } from '..'
import { fallbackUse, getSuspensePromise } from '../suspense'
import { renderWithSuspense } from './utils'
import type { StaleTime } from '@tanstack/query-core'
import type { QueryKey } from '..'

function renderWithSuspense(client: QueryClient, ui: React.ReactNode) {
return render(
<QueryClientProvider client={client}>
<Suspense fallback="loading">{ui}</Suspense>
</QueryClientProvider>,
)
}

function createTestQuery(options: {
fetchCount: { count: number }
queryKey: QueryKey
Expand Down Expand Up @@ -54,14 +49,113 @@ describe('Suspense Timer Tests', () => {
vi.useRealTimers()
})

it('should reuse the suspense promise while a query is pending', async () => {
const key = queryKey()
const options = queryClient.defaultQueryOptions({
queryKey: key,
queryFn: () => sleep(10).then(() => 'data'),
suspense: true,
})
const observer = new QueryObserver(queryClient, options)
const errorResetBoundary = {
clearReset: vi.fn(),
isReset: () => false,
reset: vi.fn(),
}

const firstPromise = getSuspensePromise(
options,
observer,
errorResetBoundary,
observer.getCurrentQuery(),
)
const secondPromise = getSuspensePromise(
options,
observer,
errorResetBoundary,
observer.getCurrentQuery(),
)

expect(secondPromise).toBe(firstPromise)

await vi.advanceTimersByTimeAsync(10)
await firstPromise
})

it('should keep the suspense promise stable after it settles', async () => {
const key = queryKey()
const options = queryClient.defaultQueryOptions({
queryKey: key,
queryFn: () => sleep(10).then(() => 'data'),
suspense: true,
})
const observer = new QueryObserver(queryClient, options)
const errorResetBoundary = {
clearReset: vi.fn(),
isReset: () => false,
reset: vi.fn(),
}

const firstPromise = getSuspensePromise(
options,
observer,
errorResetBoundary,
observer.getCurrentQuery(),
)

await vi.advanceTimersByTimeAsync(10)
await firstPromise

const secondPromise = getSuspensePromise(
options,
observer,
errorResetBoundary,
observer.getCurrentQuery(),
false,
)

expect(secondPromise).toBe(firstPromise)
})

it('should support pending, fulfilled, and rejected promise states in the React 18 fallback', async () => {
let resolvePending!: (value: string) => void
const pending = new Promise<string>((resolve) => {
resolvePending = resolve
})

let thrown: unknown
try {
fallbackUse(pending)
} catch (error) {
thrown = error
}
expect(thrown).toBe(pending)

resolvePending('data')
await pending
expect(fallbackUse(pending)).toBe('data')

const error = new Error('error')
const rejected = Promise.reject(error)
try {
fallbackUse(rejected)
} catch (rejectedPromise) {
thrown = rejectedPromise
}
expect(thrown).toBe(rejected)

await rejected.catch(() => undefined)
expect(() => fallbackUse(rejected)).toThrow(error)
})

it('should enforce minimum staleTime of 1000ms when using suspense with number', async () => {
const TestComponent = createTestQuery({
fetchCount,
queryKey: queryKey(),
staleTime: 10,
})

const rendered = renderWithSuspense(queryClient, <TestComponent />)
const rendered = await renderWithSuspense(queryClient, <TestComponent />)

expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
Expand All @@ -87,7 +181,7 @@ describe('Suspense Timer Tests', () => {
staleTime: () => 10,
})

const rendered = renderWithSuspense(queryClient, <TestComponent />)
const rendered = await renderWithSuspense(queryClient, <TestComponent />)

expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
Expand All @@ -113,7 +207,7 @@ describe('Suspense Timer Tests', () => {
staleTime: 2000,
})

const rendered = renderWithSuspense(queryClient, <TestComponent />)
const rendered = await renderWithSuspense(queryClient, <TestComponent />)

expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
Expand All @@ -139,7 +233,7 @@ describe('Suspense Timer Tests', () => {
staleTime: undefined,
})

const rendered = renderWithSuspense(queryClient, <TestComponent />)
const rendered = await renderWithSuspense(queryClient, <TestComponent />)

expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
Expand All @@ -165,7 +259,7 @@ describe('Suspense Timer Tests', () => {
staleTime: 'static',
})

const rendered = renderWithSuspense(queryClient, <TestComponent />)
const rendered = await renderWithSuspense(queryClient, <TestComponent />)

expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
Expand All @@ -191,7 +285,7 @@ describe('Suspense Timer Tests', () => {
staleTime: () => 'static',
})

const rendered = renderWithSuspense(queryClient, <TestComponent />)
const rendered = await renderWithSuspense(queryClient, <TestComponent />)

expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
Expand All @@ -217,7 +311,7 @@ describe('Suspense Timer Tests', () => {
staleTime: () => 3000,
})

const rendered = renderWithSuspense(queryClient, <TestComponent />)
const rendered = await renderWithSuspense(queryClient, <TestComponent />)

expect(rendered.getByText('loading')).toBeInTheDocument()
await act(() => vi.advanceTimersByTimeAsync(10))
Expand Down
Loading
Loading