From 3c9339753b7f2982255a786af06077b7d4999a7a Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Mon, 20 Jul 2020 14:36:39 -0600 Subject: [PATCH] feat(useErrorHandler): add new hook for handling errors (#59) --- README.md | 137 ++++++++++++++++++++++++++++++++++++----- index.d.ts | 4 ++ src/__tests__/hook.js | 103 +++++++++++++++++++++++++++++++ src/__tests__/index.js | 14 ----- src/index.js | 9 ++- 5 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 src/__tests__/hook.js diff --git a/README.md b/README.md index 648c489..072f25f 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,7 @@ then be gracefully handled. - [Error Recovery](#error-recovery) - [API](#api) - [`ErrorBoundary` props](#errorboundary-props) - - [`children`](#children) - - [`FallbackComponent`](#fallbackcomponent) - - [`fallbackRender`](#fallbackrender) - - [`fallback`](#fallback) - - [`onError`](#onerror) - - [`onReset`](#onreset) - - [`resetKeys`](#resetkeys) - - [`onResetKeysChange`](#onresetkeyschange) + - [`useErrorHandler(error?: Error)`](#useerrorhandlererror-error) - [Issues](#issues) - [🐛 Bugs](#-bugs) - [💡 Feature Requests](#-feature-requests) @@ -190,13 +183,13 @@ specific scenario. ### `ErrorBoundary` props -### `children` +#### `children` This is what you want rendered when everything's working fine. If there's an error that React can handle within the children of the `ErrorBoundary`, the `ErrorBoundary` will catch that and allow you to handle it gracefully. -### `FallbackComponent` +#### `FallbackComponent` This is a component you want rendered in the event of an error. As props it will be passed the `error`, `componentStack`, and `resetErrorBoundary` (which will @@ -205,7 +198,7 @@ when used in combination with the `onReset` prop). This is required if no `fallback` or `fallbackRender` prop is provided. -### `fallbackRender` +#### `fallbackRender` This is a render-prop based API that allows you to inline your error fallback UI into the component that's using the `ErrorBoundary`. This is useful if you need @@ -247,7 +240,7 @@ problem. This is required if no `FallbackComponent` or `fallback` prop is provided. -### `fallback` +#### `fallback` In the spirit of consistency with the `React.Suspense` component, we also support a simple `fallback` prop which you can use for a generic fallback. This @@ -262,12 +255,12 @@ const ui = ( ) ``` -### `onError` +#### `onError` This will be called when there's been an error that the `ErrorBoundary` has handled. It will be called with two arguments: `error`, `componentStack`. -### `onReset` +#### `onReset` This will be called immediately before the `ErrorBoundary` resets it's internal state (which will result in rendering the `children` again). You should use this @@ -279,7 +272,7 @@ error happening again. **Important**: `onReset` will _not_ be called when reset happens from a change in `resetKeys`. Use `onResetKeysChange` for that. -### `resetKeys` +#### `resetKeys` Sometimes an error happens as a result of local state to the component that's rendering the error. If this is the case, then you can pass `resetKeys` which is @@ -289,11 +282,123 @@ then it will reset automatically (triggering a re-render of the `children`). See the recovery examples above. -### `onResetKeysChange` +#### `onResetKeysChange` This is called when the `resetKeys` are changed (triggering a reset of the `ErrorBoundary`). It's called with the `prevResetKeys` and the `resetKeys`. +### `useErrorHandler(error?: Error)` + +React's error boundaries feature is limited in that the boundaries can only +handle errors thrown during React's lifecycles. To quote +[the React docs on Error Boundaries](https://reactjs.org/docs/error-boundaries.html): + +> Error boundaries do not catch errors for: +> +> - Event handlers +> ([learn more](https://reactjs.org/docs/error-boundaries.html#how-about-event-handlers)) +> - Asynchronous code (e.g. setTimeout or requestAnimationFrame callbacks) +> - Server side rendering +> - Errors thrown in the error boundary itself (rather than its children) + +This means you have to handle those errors yourself, but you probably would like +to reuse the error boundaries you worked hard on creating for those kinds of +errors as well. This is what `useErrorHandler` is for. + +There are two ways to use `useErrorHandler`: + +1. `const handleError = useErrorHandler()`: call `handleError(theError)` +2. `useErrorHandler(error)`: useful if you are managing the error state yourself + or get it from another hook. + +Here's an example: + +```javascript +function Greeting() { + const [greeting, setGreeting] = React.useState(null) + const handleError = useHandleError() + + function handleSubmit(event) { + event.preventDefault() + const name = event.target.elements.name.value + fetchGreeting(name).then( + newGreeting => setGreeting(newGreeting), + handleError, + ) + } + + return greeting ? ( +
{greeting}
+ ) : ( +
+ + + +
+ ) +} +``` + +> Note, in case it's not clear what's happening here, you could also write +> `handleClick` like this: + +```javascript +function handleSubmit(event) { + event.preventDefault() + const name = event.target.elements.name.value + fetchGreeting(name).then( + newGreeting => setGreeting(newGreeting), + error => handleError(error), + ) +} +``` + +Alternatively, let's say you're using a hook that gives you the error: + +```javascript +function Greeting() { + const [name, setName] = React.useState('') + const {greeting, error} = useGreeting(name) + useHandleError(error) + + function handleSubmit(event) { + event.preventDefault() + const name = event.target.elements.name.value + setName(name) + } + + return greeting ? ( +
{greeting}
+ ) : ( +
+ + + +
+ ) +} +``` + +In this case, if the `error` is ever set to a truthy value, then it will be +propagated to the nearest error boundary. + +In either case, you could handle those errors like this: + +```javascript +const ui = ( + + + +) +``` + +And now that'll handle your runtime errors as well as the async errors in the +`fetchGreeting` or `useGreeting` code. + ## Issues _Looking to contribute? Look for the [Good First Issue][good-first-issue] diff --git a/index.d.ts b/index.d.ts index 6b83b5c..02ce49a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -41,3 +41,7 @@ export function withErrorBoundary

( ComponentToDecorate: React.ComponentType

, errorBoundaryProps: ErrorBoundaryProps, ): React.ComponentType

+ +export function useErrorHandler

( + error?: P, +): React.Dispatch> diff --git a/src/__tests__/hook.js b/src/__tests__/hook.js new file mode 100644 index 0000000..326d92d --- /dev/null +++ b/src/__tests__/hook.js @@ -0,0 +1,103 @@ +import React from 'react' +import userEvent from '@testing-library/user-event' +import {render, screen} from '@testing-library/react' +import {ErrorBoundary, useErrorHandler} from '..' + +function ErrorFallback({error, componentStack, resetErrorBoundary}) { + return ( +

+

Something went wrong:

+
{error.message}
+
{componentStack}
+ +
+ ) +} + +const firstLine = str => str.split('\n')[0] + +test('handleError forwards along async errors', async () => { + function AsyncBomb() { + const [explode, setExplode] = React.useState(false) + const handleError = useErrorHandler() + React.useEffect(() => { + if (explode) { + setTimeout(() => { + handleError(new Error('💥 CABOOM 💥')) + }) + } + }) + return + } + render( + + + , + ) + + userEvent.click(screen.getByRole('button', {name: /bomb/i})) + + await screen.findByRole('alert') + + const [[actualError], [componentStack]] = console.error.mock.calls + const firstLineOfError = firstLine(actualError) + expect(firstLineOfError).toMatchInlineSnapshot( + `"Error: Uncaught [Error: 💥 CABOOM 💥]"`, + ) + expect(componentStack).toMatchInlineSnapshot(` + "The above error occurred in one of your React components: + in Unknown + in ErrorBoundary + + React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary." + `) + expect(console.error).toHaveBeenCalledTimes(2) + console.error.mockClear() + + // can recover + userEvent.click(screen.getByRole('button', {name: /try again/i})) + expect(console.error).not.toHaveBeenCalled() +}) + +test('can pass an error to useErrorHandler', async () => { + function AsyncBomb() { + const [error, setError] = React.useState(null) + const [explode, setExplode] = React.useState(false) + useErrorHandler(error) + React.useEffect(() => { + if (explode) { + setTimeout(() => { + setError(new Error('💥 CABOOM 💥')) + }) + } + }) + return + } + render( + + + , + ) + + userEvent.click(screen.getByRole('button', {name: /bomb/i})) + + await screen.findByRole('alert') + const [[actualError], [componentStack]] = console.error.mock.calls + const firstLineOfError = firstLine(actualError) + expect(firstLineOfError).toMatchInlineSnapshot( + `"Error: Uncaught [Error: 💥 CABOOM 💥]"`, + ) + expect(componentStack).toMatchInlineSnapshot(` + "The above error occurred in one of your React components: + in Unknown + in ErrorBoundary + + React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary." + `) + expect(console.error).toHaveBeenCalledTimes(2) + console.error.mockClear() + + // can recover + userEvent.click(screen.getByRole('button', {name: /try again/i})) + expect(console.error).not.toHaveBeenCalled() +}) diff --git a/src/__tests__/index.js b/src/__tests__/index.js index 3cd3c6e..02bb349 100644 --- a/src/__tests__/index.js +++ b/src/__tests__/index.js @@ -20,20 +20,6 @@ function Bomb() { const firstLine = str => str.split('\n')[0] -beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}) -}) - -afterEach(() => { - try { - expect(console.error).not.toHaveBeenCalled() - } catch (e) { - throw new Error( - `console.error was called unexpectedly (make sure to assert all calls and console.error.mockClear() at the end of the test)`, - ) - } -}) - test('standard use-case', async () => { function App() { const [username, setUsername] = React.useState('') diff --git a/src/index.js b/src/index.js index c8e72a5..a42e6da 100644 --- a/src/index.js +++ b/src/index.js @@ -68,4 +68,11 @@ function withErrorBoundary(Component, errorBoundaryProps) { return Wrapped } -export {ErrorBoundary, withErrorBoundary} +function useErrorHandler(givenError) { + const [error, setError] = React.useState(null) + if (givenError) throw givenError + if (error) throw error + return setError +} + +export {ErrorBoundary, withErrorBoundary, useErrorHandler}