diff --git a/docs/src/pages/docs/guides/suspense.md b/docs/src/pages/docs/guides/suspense.md index 3e43086846..11f0d2442e 100644 --- a/docs/src/pages/docs/guides/suspense.md +++ b/docs/src/pages/docs/guides/suspense.md @@ -43,23 +43,57 @@ In addition to queries behaving differently in suspense mode, mutations also beh ## Resetting Error Boundaries -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. +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. -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: +Query errors can be reset with the `ReactQueryErrorResetBoundary` component or with the `useErrorResetBoundary` hook. + +When using the component it will reset any query errors within the boundaries of the component: + +```js +import { ReactQueryErrorResetBoundary } from 'react-query' +import { ErrorBoundary } from 'react-error-boundary' + +const App: React.FC = () => ( + + {({ reset }) => ( + ( +
+ There was an error! + +
+ )} + > + +
+ )} +
+) +``` + +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: ```js -import { queryCache } from "react-query"; -import { ErrorBoundary } from "react-error-boundary"; - - queryCache.resetErrorBoundaries()} - fallbackRender={({ error, resetErrorBoundary }) => ( -
- There was an error! - -
- )} -> +import { useErrorResetBoundary } from 'react-query' +import { ErrorBoundary } from 'react-error-boundary' + +const App: React.FC = () => { + const { reset } = useErrorResetBoundary() + return ( + ( +
+ There was an error! + +
+ )} + > + +
+ ) +} ``` ## Fetch-on-render vs Render-as-you-fetch diff --git a/src/react/ReactQueryErrorResetBoundary.tsx b/src/react/ReactQueryErrorResetBoundary.tsx new file mode 100644 index 0000000000..ac6a8adf92 --- /dev/null +++ b/src/react/ReactQueryErrorResetBoundary.tsx @@ -0,0 +1,51 @@ +import React from 'react' + +// CONTEXT + +interface ReactQueryErrorResetBoundaryValue { + clearReset: () => void + isReset: () => boolean + reset: () => void +} + +function createValue(): ReactQueryErrorResetBoundaryValue { + let isReset = true + return { + clearReset: () => { + isReset = false + }, + reset: () => { + isReset = true + }, + isReset: () => { + return isReset + }, + } +} + +const context = React.createContext(createValue()) + +// HOOK + +export const useErrorResetBoundary = () => React.useContext(context) + +// COMPONENT + +export interface ReactQueryErrorResetBoundaryProps { + children: + | ((value: ReactQueryErrorResetBoundaryValue) => React.ReactNode) + | React.ReactNode +} + +export const ReactQueryErrorResetBoundary: React.FC = ({ + children, +}) => { + const value = React.useMemo(() => createValue(), []) + return ( + + {typeof children === 'function' + ? (children as Function)(value) + : children} + + ) +} diff --git a/src/react/index.ts b/src/react/index.ts index 4a645f15bd..93a54f7e44 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -3,6 +3,10 @@ export { useQueryCache, } from './ReactQueryCacheProvider' export { ReactQueryConfigProvider } from './ReactQueryConfigProvider' +export { + ReactQueryErrorResetBoundary, + useErrorResetBoundary, +} from './ReactQueryErrorResetBoundary' export { useIsFetching } from './useIsFetching' export { useMutation } from './useMutation' export { useQuery } from './useQuery' @@ -15,3 +19,4 @@ export type { UseInfiniteQueryObjectConfig } from './useInfiniteQuery' export type { UsePaginatedQueryObjectConfig } from './usePaginatedQuery' export type { ReactQueryCacheProviderProps } from './ReactQueryCacheProvider' export type { ReactQueryConfigProviderProps } from './ReactQueryConfigProvider' +export type { ReactQueryErrorResetBoundaryProps } from './ReactQueryErrorResetBoundary' diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx index 0dd3cd0845..387fce907c 100644 --- a/src/react/tests/suspense.test.tsx +++ b/src/react/tests/suspense.test.tsx @@ -5,6 +5,10 @@ import * as React from 'react' import { sleep, queryKey, mockConsoleError } from './utils' import { useQuery } from '..' import { queryCache } from '../../core' +import { + ReactQueryErrorResetBoundary, + useErrorResetBoundary, +} from '../ReactQueryErrorResetBoundary' describe("useQuery's in Suspense mode", () => { it('should not call the queryFn twice when used in Suspense mode', async () => { @@ -192,6 +196,135 @@ describe("useQuery's in Suspense mode", () => { consoleMock.mockRestore() }) + it('should retry fetch if the reset error boundary has been reset', async () => { + const key = queryKey() + + let succeed = false + const consoleMock = mockConsoleError() + + function Page() { + useQuery( + key, + async () => { + await sleep(10) + if (!succeed) { + throw new Error('Suspense Error Bingo') + } else { + return 'data' + } + }, + { + retry: false, + suspense: true, + } + ) + return
rendered
+ } + + const rendered = render( + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + + + +
+ )} +
+ ) + + await waitFor(() => rendered.getByText('Loading...')) + await waitFor(() => rendered.getByText('error boundary')) + await waitFor(() => rendered.getByText('retry')) + fireEvent.click(rendered.getByText('retry')) + await waitFor(() => rendered.getByText('error boundary')) + await waitFor(() => rendered.getByText('retry')) + succeed = true + fireEvent.click(rendered.getByText('retry')) + await waitFor(() => rendered.getByText('rendered')) + + consoleMock.mockRestore() + }) + + it('should retry fetch if the reset error boundary has been reset with global hook', async () => { + const key = queryKey() + + let succeed = false + const consoleMock = mockConsoleError() + + function Page() { + useQuery( + key, + async () => { + await sleep(10) + if (!succeed) { + throw new Error('Suspense Error Bingo') + } else { + return 'data' + } + }, + { + retry: false, + suspense: true, + } + ) + return
rendered
+ } + + function App() { + const { reset } = useErrorResetBoundary() + return ( + ( +
+
error boundary
+ +
+ )} + > + + + +
+ ) + } + + const rendered = render() + + await waitFor(() => rendered.getByText('Loading...')) + await waitFor(() => rendered.getByText('error boundary')) + await waitFor(() => rendered.getByText('retry')) + fireEvent.click(rendered.getByText('retry')) + await waitFor(() => rendered.getByText('error boundary')) + await waitFor(() => rendered.getByText('retry')) + succeed = true + fireEvent.click(rendered.getByText('retry')) + await waitFor(() => rendered.getByText('rendered')) + + consoleMock.mockRestore() + }) + it('should not call the queryFn when not enabled', async () => { const key = queryKey() diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts index 6509f8efd0..cce86a709d 100644 --- a/src/react/useBaseQuery.ts +++ b/src/react/useBaseQuery.ts @@ -3,17 +3,19 @@ import React from 'react' import { useRerenderer } from './utils' import { getResolvedQueryConfig } from '../core/config' import { QueryObserver } from '../core/queryObserver' -import { QueryResultBase, QueryConfig, QueryKey } from '../core/types' -import { useQueryCache } from './ReactQueryCacheProvider' +import { QueryResultBase, QueryKey, QueryConfig } from '../core/types' +import { useErrorResetBoundary } from './ReactQueryErrorResetBoundary' +import { useQueryCache } from '.' import { useContextConfig } from './ReactQueryConfigProvider' export function useBaseQuery( queryKey: QueryKey, config?: QueryConfig ): QueryResultBase { - const rerender = useRerenderer() const cache = useQueryCache() + const rerender = useRerenderer() const contextConfig = useContextConfig() + const errorResetBoundary = useErrorResetBoundary() // Get resolved config const resolvedConfig = getResolvedQueryConfig( @@ -49,7 +51,11 @@ export function useBaseQuery( if (resolvedConfig.suspense || resolvedConfig.useErrorBoundary) { const query = observer.getCurrentQuery() - if (result.isError && query.state.throwInErrorBoundary) { + if ( + result.isError && + !errorResetBoundary.isReset() && + query.state.throwInErrorBoundary + ) { throw result.error } @@ -58,6 +64,7 @@ export function useBaseQuery( resolvedConfig.suspense && !result.isSuccess ) { + errorResetBoundary.clearReset() const unsubscribe = observer.subscribe() throw observer.fetch().finally(unsubscribe) }