Skip to content

Commit 45ef8f8

Browse files
authored
feat: suspense error handling (#5979)
only hard errors are thrown to ErrorBoundaries
1 parent 6ac08b4 commit 45ef8f8

File tree

5 files changed

+109
-3
lines changed

5 files changed

+109
-3
lines changed

docs/react/guides/suspense.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,32 @@ const { data } = useSuspenseQuery({ queryKey, queryFn })
2323

2424
This works nicely in TypeScript, because `data` is guaranteed to be defined (as errors and loading states are handled by Suspense- and ErrorBoundaries).
2525

26-
On the flip side, you therefore can't conditionally enable / disable the Query. `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).
26+
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.
27+
28+
`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).
29+
30+
### throwOnError default
31+
32+
Not all errors are thrown to the nearest Error Boundary per default - we're only throwing errors if there is no other data to show. That means if a Query ever successfully got data in the cache, the component will render, even if data is `stale`. Thus, the default for `throwOnError` is:
33+
34+
```
35+
throwOnError: (error, query) => typeof query.state.data === 'undefined'
36+
```
37+
38+
Since you can't change `throwOnError` (because it would allow for `data` to become potentially `undefined`), you have to throw errors manually if you want all errors to be handled by Error Boundaries:
39+
40+
```tsx
41+
import { useSuspenseQuery } from '@tanstack/react-query'
42+
43+
const { data, error } = useSuspenseQuery({ queryKey, queryFn })
44+
45+
if (error) {
46+
throw error
47+
}
48+
49+
// continue rendering data
50+
51+
```
2752

2853
## Resetting Error Boundaries
2954

packages/react-query/src/__tests__/suspense.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,11 @@ describe('useSuspenseQuery', () => {
624624
},
625625
retry: false,
626626
})
627+
628+
if (result.error) {
629+
throw result.error
630+
}
631+
627632
return (
628633
<div>
629634
<span>rendered</span> <span>{result.data}</span>
@@ -712,6 +717,68 @@ describe('useSuspenseQuery', () => {
712717
expect(renders).toBe(2)
713718
expect(rendered.queryByText('rendered')).not.toBeNull()
714719
})
720+
721+
it('should not throw background errors to the error boundary', async () => {
722+
const consoleMock = vi
723+
.spyOn(console, 'error')
724+
.mockImplementation(() => undefined)
725+
let succeed = true
726+
const key = queryKey()
727+
728+
function Page() {
729+
const result = useSuspenseQuery({
730+
queryKey: key,
731+
queryFn: async () => {
732+
await sleep(10)
733+
if (!succeed) {
734+
throw new Error('Suspense Error Bingo')
735+
} else {
736+
return 'data'
737+
}
738+
},
739+
retry: false,
740+
})
741+
742+
return (
743+
<div>
744+
<span>
745+
rendered {result.data} {result.status}
746+
</span>
747+
<button onClick={() => result.refetch()}>refetch</button>
748+
</div>
749+
)
750+
}
751+
752+
function App() {
753+
const { reset } = useQueryErrorResetBoundary()
754+
return (
755+
<ErrorBoundary
756+
onReset={reset}
757+
fallbackRender={() => <div>error boundary</div>}
758+
>
759+
<React.Suspense fallback="Loading...">
760+
<Page />
761+
</React.Suspense>
762+
</ErrorBoundary>
763+
)
764+
}
765+
766+
const rendered = renderWithClient(queryClient, <App />)
767+
768+
// render suspense fallback (Loading...)
769+
await waitFor(() => rendered.getByText('Loading...'))
770+
// resolve promise -> render Page (rendered)
771+
await waitFor(() => rendered.getByText('rendered data success'))
772+
773+
// change promise result to error
774+
succeed = false
775+
// refetch
776+
fireEvent.click(rendered.getByRole('button', { name: 'refetch' }))
777+
// we are now in error state but still have data to show
778+
await waitFor(() => rendered.getByText('rendered data error'))
779+
780+
consoleMock.mockRestore()
781+
})
715782
})
716783

717784
describe('useSuspenseQueries', () => {

packages/react-query/src/suspense.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
1+
import type { DefaultError } from '@tanstack/query-core/src'
12
import type {
23
DefaultedQueryObserverOptions,
4+
Query,
35
QueryKey,
46
QueryObserver,
57
QueryObserverResult,
68
} from '@tanstack/query-core'
79
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'
810

11+
export const defaultThrowOnError = <
12+
TQueryFnData = unknown,
13+
TError = DefaultError,
14+
TData = TQueryFnData,
15+
TQueryKey extends QueryKey = QueryKey,
16+
>(
17+
_error: TError,
18+
query: Query<TQueryFnData, TError, TData, TQueryKey>,
19+
) => typeof query.state.data === 'undefined'
20+
921
export const ensureStaleTime = (
1022
defaultedOptions: DefaultedQueryObserverOptions<any, any, any, any, any>,
1123
) => {

packages/react-query/src/useSuspenseQueries.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client'
22
import { useQueries } from './useQueries'
3+
import { defaultThrowOnError } from './suspense'
34
import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types'
45
import type {
56
DefaultError,
@@ -154,7 +155,7 @@ export function useSuspenseQueries<
154155
queries: options.queries.map((query) => ({
155156
...query,
156157
suspense: true,
157-
throwOnError: true,
158+
throwOnError: defaultThrowOnError,
158159
enabled: true,
159160
})),
160161
} as any,

packages/react-query/src/useSuspenseQuery.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22
import { QueryObserver } from '@tanstack/query-core'
33
import { useBaseQuery } from './useBaseQuery'
4+
import { defaultThrowOnError } from './suspense'
45
import type { UseSuspenseQueryOptions, UseSuspenseQueryResult } from './types'
56
import type { DefaultError, QueryClient, QueryKey } from '@tanstack/query-core'
67

@@ -18,7 +19,7 @@ export function useSuspenseQuery<
1819
...options,
1920
enabled: true,
2021
suspense: true,
21-
throwOnError: true,
22+
throwOnError: defaultThrowOnError,
2223
},
2324
QueryObserver,
2425
queryClient,

0 commit comments

Comments
 (0)