Skip to content

Commit ee7ca0b

Browse files
authored
feat: Allow handling some errors locally while using error boundaries (TanStack#2619)
1 parent 1986127 commit ee7ca0b

File tree

9 files changed

+336
-12
lines changed

9 files changed

+336
-12
lines changed

docs/src/pages/reference/useMutation.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,11 @@ mutate(variables, {
6666
- This function receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds.
6767
- A function like `attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)` applies exponential backoff.
6868
- A function like `attempt => attempt * 1000` applies linear backoff.
69-
- `useErrorBoundary`
70-
- Defaults to the global query config's `useErrorBoundary` value, which is `false`
71-
- Set this to true if you want mutation errors to be thrown in the render phase and propagate to the nearest error boundary
69+
- `useErrorBoundary: undefined | boolean | (error: TError) => boolean`
70+
- Defaults to the global query config's `useErrorBoundary` value, which is `undefined`
71+
- Set this to `true` if you want mutation errors to be thrown in the render phase and propagate to the nearest error boundary
72+
- Set this to `false` to disable the behaviour of throwing errors to the error boundary.
73+
- If set to a function, it will be passed the error and should return a boolean indicating whether to show the error in an error boundary (`true`) or return the error as state (`false`)
7274

7375
**Returns**
7476

docs/src/pages/reference/useQuery.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,11 @@ const result = useQuery({
170170
- Optional
171171
- Defaults to `true`
172172
- If set to `false`, structural sharing between query results will be disabled.
173-
- `useErrorBoundary: boolean`
174-
- Defaults to the global query config's `useErrorBoundary` value, which is false
175-
- Set this to true if you want errors to be thrown in the render phase and propagated to the nearest error boundary
173+
- `useErrorBoundary: undefined | boolean | (error: TError) => boolean`
174+
- Defaults to the global query config's `useErrorBoundary` value, which is `undefined`
175+
- Set this to `true` if you want errors to be thrown in the render phase and propagate to the nearest error boundary
176+
- Set this to `false` to disable `suspense`'s default behaviour of throwing errors to the error boundary.
177+
- If set to a function, it will be passed the error and should return a boolean indicating whether to show the error in an error boundary (`true`) or return the error as state (`false`)
176178
177179
**Returns**
178180

src/core/types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,12 @@ export interface QueryObserverOptions<
163163
onSettled?: (data: TData | undefined, error: TError | null) => void
164164
/**
165165
* Whether errors should be thrown instead of setting the `error` property.
166+
* If set to `true` or `suspense` is `true`, all errors will be thrown to the error boundary.
167+
* If set to `false` and `suspense` is `false`, errors are returned as state.
168+
* If set to a function, it will be passed the error and should return a boolean indicating whether to show the error in an error boundary (`true`) or return the error as state (`false`).
166169
* Defaults to `false`.
167170
*/
168-
useErrorBoundary?: boolean
171+
useErrorBoundary?: boolean | ((error: TError) => boolean)
169172
/**
170173
* This option can be used to transform or select a part of the data returned by the query function.
171174
*/
@@ -527,7 +530,7 @@ export interface MutationObserverOptions<
527530
TVariables = void,
528531
TContext = unknown
529532
> extends MutationOptions<TData, TError, TVariables, TContext> {
530-
useErrorBoundary?: boolean
533+
useErrorBoundary?: boolean | ((error: TError) => boolean)
531534
}
532535

533536
export interface MutateOptions<

src/react/tests/suspense.test.tsx

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,185 @@ describe("useQuery's in Suspense mode", () => {
550550
consoleMock.mockRestore()
551551
})
552552

553+
it('should throw errors to the error boundary by default', async () => {
554+
const key = queryKey()
555+
556+
const consoleMock = mockConsoleError()
557+
558+
function Page() {
559+
useQuery(
560+
key,
561+
async () => {
562+
await sleep(10)
563+
throw new Error('Suspense Error a1x')
564+
},
565+
{
566+
retry: false,
567+
suspense: true,
568+
}
569+
)
570+
return <div>rendered</div>
571+
}
572+
573+
function App() {
574+
return (
575+
<ErrorBoundary
576+
fallbackRender={() => (
577+
<div>
578+
<div>error boundary</div>
579+
</div>
580+
)}
581+
>
582+
<React.Suspense fallback="Loading...">
583+
<Page />
584+
</React.Suspense>
585+
</ErrorBoundary>
586+
)
587+
}
588+
589+
const rendered = renderWithClient(queryClient, <App />)
590+
591+
await waitFor(() => rendered.getByText('Loading...'))
592+
await waitFor(() => rendered.getByText('error boundary'))
593+
594+
consoleMock.mockRestore()
595+
})
596+
597+
it('should not throw errors to the error boundary when useErrorBoundary: false', async () => {
598+
const key = queryKey()
599+
600+
const consoleMock = mockConsoleError()
601+
602+
function Page() {
603+
useQuery(
604+
key,
605+
async () => {
606+
await sleep(10)
607+
throw new Error('Suspense Error a2x')
608+
},
609+
{
610+
retry: false,
611+
suspense: true,
612+
useErrorBoundary: false,
613+
}
614+
)
615+
return <div>rendered</div>
616+
}
617+
618+
function App() {
619+
return (
620+
<ErrorBoundary
621+
fallbackRender={() => (
622+
<div>
623+
<div>error boundary</div>
624+
</div>
625+
)}
626+
>
627+
<React.Suspense fallback="Loading...">
628+
<Page />
629+
</React.Suspense>
630+
</ErrorBoundary>
631+
)
632+
}
633+
634+
const rendered = renderWithClient(queryClient, <App />)
635+
636+
await waitFor(() => rendered.getByText('Loading...'))
637+
await waitFor(() => rendered.getByText('rendered'))
638+
639+
consoleMock.mockRestore()
640+
})
641+
642+
it('should not throw errors to the error boundary when a useErrorBoundary function returns true', async () => {
643+
const key = queryKey()
644+
645+
const consoleMock = mockConsoleError()
646+
647+
function Page() {
648+
useQuery(
649+
key,
650+
async () => {
651+
await sleep(10)
652+
return Promise.reject('Remote Error')
653+
},
654+
{
655+
retry: false,
656+
suspense: true,
657+
useErrorBoundary: err => err !== 'Local Error',
658+
}
659+
)
660+
return <div>rendered</div>
661+
}
662+
663+
function App() {
664+
return (
665+
<ErrorBoundary
666+
fallbackRender={() => (
667+
<div>
668+
<div>error boundary</div>
669+
</div>
670+
)}
671+
>
672+
<React.Suspense fallback="Loading...">
673+
<Page />
674+
</React.Suspense>
675+
</ErrorBoundary>
676+
)
677+
}
678+
679+
const rendered = renderWithClient(queryClient, <App />)
680+
681+
await waitFor(() => rendered.getByText('Loading...'))
682+
await waitFor(() => rendered.getByText('error boundary'))
683+
684+
consoleMock.mockRestore()
685+
})
686+
687+
it('should not throw errors to the error boundary when a useErrorBoundary function returns false', async () => {
688+
const key = queryKey()
689+
690+
const consoleMock = mockConsoleError()
691+
692+
function Page() {
693+
useQuery(
694+
key,
695+
async () => {
696+
await sleep(10)
697+
return Promise.reject('Local Error')
698+
},
699+
{
700+
retry: false,
701+
suspense: true,
702+
useErrorBoundary: err => err !== 'Local Error',
703+
}
704+
)
705+
return <div>rendered</div>
706+
}
707+
708+
function App() {
709+
return (
710+
<ErrorBoundary
711+
fallbackRender={() => (
712+
<div>
713+
<div>error boundary</div>
714+
</div>
715+
)}
716+
>
717+
<React.Suspense fallback="Loading...">
718+
<Page />
719+
</React.Suspense>
720+
</ErrorBoundary>
721+
)
722+
}
723+
724+
const rendered = renderWithClient(queryClient, <App />)
725+
726+
await waitFor(() => rendered.getByText('Loading...'))
727+
await waitFor(() => rendered.getByText('rendered'))
728+
729+
consoleMock.mockRestore()
730+
})
731+
553732
it('should not call the queryFn when not enabled', async () => {
554733
const key = queryKey()
555734

src/react/tests/useQuery.test.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
QueryFunction,
2020
QueryFunctionContext,
2121
} from '../..'
22+
import { ErrorBoundary } from 'react-error-boundary'
2223

2324
describe('useQuery', () => {
2425
const queryCache = new QueryCache()
@@ -2552,6 +2553,114 @@ describe('useQuery', () => {
25522553
consoleMock.mockRestore()
25532554
})
25542555

2556+
it('should throw error if queryFn throws and useErrorBoundary is in use', async () => {
2557+
const key = queryKey()
2558+
const consoleMock = mockConsoleError()
2559+
2560+
function Page() {
2561+
const { status, error } = useQuery<undefined, string>(
2562+
key,
2563+
() => Promise.reject('Error test jaylen'),
2564+
{ retry: false, useErrorBoundary: true }
2565+
)
2566+
2567+
return (
2568+
<div>
2569+
<h1>{status}</h1>
2570+
<h2>{error}</h2>
2571+
</div>
2572+
)
2573+
}
2574+
2575+
const rendered = renderWithClient(
2576+
queryClient,
2577+
<ErrorBoundary fallbackRender={() => <div>error boundary</div>}>
2578+
<Page />
2579+
</ErrorBoundary>
2580+
)
2581+
2582+
await waitFor(() => rendered.getByText('error boundary'))
2583+
2584+
consoleMock.mockRestore()
2585+
})
2586+
2587+
it('should set status to error instead of throwing when error should not be thrown', async () => {
2588+
const key = queryKey()
2589+
const consoleMock = mockConsoleError()
2590+
2591+
function Page() {
2592+
const { status, error } = useQuery<undefined, string>(
2593+
key,
2594+
() => Promise.reject('Local Error'),
2595+
{
2596+
retry: false,
2597+
useErrorBoundary: err => err !== 'Local Error',
2598+
}
2599+
)
2600+
2601+
return (
2602+
<div>
2603+
<h1>{status}</h1>
2604+
<h2>{error}</h2>
2605+
</div>
2606+
)
2607+
}
2608+
2609+
const rendered = renderWithClient(
2610+
queryClient,
2611+
<ErrorBoundary fallbackRender={() => <div>error boundary</div>}>
2612+
<Page />
2613+
</ErrorBoundary>
2614+
)
2615+
2616+
await waitFor(() => rendered.getByText('error'))
2617+
await waitFor(() => rendered.getByText('Local Error'))
2618+
2619+
consoleMock.mockRestore()
2620+
})
2621+
2622+
it('should throw error instead of setting status when error should be thrown', async () => {
2623+
const key = queryKey()
2624+
const consoleMock = mockConsoleError()
2625+
2626+
function Page() {
2627+
const { status, error } = useQuery<undefined, string>(
2628+
key,
2629+
() => Promise.reject('Remote Error'),
2630+
{
2631+
retry: false,
2632+
useErrorBoundary: err => err !== 'Local Error',
2633+
}
2634+
)
2635+
2636+
return (
2637+
<div>
2638+
<h1>{status}</h1>
2639+
<h2>{error}</h2>
2640+
</div>
2641+
)
2642+
}
2643+
2644+
const rendered = renderWithClient(
2645+
queryClient,
2646+
<ErrorBoundary
2647+
fallbackRender={({ error }) => (
2648+
<div>
2649+
<div>error boundary</div>
2650+
<div>{error}</div>
2651+
</div>
2652+
)}
2653+
>
2654+
<Page />
2655+
</ErrorBoundary>
2656+
)
2657+
2658+
await waitFor(() => rendered.getByText('error boundary'))
2659+
await waitFor(() => rendered.getByText('Remote Error'))
2660+
2661+
consoleMock.mockRestore()
2662+
})
2663+
25552664
it('should always fetch if refetchOnMount is set to always', async () => {
25562665
const key = queryKey()
25572666
const states: UseQueryResult<string>[] = []

src/react/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export interface UseMutationOptions<
9696
) => Promise<unknown> | void
9797
retry?: RetryValue<TError>
9898
retryDelay?: RetryDelayValue<TError>
99-
useErrorBoundary?: boolean
99+
useErrorBoundary?: boolean | ((error: TError) => boolean)
100100
}
101101

102102
export type UseMutateFunction<

src/react/useBaseQuery.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { QueryObserver } from '../core/queryObserver'
66
import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
77
import { useQueryClient } from './QueryClientProvider'
88
import { UseBaseQueryOptions } from './types'
9+
import { shouldThrowError } from './utils'
910

1011
export function useBaseQuery<
1112
TQueryFnData,
@@ -123,9 +124,13 @@ export function useBaseQuery<
123124

124125
// Handle error boundary
125126
if (
126-
(defaultedOptions.suspense || defaultedOptions.useErrorBoundary) &&
127127
result.isError &&
128-
!result.isFetching
128+
!result.isFetching &&
129+
shouldThrowError(
130+
defaultedOptions.suspense,
131+
defaultedOptions.useErrorBoundary,
132+
result.error
133+
)
129134
) {
130135
throw result.error
131136
}

0 commit comments

Comments
 (0)