Skip to content

Commit 93db5b6

Browse files
authored
feat: add reset error boundary component (#980)
1 parent 00b9e96 commit 93db5b6

File tree

5 files changed

+248
-18
lines changed

5 files changed

+248
-18
lines changed

docs/src/pages/docs/guides/suspense.md

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,57 @@ In addition to queries behaving differently in suspense mode, mutations also beh
4343

4444
## Resetting Error Boundaries
4545

46-
Whether you are using **suspense** or **useErrorBoundaries** in your queries, you will need to know how to use the `queryCache.resetErrorBoundaries` function to let queries know that you want them to try again when you render them again.
46+
Whether you are using **suspense** or **useErrorBoundaries** in your queries, you will need a way to let queries know that you want to try again when re-rendering after some error occured.
4747

48-
How you trigger this function is up to you, but the most common use case is to do it in something like `react-error-boundary`'s `onReset` callback:
48+
Query errors can be reset with the `ReactQueryErrorResetBoundary` component or with the `useErrorResetBoundary` hook.
49+
50+
When using the component it will reset any query errors within the boundaries of the component:
51+
52+
```js
53+
import { ReactQueryErrorResetBoundary } from 'react-query'
54+
import { ErrorBoundary } from 'react-error-boundary'
55+
56+
const App: React.FC = () => (
57+
<ReactQueryErrorResetBoundary>
58+
{({ reset }) => (
59+
<ErrorBoundary
60+
onReset={reset}
61+
fallbackRender={({ resetErrorBoundary }) => (
62+
<div>
63+
There was an error!
64+
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
65+
</div>
66+
)}
67+
>
68+
<Page />
69+
</ErrorBoundary>
70+
)}
71+
</ReactQueryErrorResetBoundary>
72+
)
73+
```
74+
75+
When using the hook it will reset any query errors within the closest `ReactQueryErrorResetBoundary`. If there is no boundary defined it will reset them globally:
4976

5077
```js
51-
import { queryCache } from "react-query";
52-
import { ErrorBoundary } from "react-error-boundary";
53-
54-
<ErrorBoundary
55-
onReset={() => queryCache.resetErrorBoundaries()}
56-
fallbackRender={({ error, resetErrorBoundary }) => (
57-
<div>
58-
There was an error!
59-
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
60-
</div>
61-
)}
62-
>
78+
import { useErrorResetBoundary } from 'react-query'
79+
import { ErrorBoundary } from 'react-error-boundary'
80+
81+
const App: React.FC = () => {
82+
const { reset } = useErrorResetBoundary()
83+
return (
84+
<ErrorBoundary
85+
onReset={reset}
86+
fallbackRender={({ resetErrorBoundary }) => (
87+
<div>
88+
There was an error!
89+
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
90+
</div>
91+
)}
92+
>
93+
<Page />
94+
</ErrorBoundary>
95+
)
96+
}
6397
```
6498

6599
## Fetch-on-render vs Render-as-you-fetch
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React from 'react'
2+
3+
// CONTEXT
4+
5+
interface ReactQueryErrorResetBoundaryValue {
6+
clearReset: () => void
7+
isReset: () => boolean
8+
reset: () => void
9+
}
10+
11+
function createValue(): ReactQueryErrorResetBoundaryValue {
12+
let isReset = true
13+
return {
14+
clearReset: () => {
15+
isReset = false
16+
},
17+
reset: () => {
18+
isReset = true
19+
},
20+
isReset: () => {
21+
return isReset
22+
},
23+
}
24+
}
25+
26+
const context = React.createContext(createValue())
27+
28+
// HOOK
29+
30+
export const useErrorResetBoundary = () => React.useContext(context)
31+
32+
// COMPONENT
33+
34+
export interface ReactQueryErrorResetBoundaryProps {
35+
children:
36+
| ((value: ReactQueryErrorResetBoundaryValue) => React.ReactNode)
37+
| React.ReactNode
38+
}
39+
40+
export const ReactQueryErrorResetBoundary: React.FC<ReactQueryErrorResetBoundaryProps> = ({
41+
children,
42+
}) => {
43+
const value = React.useMemo(() => createValue(), [])
44+
return (
45+
<context.Provider value={value}>
46+
{typeof children === 'function'
47+
? (children as Function)(value)
48+
: children}
49+
</context.Provider>
50+
)
51+
}

src/react/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ export {
33
useQueryCache,
44
} from './ReactQueryCacheProvider'
55
export { ReactQueryConfigProvider } from './ReactQueryConfigProvider'
6+
export {
7+
ReactQueryErrorResetBoundary,
8+
useErrorResetBoundary,
9+
} from './ReactQueryErrorResetBoundary'
610
export { useIsFetching } from './useIsFetching'
711
export { useMutation } from './useMutation'
812
export { useQuery } from './useQuery'
@@ -15,3 +19,4 @@ export type { UseInfiniteQueryObjectConfig } from './useInfiniteQuery'
1519
export type { UsePaginatedQueryObjectConfig } from './usePaginatedQuery'
1620
export type { ReactQueryCacheProviderProps } from './ReactQueryCacheProvider'
1721
export type { ReactQueryConfigProviderProps } from './ReactQueryConfigProvider'
22+
export type { ReactQueryErrorResetBoundaryProps } from './ReactQueryErrorResetBoundary'

src/react/tests/suspense.test.tsx

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import * as React from 'react'
55
import { sleep, queryKey, mockConsoleError } from './utils'
66
import { useQuery } from '..'
77
import { queryCache } from '../../core'
8+
import {
9+
ReactQueryErrorResetBoundary,
10+
useErrorResetBoundary,
11+
} from '../ReactQueryErrorResetBoundary'
812

913
describe("useQuery's in Suspense mode", () => {
1014
it('should not call the queryFn twice when used in Suspense mode', async () => {
@@ -192,6 +196,135 @@ describe("useQuery's in Suspense mode", () => {
192196
consoleMock.mockRestore()
193197
})
194198

199+
it('should retry fetch if the reset error boundary has been reset', async () => {
200+
const key = queryKey()
201+
202+
let succeed = false
203+
const consoleMock = mockConsoleError()
204+
205+
function Page() {
206+
useQuery(
207+
key,
208+
async () => {
209+
await sleep(10)
210+
if (!succeed) {
211+
throw new Error('Suspense Error Bingo')
212+
} else {
213+
return 'data'
214+
}
215+
},
216+
{
217+
retry: false,
218+
suspense: true,
219+
}
220+
)
221+
return <div>rendered</div>
222+
}
223+
224+
const rendered = render(
225+
<ReactQueryErrorResetBoundary>
226+
{({ reset }) => (
227+
<ErrorBoundary
228+
onReset={reset}
229+
fallbackRender={({ resetErrorBoundary }) => (
230+
<div>
231+
<div>error boundary</div>
232+
<button
233+
onClick={() => {
234+
resetErrorBoundary()
235+
}}
236+
>
237+
retry
238+
</button>
239+
</div>
240+
)}
241+
>
242+
<React.Suspense fallback="Loading...">
243+
<Page />
244+
</React.Suspense>
245+
</ErrorBoundary>
246+
)}
247+
</ReactQueryErrorResetBoundary>
248+
)
249+
250+
await waitFor(() => rendered.getByText('Loading...'))
251+
await waitFor(() => rendered.getByText('error boundary'))
252+
await waitFor(() => rendered.getByText('retry'))
253+
fireEvent.click(rendered.getByText('retry'))
254+
await waitFor(() => rendered.getByText('error boundary'))
255+
await waitFor(() => rendered.getByText('retry'))
256+
succeed = true
257+
fireEvent.click(rendered.getByText('retry'))
258+
await waitFor(() => rendered.getByText('rendered'))
259+
260+
consoleMock.mockRestore()
261+
})
262+
263+
it('should retry fetch if the reset error boundary has been reset with global hook', async () => {
264+
const key = queryKey()
265+
266+
let succeed = false
267+
const consoleMock = mockConsoleError()
268+
269+
function Page() {
270+
useQuery(
271+
key,
272+
async () => {
273+
await sleep(10)
274+
if (!succeed) {
275+
throw new Error('Suspense Error Bingo')
276+
} else {
277+
return 'data'
278+
}
279+
},
280+
{
281+
retry: false,
282+
suspense: true,
283+
}
284+
)
285+
return <div>rendered</div>
286+
}
287+
288+
function App() {
289+
const { reset } = useErrorResetBoundary()
290+
return (
291+
<ErrorBoundary
292+
onReset={reset}
293+
fallbackRender={({ resetErrorBoundary }) => (
294+
<div>
295+
<div>error boundary</div>
296+
<button
297+
onClick={() => {
298+
resetErrorBoundary()
299+
}}
300+
>
301+
retry
302+
</button>
303+
</div>
304+
)}
305+
>
306+
<React.Suspense fallback="Loading...">
307+
<Page />
308+
</React.Suspense>
309+
</ErrorBoundary>
310+
)
311+
}
312+
313+
const rendered = render(<App />)
314+
315+
await waitFor(() => rendered.getByText('Loading...'))
316+
await waitFor(() => rendered.getByText('error boundary'))
317+
await waitFor(() => rendered.getByText('retry'))
318+
fireEvent.click(rendered.getByText('retry'))
319+
await waitFor(() => rendered.getByText('error boundary'))
320+
await waitFor(() => rendered.getByText('retry'))
321+
succeed = true
322+
fireEvent.click(rendered.getByText('retry'))
323+
await waitFor(() => rendered.getByText('rendered'))
324+
325+
consoleMock.mockRestore()
326+
})
327+
195328
it('should not call the queryFn when not enabled', async () => {
196329
const key = queryKey()
197330

src/react/useBaseQuery.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,19 @@ import React from 'react'
33
import { useRerenderer } from './utils'
44
import { getResolvedQueryConfig } from '../core/config'
55
import { QueryObserver } from '../core/queryObserver'
6-
import { QueryResultBase, QueryConfig, QueryKey } from '../core/types'
7-
import { useQueryCache } from './ReactQueryCacheProvider'
6+
import { QueryResultBase, QueryKey, QueryConfig } from '../core/types'
7+
import { useErrorResetBoundary } from './ReactQueryErrorResetBoundary'
8+
import { useQueryCache } from '.'
89
import { useContextConfig } from './ReactQueryConfigProvider'
910

1011
export function useBaseQuery<TResult, TError>(
1112
queryKey: QueryKey,
1213
config?: QueryConfig<TResult, TError>
1314
): QueryResultBase<TResult, TError> {
14-
const rerender = useRerenderer()
1515
const cache = useQueryCache()
16+
const rerender = useRerenderer()
1617
const contextConfig = useContextConfig()
18+
const errorResetBoundary = useErrorResetBoundary()
1719

1820
// Get resolved config
1921
const resolvedConfig = getResolvedQueryConfig(
@@ -49,7 +51,11 @@ export function useBaseQuery<TResult, TError>(
4951
if (resolvedConfig.suspense || resolvedConfig.useErrorBoundary) {
5052
const query = observer.getCurrentQuery()
5153

52-
if (result.isError && query.state.throwInErrorBoundary) {
54+
if (
55+
result.isError &&
56+
!errorResetBoundary.isReset() &&
57+
query.state.throwInErrorBoundary
58+
) {
5359
throw result.error
5460
}
5561

@@ -58,6 +64,7 @@ export function useBaseQuery<TResult, TError>(
5864
resolvedConfig.suspense &&
5965
!result.isSuccess
6066
) {
67+
errorResetBoundary.clearReset()
6168
const unsubscribe = observer.subscribe()
6269
throw observer.fetch().finally(unsubscribe)
6370
}

0 commit comments

Comments
 (0)