├── .gitattributes ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.cjs ├── eslint.config.mjs ├── other ├── MAINTAINING.md ├── USERS.md └── manual-releases.md ├── package.json ├── pnpm-lock.yaml ├── src ├── ErrorBoundary.test.tsx ├── ErrorBoundary.ts ├── ErrorBoundaryContext.ts ├── assertErrorBoundaryContext.ts ├── env-conditions │ ├── development.ts │ └── production.ts ├── index.ts ├── types.ts ├── useErrorBoundary.test.tsx ├── useErrorBoundary.ts ├── withErrorBoundary.test.tsx └── withErrorBoundary.ts ├── tsconfig.json └── vitest.config.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | - `react-error-boundary` version: 15 | - `node` version: 16 | - `npm` version: 17 | 18 | Relevant code or config 19 | 20 | ```js 21 | 22 | ``` 23 | 24 | What you did: 25 | 26 | What happened: 27 | 28 | 29 | 30 | Reproduction repository: 31 | 32 | 36 | 37 | Problem description: 38 | 39 | Suggested solution: 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | **What**: 20 | 21 | 22 | 23 | **Why**: 24 | 25 | 26 | 27 | **How**: 28 | 29 | 30 | 31 | **Checklist**: 32 | 33 | 34 | 35 | 36 | - [ ] Documentation 37 | - [ ] Tests 38 | - [ ] Ready to be merged 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | jobs: 8 | ci: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | command: ["pnpm lint", "pnpm typescript", "pnpm test"] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: pnpm/action-setup@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | cache: "pnpm" 19 | cache-dependency-path: "pnpm-lock.yaml" 20 | node-version-file: ".nvmrc" 21 | - run: pnpm install --frozen-lockfile 22 | - run: pnpm prerelease 23 | - name: Run cis 24 | run: ${{ matrix.command }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | 4 | .DS_Store 5 | .cache 6 | *.log 7 | .parcel-cache 8 | .pnp.* -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.9.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "arrowParens": "always", 7 | "bracketSameLine": false, 8 | "bracketSpacing": true, 9 | "endOfLine": "lf", 10 | "jsxSingleQuote": false, 11 | "proseWrap": "preserve", 12 | "quoteProps": "as-needed" 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | See the [releases page](../../releases). 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | me+coc@kentcdodds.com. All complaints will be reviewed and investigated promptly 64 | and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | **Working on your first Pull Request?** You can learn how from this _free_ 6 | series [How to Contribute to an Open Source Project on GitHub][egghead] 7 | 8 | ## Project setup 9 | 10 | 1. Fork and clone the repo 11 | 2. Run `pnpm install` to install dependencies and run validation (We use pnpm so you need to follow [pnpm installation guide](https://pnpm.io/installation) first) 12 | 3. Create a branch for your PR with `git checkout -b pr/your-branch-name` 13 | 14 | > Tip: Keep your `master` branch pointing at the original repository and make 15 | > pull requests from branches on your fork. To do this, run: 16 | > 17 | > ``` 18 | > git remote add upstream https://github.com/bvaughn/react-error-boundary 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/master master 21 | > ``` 22 | > 23 | > This will add the original repository as a "remote" called "upstream," Then 24 | > fetch the git information from that remote, then set your local `master` 25 | > branch to use the upstream master branch whenever you run `git pull`. Then you 26 | > can make all of your pull request branches based on this `master` branch. 27 | > Whenever you want to update your version of `master`, do a regular `git pull`. 28 | 29 | ## Committing and Pushing changes 30 | 31 | Please make sure to run the tests before you commit your changes. You can run `pnpm test`. Make 32 | sure to include those changes (if they exist) in your commit. 33 | 34 | ## Help needed 35 | 36 | Please checkout the [open issues][issues] 37 | 38 | Also, please watch the repo and respond to questions/bug reports/feature 39 | requests! Thanks! 40 | 41 | 42 | [egghead]: https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github 43 | [issues]: https://github.com/bvaughn/react-error-boundary/issues 44 | 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2020 Brian Vaughn 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-error-boundary 2 | 3 | Reusable React [error boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) component. Supports all React renderers (including React DOM and React Native). 4 | 5 | ### If you like this project, 🎉 [become a sponsor](https://github.com/sponsors/bvaughn/) or ☕ [buy me a coffee](http://givebrian.coffee/) 6 | 7 | ## Getting started 8 | 9 | ```sh 10 | # npm 11 | npm install react-error-boundary 12 | 13 | # pnpm 14 | pnpm add react-error-boundary 15 | 16 | # yarn 17 | yarn add react-error-boundary 18 | ``` 19 | 20 | ## API 21 | 22 | ### `ErrorBoundary` component 23 | Wrap an `ErrorBoundary` component around other React components to "catch" errors and render a fallback UI. The component supports several ways to render a fallback (as shown below). 24 | 25 | > **Note** `ErrorBoundary` is a _client_ component. You can only pass props to it that are serializeable or use it in files that have a `"use client";` directive. 26 | 27 | #### `ErrorBoundary` with `fallback` prop 28 | The simplest way to render a default "something went wrong" type of error message. 29 | ```js 30 | "use client"; 31 | 32 | import { ErrorBoundary } from "react-error-boundary"; 33 | 34 | Something went wrong}> 35 | 36 | 37 | ``` 38 | #### `ErrorBoundary` with `fallbackRender` prop 39 | ["Render prop"](https://react.dev/reference/react/Children#calling-a-render-prop-to-customize-rendering) function responsible for returning a fallback UI based on a thrown value. 40 | ```js 41 | "use client"; 42 | 43 | import { ErrorBoundary } from "react-error-boundary"; 44 | 45 | function fallbackRender({ error, resetErrorBoundary }) { 46 | // Call resetErrorBoundary() to reset the error boundary and retry the render. 47 | 48 | return ( 49 |
50 |

Something went wrong:

51 |
{error.message}
52 |
53 | ); 54 | } 55 | 56 | { 59 | // Reset the state of your app so the error doesn't happen again 60 | }} 61 | > 62 | 63 | ; 64 | ``` 65 | #### `ErrorBoundary` with `FallbackComponent` prop 66 | React component responsible for returning a fallback UI based on a thrown value. 67 | ```js 68 | "use client"; 69 | 70 | import { ErrorBoundary } from "react-error-boundary"; 71 | 72 | function Fallback({ error, resetErrorBoundary }) { 73 | // Call resetErrorBoundary() to reset the error boundary and retry the render. 74 | 75 | return ( 76 |
77 |

Something went wrong:

78 |
{error.message}
79 |
80 | ); 81 | } 82 | 83 | { 86 | // Reset the state of your app so the error doesn't happen again 87 | }} 88 | > 89 | 90 | ; 91 | ``` 92 | 93 | #### Logging errors with `onError` 94 | 95 | ```js 96 | "use client"; 97 | 98 | import { ErrorBoundary } from "react-error-boundary"; 99 | 100 | const logError = (error: Error, info: { componentStack: string }) => { 101 | // Do something with the error, e.g. log to an external API 102 | }; 103 | 104 | const ui = ( 105 | 106 | 107 | 108 | ); 109 | ``` 110 | 111 | ### `useErrorBoundary` hook 112 | Convenience hook for imperatively showing or dismissing error boundaries. 113 | 114 | #### Show the nearest error boundary from an event handler 115 | 116 | React only handles errors thrown during render or during component lifecycle methods (e.g. effects and did-mount/did-update). Errors thrown in event handlers, or after async code has run, will not be caught. 117 | 118 | This hook can be used to pass those errors to the nearest error boundary: 119 | 120 | ```js 121 | "use client"; 122 | 123 | import { useErrorBoundary } from "react-error-boundary"; 124 | 125 | function Example() { 126 | const { showBoundary } = useErrorBoundary(); 127 | 128 | useEffect(() => { 129 | fetchGreeting(name).then( 130 | response => { 131 | // Set data in state and re-render 132 | }, 133 | error => { 134 | // Show error boundary 135 | showBoundary(error); 136 | } 137 | ); 138 | }); 139 | 140 | // Render ... 141 | } 142 | ``` 143 | 144 | #### Dismiss the nearest error boundary 145 | A fallback component can use this hook to request the nearest error boundary retry the render that originally failed. 146 | 147 | ```js 148 | "use client"; 149 | 150 | import { useErrorBoundary } from "react-error-boundary"; 151 | 152 | function ErrorFallback({ error }) { 153 | const { resetBoundary } = useErrorBoundary(); 154 | 155 | return ( 156 |
157 |

Something went wrong:

158 |
{error.message}
159 | 160 |
161 | ); 162 | } 163 | ``` 164 | 165 | ### `withErrorBoundary` HOC 166 | This package can also be used as a [higher-order component](https://legacy.reactjs.org/docs/higher-order-components.html) that accepts all of the same props as above: 167 | 168 | ```js 169 | "use client"; 170 | 171 | import {withErrorBoundary} from 'react-error-boundary' 172 | 173 | const ComponentWithErrorBoundary = withErrorBoundary(ExampleComponent, { 174 | fallback:
Something went wrong
, 175 | onError(error, info) { 176 | // Do something with the error 177 | // E.g. log to an error logging client here 178 | }, 179 | }) 180 | 181 | // Can be rendered as 182 | ``` 183 | 184 | --- 185 | 186 | # FAQ 187 | ## `ErrorBoundary` cannot be used as a JSX component 188 | This error can be caused by a version mismatch between [react](https://npmjs.com/package/react) and [@types/react](https://npmjs.com/package/@types/react). To fix this, ensure that both match exactly, e.g.: 189 | 190 | If using NPM: 191 | ```json 192 | { 193 | ... 194 | "overrides": { 195 | "@types/react": "17.0.60" 196 | }, 197 | ... 198 | } 199 | ``` 200 | 201 | If using Yarn: 202 | ```json 203 | { 204 | ... 205 | "resolutions": { 206 | "@types/react": "17.0.60" 207 | }, 208 | ... 209 | } 210 | ``` 211 | 212 | --- 213 | 214 | [This blog post](https://kentcdodds.com/blog/use-react-error-boundary-to-handle-errors-in-react) shows more examples of how this package can be used, although it was written for the [version 3 API](https://github.com/bvaughn/react-error-boundary/releases/tag/v3.1.4). 215 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-typescript", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | targets: { 8 | safari: "12", 9 | }, 10 | }, 11 | ], 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import tseslint from "typescript-eslint"; 3 | import reactPlugin from "eslint-plugin-react"; 4 | import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; 5 | 6 | export default tseslint.config( 7 | { 8 | ignores: ["dist"], 9 | }, 10 | reactPlugin.configs.flat.recommended, 11 | tseslint.configs.recommended, 12 | { 13 | languageOptions: { 14 | globals: { 15 | ...globals.browser, 16 | ...globals.es2021, 17 | }, 18 | parser: tseslint.parser, 19 | parserOptions: { 20 | ecmaVersion: "latest", 21 | sourceType: "module", 22 | }, 23 | }, 24 | plugins: { 25 | react: reactPlugin, 26 | "@typescript-eslint": tseslint.plugin, 27 | }, 28 | rules: { 29 | "react/no-did-update-set-state": "off", 30 | "react/react-in-jsx-scope": "off", 31 | "react/prop-types": "off", 32 | "@typescript-eslint/no-explicit-any": "off", 33 | }, 34 | }, 35 | eslintPluginPrettierRecommended 36 | ); 37 | -------------------------------------------------------------------------------- /other/MAINTAINING.md: -------------------------------------------------------------------------------- 1 | # Maintaining 2 | 3 | 4 | 5 | 6 | **Table of Contents** 7 | 8 | - [Code of Conduct](#code-of-conduct) 9 | - [Issues](#issues) 10 | - [Pull Requests](#pull-requests) 11 | - [Release](#release) 12 | - [Thanks!](#thanks) 13 | 14 | 15 | 16 | This is documentation for maintainers of this project. 17 | 18 | ## Code of Conduct 19 | 20 | Please review, understand, and be an example of it. Violations of the code of 21 | conduct are taken seriously, even (especially) for maintainers. 22 | 23 | ## Issues 24 | 25 | We want to support and build the community. We do that best by helping people 26 | learn to solve their own problems. We have an issue template and hopefully most 27 | folks follow it. If it's not clear what the issue is, invite them to create a 28 | minimal reproduction of what they're trying to accomplish or the bug they think 29 | they've found. 30 | 31 | Once it's determined that a code change is necessary, point people to 32 | [makeapullrequest.com](https://makeapullrequest.com) and invite them to make a 33 | pull request. If they're the one who needs the feature, they're the one who can 34 | build it. If they need some hand holding and you have time to lend a hand, 35 | please do so. It's an investment into another human being, and an investment 36 | into a potential maintainer. 37 | 38 | Remember that this is open source, so the code is not yours, it's ours. If 39 | someone needs a change in the codebase, you don't have to make it happen 40 | yourself. Commit as much time to the project as you want/need to. Nobody can ask 41 | any more of you than that. 42 | 43 | ## Pull Requests 44 | 45 | As a maintainer, you're fine to make your branches on the main repo or on your 46 | own fork. Either way is fine. 47 | 48 | When we receive a pull request, a GitHub Action is kicked off automatically (see 49 | the `.github/workflows/validate.yml` for what runs in the Action). We avoid 50 | merging anything that breaks the GitHub Action. 51 | 52 | Please review PRs and focus on the code rather than the individual. You never 53 | know when this is someone's first ever PR and we want their experience to be as 54 | positive as possible, so be uplifting and constructive. 55 | 56 | When you merge the pull request, 99% of the time you should use the 57 | [Squash and merge](https://help.github.com/articles/merging-a-pull-request/) 58 | feature. This keeps our git history clean, but more importantly, this allows us 59 | to make any necessary changes to the commit message so we release what we want 60 | to release. See the next section on Releases for more about that. 61 | 62 | ## Release 63 | 64 | Our releases are automatic. They happen whenever code lands into `main`. A 65 | GitHub Action gets kicked off and if it's successful, a tool called 66 | [`semantic-release`](https://github.com/semantic-release/semantic-release) is 67 | used to automatically publish a new release to npm as well as a changelog to 68 | GitHub. It is only able to determine the version and whether a release is 69 | necessary by the git commit messages. With this in mind, **please brush up on 70 | [the commit message convention][commit] which drives our releases.** 71 | 72 | > One important note about this: Please make sure that commit messages do NOT 73 | > contain the words "BREAKING CHANGE" in them unless we want to push a major 74 | > version. I've been burned by this more than once where someone will include 75 | > "BREAKING CHANGE: None" and it will end up releasing a new major version. Not 76 | > a huge deal honestly, but kind of annoying... 77 | 78 | ## Thanks! 79 | 80 | Thank you so much for helping to maintain this project! 81 | 82 | 83 | [commit]: https://github.com/conventional-changelog-archived-repos/conventional-changelog-angular/blob/ed32559941719a130bb0327f886d6a32a8cbc2ba/convention.md 84 | 85 | -------------------------------------------------------------------------------- /other/USERS.md: -------------------------------------------------------------------------------- 1 | # Users 2 | 3 | If you or your company uses this project, add your name to this list! Eventually 4 | we may have a website to showcase these (wanna build it!?) 5 | 6 | > No users have been added yet! 7 | 8 | 13 | -------------------------------------------------------------------------------- /other/manual-releases.md: -------------------------------------------------------------------------------- 1 | # manual-releases 2 | 3 | This project has an automated release set up. So things are only released when 4 | there are useful changes in the code that justify a release. But sometimes 5 | things get messed up one way or another and we need to trigger the release 6 | ourselves. When this happens, simply bump the number below and commit that with 7 | the following commit message based on your needs: 8 | 9 | **Major** 10 | 11 | ``` 12 | fix(release): manually release a major version 13 | 14 | There was an issue with a major release, so this manual-releases.md 15 | change is to release a new major version. 16 | 17 | Reference: # 18 | 19 | BREAKING CHANGE: 20 | ``` 21 | 22 | **Minor** 23 | 24 | ``` 25 | feat(release): manually release a minor version 26 | 27 | There was an issue with a minor release, so this manual-releases.md 28 | change is to release a new minor version. 29 | 30 | Reference: # 31 | ``` 32 | 33 | **Patch** 34 | 35 | ``` 36 | fix(release): manually release a patch version 37 | 38 | There was an issue with a patch release, so this manual-releases.md 39 | change is to release a new patch version. 40 | 41 | Reference: # 42 | ``` 43 | 44 | The number of times we've had to do a manual release is: 0 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-error-boundary", 3 | "version": "6.0.0", 4 | "type": "module", 5 | "description": "Simple reusable React error boundary component", 6 | "author": "Brian Vaughn ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/bvaughn/react-error-boundary" 11 | }, 12 | "packageManager": "pnpm@9.6.0", 13 | "exports": { 14 | ".": { 15 | "types": "./dist/react-error-boundary.js", 16 | "development": "./dist/react-error-boundary.development.js", 17 | "default": "./dist/react-error-boundary.js" 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "imports": { 22 | "#is-development": { 23 | "development": "./src/env-conditions/development.ts", 24 | "default": "./src/env-conditions/production.ts" 25 | } 26 | }, 27 | "types": "dist/react-error-boundary.d.ts", 28 | "files": [ 29 | "dist" 30 | ], 31 | "sideEffects": false, 32 | "scripts": { 33 | "clear": "pnpm clear:builds & pnpm clear:node_modules", 34 | "clear:builds": "rimraf ./dist", 35 | "clear:node_modules": "rimraf ./node_modules", 36 | "prerelease": "preconstruct build", 37 | "lint": "eslint .", 38 | "lint:fix": "eslint . --fix", 39 | "test": "vitest --environment=jsdom --watch=false", 40 | "test:watch": "vitest --environment=jsdom --watch", 41 | "typescript": "tsc --noEmit", 42 | "typescript:watch": "tsc --noEmit --watch" 43 | }, 44 | "dependencies": { 45 | "@babel/runtime": "^7.12.5" 46 | }, 47 | "devDependencies": { 48 | "@babel/preset-env": "^7.22.5", 49 | "@babel/preset-typescript": "^7.21.5", 50 | "@preconstruct/cli": "^2.8.12", 51 | "@types/assert": "^1.5.10", 52 | "@types/react": "^18.3.17", 53 | "@types/react-dom": "^18", 54 | "assert": "^2.0.0", 55 | "eslint": "^9.13.0", 56 | "eslint-config-prettier": "^9.1.0", 57 | "eslint-plugin-import": "^2.25.2", 58 | "eslint-plugin-prettier": "^5.2.1", 59 | "eslint-plugin-react": "^7.37.2", 60 | "globals": "^15.11.0", 61 | "prettier": "^3.0.1", 62 | "react": "^18", 63 | "react-dom": "^18", 64 | "rimraf": "^6.0.1", 65 | "vitest": "^3.1.2", 66 | "typescript": "^5.8.3", 67 | "typescript-eslint": "^8.18.0" 68 | }, 69 | "peerDependencies": { 70 | "react": ">=16.13.1" 71 | }, 72 | "preconstruct": { 73 | "exports": { 74 | "importConditionDefaultExport": "default" 75 | }, 76 | "___experimentalFlags_WILL_CHANGE_IN_PATCH": { 77 | "distInRoot": true, 78 | "importsConditions": true, 79 | "typeModule": true 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/ErrorBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, vi, it, expect, Mock } from "vitest"; 2 | import assert from "assert"; 3 | import { createRef, PropsWithChildren, ReactElement, RefObject } from "react"; 4 | import { createRoot } from "react-dom/client"; 5 | import { act } from "react-dom/test-utils"; 6 | import { ErrorBoundary } from "./ErrorBoundary"; 7 | import { 8 | ErrorBoundaryPropsWithComponent, 9 | ErrorBoundaryPropsWithFallback, 10 | ErrorBoundaryPropsWithRender, 11 | FallbackProps, 12 | } from "./types"; 13 | 14 | describe("ErrorBoundary", () => { 15 | let container: HTMLDivElement; 16 | let root: ReturnType; 17 | let shouldThrow = true; 18 | let valueToThrow: any; 19 | 20 | beforeEach(() => { 21 | // @ts-expect-error This is a React internal 22 | global.IS_REACT_ACT_ENVIRONMENT = true; 23 | 24 | // Don't clutter the console with expected error text 25 | vi.spyOn(console, "error").mockImplementation(() => { 26 | // No-op 27 | }); 28 | 29 | container = document.createElement("div"); 30 | root = createRoot(container); 31 | shouldThrow = false; 32 | valueToThrow = new Error("💥💥💥"); 33 | }); 34 | 35 | function MaybeThrows({ children }: PropsWithChildren) { 36 | if (shouldThrow) { 37 | throw valueToThrow; 38 | } 39 | return children as any; 40 | } 41 | 42 | it("should render children", () => { 43 | const container = document.createElement("div"); 44 | const root = createRoot(container); 45 | act(() => { 46 | root.render( 47 | Error}> 48 | Content 49 | 50 | ); 51 | }); 52 | 53 | expect(container.textContent).toBe("Content"); 54 | }); 55 | 56 | describe("fallback props", () => { 57 | let errorBoundaryRef: RefObject; 58 | 59 | beforeEach(() => { 60 | errorBoundaryRef = createRef(); 61 | }); 62 | 63 | function render(props: Omit) { 64 | act(() => { 65 | root.render( 66 | 67 | Content 68 | 69 | ); 70 | }); 71 | } 72 | 73 | it('should call "onError" prop if one is provided', () => { 74 | shouldThrow = true; 75 | 76 | const onError: Mock<(...args: any[]) => any> = vi.fn(); 77 | 78 | render({ onError }); 79 | 80 | expect(onError).toHaveBeenCalledTimes(1); 81 | expect(onError.mock.calls[0][0].message).toEqual("💥💥💥"); 82 | }); 83 | 84 | it('should call "onReset" when boundary reset via imperative API', () => { 85 | shouldThrow = true; 86 | 87 | const onReset: Mock<(...args: any[]) => any> = vi.fn(); 88 | 89 | render({ onReset }); 90 | expect(onReset).not.toHaveBeenCalled(); 91 | 92 | act(() => errorBoundaryRef.current?.resetErrorBoundary("abc", 123)); 93 | 94 | expect(onReset).toHaveBeenCalledTimes(1); 95 | }); 96 | 97 | it('should call "onReset" when boundary reset via "resetKeys"', () => { 98 | shouldThrow = false; 99 | 100 | const onReset: Mock<(...args: any[]) => any> = vi.fn(); 101 | 102 | render({ onReset, resetKeys: [1] }); 103 | expect(onReset).not.toHaveBeenCalled(); 104 | 105 | // It should not be called if the keys change without an error 106 | render({ onReset, resetKeys: [2] }); 107 | expect(onReset).not.toHaveBeenCalled(); 108 | 109 | shouldThrow = true; 110 | 111 | render({ onReset, resetKeys: [2] }); 112 | expect(onReset).not.toHaveBeenCalled(); 113 | 114 | shouldThrow = false; 115 | 116 | render({ onReset, resetKeys: [3] }); 117 | expect(onReset).toHaveBeenCalledTimes(1); 118 | }); 119 | }); 120 | 121 | describe('"fallback" element', () => { 122 | function render( 123 | props: Omit = {} 124 | ) { 125 | act(() => { 126 | root.render( 127 | Error}> 128 | Content 129 | 130 | ); 131 | }); 132 | } 133 | 134 | it("should render fallback in the event of an error", () => { 135 | shouldThrow = true; 136 | render(); 137 | expect(container.textContent).toBe("Error"); 138 | }); 139 | 140 | it("should re-render children if boundary is reset reset keys", () => { 141 | shouldThrow = true; 142 | render({ resetKeys: [1] }); 143 | 144 | shouldThrow = false; 145 | expect(container.textContent).toBe("Error"); 146 | 147 | render({ resetKeys: [2] }); 148 | expect(container.textContent).toBe("Content"); 149 | }); 150 | 151 | it("should render a null fallback if specified", () => { 152 | shouldThrow = true; 153 | act(() => { 154 | root.render( 155 | 156 | Content 157 | 158 | ); 159 | }); 160 | expect(container.textContent).toBe(""); 161 | }); 162 | }); 163 | 164 | describe('"FallbackComponent"', () => { 165 | let fallbackComponent: Mock<(props: FallbackProps) => ReactElement>; 166 | let lastRenderedError: any = null; 167 | let lastRenderedResetErrorBoundary: 168 | | FallbackProps["resetErrorBoundary"] 169 | | null = null; 170 | 171 | function render( 172 | props: Omit = {} 173 | ) { 174 | act(() => { 175 | root.render( 176 | 177 | Content 178 | 179 | ); 180 | }); 181 | } 182 | 183 | beforeEach(() => { 184 | lastRenderedError = null; 185 | lastRenderedResetErrorBoundary = null; 186 | 187 | fallbackComponent = vi.fn(); 188 | fallbackComponent.mockImplementation( 189 | ({ error, resetErrorBoundary }: FallbackProps) => { 190 | lastRenderedError = error; 191 | lastRenderedResetErrorBoundary = resetErrorBoundary; 192 | 193 | return
FallbackComponent
; 194 | } 195 | ); 196 | }); 197 | 198 | it("should render fallback in the event of an error", () => { 199 | shouldThrow = true; 200 | render(); 201 | expect(lastRenderedError.message).toBe("💥💥💥"); 202 | expect(container.textContent).toBe("FallbackComponent"); 203 | }); 204 | 205 | it("should re-render children if boundary is reset via prop", () => { 206 | shouldThrow = true; 207 | render(); 208 | expect(container.textContent).toBe("FallbackComponent"); 209 | 210 | expect(lastRenderedResetErrorBoundary).not.toBeNull(); 211 | act(() => { 212 | shouldThrow = false; 213 | assert(lastRenderedResetErrorBoundary !== null); 214 | lastRenderedResetErrorBoundary(); 215 | }); 216 | 217 | expect(container.textContent).toBe("Content"); 218 | }); 219 | 220 | it("should re-render children if boundary is reset reset keys", () => { 221 | shouldThrow = true; 222 | render({ resetKeys: [1] }); 223 | expect(container.textContent).toBe("FallbackComponent"); 224 | 225 | shouldThrow = false; 226 | render({ resetKeys: [2] }); 227 | expect(container.textContent).toBe("Content"); 228 | }); 229 | }); 230 | 231 | describe('"fallbackRender" render prop', () => { 232 | let lastRenderedError: any = null; 233 | let lastRenderedResetErrorBoundary: 234 | | FallbackProps["resetErrorBoundary"] 235 | | null = null; 236 | let fallbackRender: Mock<(props: FallbackProps) => ReactElement>; 237 | 238 | function render( 239 | props: Omit = {} 240 | ) { 241 | act(() => { 242 | root.render( 243 | 244 | Content 245 | 246 | ); 247 | }); 248 | } 249 | 250 | beforeEach(() => { 251 | lastRenderedError = null; 252 | lastRenderedResetErrorBoundary = null; 253 | 254 | fallbackRender = vi.fn(); 255 | fallbackRender.mockImplementation( 256 | ({ error, resetErrorBoundary }: FallbackProps) => { 257 | lastRenderedError = error; 258 | lastRenderedResetErrorBoundary = resetErrorBoundary; 259 | 260 | return
fallbackRender
; 261 | } 262 | ); 263 | }); 264 | 265 | it("should render fallback in the event of an error", () => { 266 | shouldThrow = true; 267 | render(); 268 | expect(lastRenderedError.message).toBe("💥💥💥"); 269 | expect(fallbackRender).toHaveBeenCalled(); 270 | expect(container.textContent).toBe("fallbackRender"); 271 | }); 272 | 273 | it("should re-render children if boundary is reset via prop", () => { 274 | shouldThrow = true; 275 | render(); 276 | expect(lastRenderedError.message).toBe("💥💥💥"); 277 | expect(fallbackRender).toHaveBeenCalled(); 278 | expect(container.textContent).toBe("fallbackRender"); 279 | 280 | act(() => { 281 | shouldThrow = false; 282 | assert(lastRenderedResetErrorBoundary !== null); 283 | lastRenderedResetErrorBoundary(); 284 | }); 285 | 286 | expect(container.textContent).toBe("Content"); 287 | }); 288 | 289 | it("should re-render children if boundary is reset reset keys", () => { 290 | shouldThrow = true; 291 | render({ resetKeys: [1] }); 292 | expect(lastRenderedError.message).toBe("💥💥💥"); 293 | expect(fallbackRender).toHaveBeenCalled(); 294 | expect(container.textContent).toBe("fallbackRender"); 295 | 296 | shouldThrow = false; 297 | render({ resetKeys: [2] }); 298 | expect(container.textContent).toBe("Content"); 299 | }); 300 | }); 301 | 302 | describe("thrown values", () => { 303 | let lastRenderedError: any = null; 304 | let fallbackRender: (props: FallbackProps) => ReactElement; 305 | let onError: Mock<(...args: any[]) => any>; 306 | 307 | beforeEach(() => { 308 | lastRenderedError = null; 309 | 310 | onError = vi.fn(); 311 | 312 | fallbackRender = ({ error }: FallbackProps) => { 313 | lastRenderedError = error; 314 | 315 | return
Error
; 316 | }; 317 | }); 318 | 319 | function render() { 320 | act(() => { 321 | root.render( 322 | 323 | Content 324 | 325 | ); 326 | }); 327 | } 328 | 329 | it("should support thrown strings", () => { 330 | shouldThrow = true; 331 | valueToThrow = "String error"; 332 | 333 | render(); 334 | 335 | expect(lastRenderedError).toBe("String error"); 336 | expect(onError).toHaveBeenCalledTimes(1); 337 | expect(onError.mock.calls[0][0]).toEqual("String error"); 338 | expect(container.textContent).toBe("Error"); 339 | }); 340 | 341 | it("should support thrown null or undefined values", () => { 342 | shouldThrow = true; 343 | valueToThrow = null; 344 | 345 | render(); 346 | 347 | expect(lastRenderedError).toBe(null); 348 | expect(onError).toHaveBeenCalledTimes(1); 349 | expect(onError.mock.calls[0][0]).toEqual(null); 350 | expect(container.textContent).toBe("Error"); 351 | }); 352 | }); 353 | 354 | // TODO Various cases with resetKeys changing (length, order, etc) 355 | // TODO Errors thrown again after reset are caught 356 | // TODO Nested error boundaries if a fallback throws 357 | }); 358 | -------------------------------------------------------------------------------- /src/ErrorBoundary.ts: -------------------------------------------------------------------------------- 1 | import { isDevelopment } from "#is-development"; 2 | import { Component, createElement, ErrorInfo } from "react"; 3 | import { ErrorBoundaryContext } from "./ErrorBoundaryContext"; 4 | import { ErrorBoundaryProps, FallbackProps } from "./types"; 5 | 6 | type ErrorBoundaryState = 7 | | { 8 | didCatch: true; 9 | error: any; 10 | } 11 | | { 12 | didCatch: false; 13 | error: null; 14 | }; 15 | 16 | const initialState: ErrorBoundaryState = { 17 | didCatch: false, 18 | error: null, 19 | }; 20 | 21 | export class ErrorBoundary extends Component< 22 | ErrorBoundaryProps, 23 | ErrorBoundaryState 24 | > { 25 | constructor(props: ErrorBoundaryProps) { 26 | super(props); 27 | 28 | this.resetErrorBoundary = this.resetErrorBoundary.bind(this); 29 | this.state = initialState; 30 | } 31 | 32 | static getDerivedStateFromError(error: Error) { 33 | return { didCatch: true, error }; 34 | } 35 | 36 | resetErrorBoundary(...args: any[]) { 37 | const { error } = this.state; 38 | 39 | if (error !== null) { 40 | this.props.onReset?.({ 41 | args, 42 | reason: "imperative-api", 43 | }); 44 | 45 | this.setState(initialState); 46 | } 47 | } 48 | 49 | componentDidCatch(error: Error, info: ErrorInfo) { 50 | this.props.onError?.(error, info); 51 | } 52 | 53 | componentDidUpdate( 54 | prevProps: ErrorBoundaryProps, 55 | prevState: ErrorBoundaryState 56 | ) { 57 | const { didCatch } = this.state; 58 | const { resetKeys } = this.props; 59 | 60 | // There's an edge case where if the thing that triggered the error happens to *also* be in the resetKeys array, 61 | // we'd end up resetting the error boundary immediately. 62 | // This would likely trigger a second error to be thrown. 63 | // So we make sure that we don't check the resetKeys on the first call of cDU after the error is set. 64 | 65 | if ( 66 | didCatch && 67 | prevState.error !== null && 68 | hasArrayChanged(prevProps.resetKeys, resetKeys) 69 | ) { 70 | this.props.onReset?.({ 71 | next: resetKeys, 72 | prev: prevProps.resetKeys, 73 | reason: "keys", 74 | }); 75 | 76 | this.setState(initialState); 77 | } 78 | } 79 | 80 | render() { 81 | const { children, fallbackRender, FallbackComponent, fallback } = 82 | this.props; 83 | const { didCatch, error } = this.state; 84 | 85 | let childToRender = children; 86 | 87 | if (didCatch) { 88 | const props: FallbackProps = { 89 | error, 90 | resetErrorBoundary: this.resetErrorBoundary, 91 | }; 92 | 93 | if (typeof fallbackRender === "function") { 94 | childToRender = fallbackRender(props); 95 | } else if (FallbackComponent) { 96 | childToRender = createElement(FallbackComponent, props); 97 | } else if (fallback !== undefined) { 98 | childToRender = fallback; 99 | } else { 100 | if (isDevelopment) { 101 | console.error( 102 | "react-error-boundary requires either a fallback, fallbackRender, or FallbackComponent prop" 103 | ); 104 | } 105 | 106 | throw error; 107 | } 108 | } 109 | 110 | return createElement( 111 | ErrorBoundaryContext.Provider, 112 | { 113 | value: { 114 | didCatch, 115 | error, 116 | resetErrorBoundary: this.resetErrorBoundary, 117 | }, 118 | }, 119 | childToRender 120 | ); 121 | } 122 | } 123 | 124 | function hasArrayChanged(a: any[] = [], b: any[] = []) { 125 | return ( 126 | a.length !== b.length || a.some((item, index) => !Object.is(item, b[index])) 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /src/ErrorBoundaryContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export type ErrorBoundaryContextType = { 4 | didCatch: boolean; 5 | error: any; 6 | resetErrorBoundary: (...args: any[]) => void; 7 | }; 8 | 9 | export const ErrorBoundaryContext = 10 | createContext(null); 11 | -------------------------------------------------------------------------------- /src/assertErrorBoundaryContext.ts: -------------------------------------------------------------------------------- 1 | import { ErrorBoundaryContextType } from "./ErrorBoundaryContext"; 2 | 3 | export function assertErrorBoundaryContext( 4 | value: any 5 | ): asserts value is ErrorBoundaryContextType { 6 | if ( 7 | value == null || 8 | typeof value.didCatch !== "boolean" || 9 | typeof value.resetErrorBoundary !== "function" 10 | ) { 11 | throw new Error("ErrorBoundaryContext not found"); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/env-conditions/development.ts: -------------------------------------------------------------------------------- 1 | export const isDevelopment = true; 2 | -------------------------------------------------------------------------------- /src/env-conditions/production.ts: -------------------------------------------------------------------------------- 1 | export const isDevelopment = false; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | export * from "./ErrorBoundary"; 3 | export * from "./ErrorBoundaryContext"; 4 | export * from "./useErrorBoundary"; 5 | export * from "./withErrorBoundary"; 6 | 7 | // TypeScript types 8 | export * from "./types"; 9 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType, ErrorInfo, PropsWithChildren, ReactNode } from "react"; 2 | 3 | export type FallbackProps = { 4 | error: any; 5 | resetErrorBoundary: (...args: any[]) => void; 6 | }; 7 | 8 | type ErrorBoundarySharedProps = PropsWithChildren<{ 9 | onError?: (error: Error, info: ErrorInfo) => void; 10 | onReset?: ( 11 | details: 12 | | { reason: "imperative-api"; args: any[] } 13 | | { reason: "keys"; prev: any[] | undefined; next: any[] | undefined } 14 | ) => void; 15 | resetKeys?: any[]; 16 | }>; 17 | 18 | export type ErrorBoundaryPropsWithComponent = ErrorBoundarySharedProps & { 19 | fallback?: never; 20 | FallbackComponent: ComponentType; 21 | fallbackRender?: never; 22 | }; 23 | 24 | export type ErrorBoundaryPropsWithRender = ErrorBoundarySharedProps & { 25 | fallback?: never; 26 | FallbackComponent?: never; 27 | fallbackRender: (props: FallbackProps) => ReactNode; 28 | }; 29 | 30 | export type ErrorBoundaryPropsWithFallback = ErrorBoundarySharedProps & { 31 | fallback: ReactNode; 32 | FallbackComponent?: never; 33 | fallbackRender?: never; 34 | }; 35 | 36 | export type ErrorBoundaryProps = 37 | | ErrorBoundaryPropsWithFallback 38 | | ErrorBoundaryPropsWithComponent 39 | | ErrorBoundaryPropsWithRender; 40 | -------------------------------------------------------------------------------- /src/useErrorBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, vi, it, expect } from "vitest"; 2 | import assert from "assert"; 3 | import { createRoot } from "react-dom/client"; 4 | import { act } from "react-dom/test-utils"; 5 | import { ErrorBoundary } from "./ErrorBoundary"; 6 | import { UseErrorBoundaryApi, useErrorBoundary } from "./useErrorBoundary"; 7 | 8 | describe("useErrorBoundary", () => { 9 | let container: HTMLDivElement; 10 | let lastRenderedUseErrorBoundaryApi: UseErrorBoundaryApi | null = null; 11 | 12 | beforeEach(() => { 13 | // @ts-expect-error This is a React internal 14 | global.IS_REACT_ACT_ENVIRONMENT = true; 15 | 16 | // Don't clutter the console with expected error text 17 | vi.spyOn(console, "error").mockImplementation(() => { 18 | // No-op 19 | }); 20 | 21 | container = document.createElement("div"); 22 | lastRenderedUseErrorBoundaryApi = null; 23 | }); 24 | 25 | function render() { 26 | function Child() { 27 | lastRenderedUseErrorBoundaryApi = useErrorBoundary(); 28 | 29 | return
Child
; 30 | } 31 | 32 | const root = createRoot(container); 33 | act(() => { 34 | root.render( 35 | Error}> 36 | 37 | 38 | ); 39 | }); 40 | } 41 | 42 | it("should activate an error boundary", () => { 43 | render(); 44 | expect(container.textContent).toBe("Child"); 45 | 46 | act(() => { 47 | lastRenderedUseErrorBoundaryApi?.showBoundary(new Error("Example")); 48 | }); 49 | expect(container.textContent).toBe("Error"); 50 | }); 51 | 52 | it("should reset an active error boundary", () => { 53 | render(); 54 | 55 | act(() => { 56 | lastRenderedUseErrorBoundaryApi?.showBoundary(new Error("Example")); 57 | }); 58 | expect(container.textContent).toBe("Error"); 59 | 60 | act(() => { 61 | lastRenderedUseErrorBoundaryApi?.resetBoundary(); 62 | }); 63 | expect(container.textContent).toBe("Child"); 64 | }); 65 | 66 | it("should work within a fallback component", () => { 67 | let resetBoundary: UseErrorBoundaryApi["resetBoundary"] | null = 68 | null; 69 | let showBoundary: UseErrorBoundaryApi["showBoundary"] | null = null; 70 | 71 | function FallbackComponent() { 72 | resetBoundary = useErrorBoundary().resetBoundary; 73 | return
Error
; 74 | } 75 | 76 | function Child() { 77 | showBoundary = useErrorBoundary().showBoundary; 78 | return
Child
; 79 | } 80 | 81 | const root = createRoot(container); 82 | act(() => { 83 | root.render( 84 | 85 | 86 | 87 | ); 88 | }); 89 | expect(container.textContent).toBe("Child"); 90 | 91 | act(() => { 92 | assert(showBoundary !== null); 93 | showBoundary(new Error("Example")); 94 | }); 95 | expect(container.textContent).toBe("Error"); 96 | 97 | act(() => { 98 | assert(resetBoundary !== null); 99 | resetBoundary(); 100 | }); 101 | expect(container.textContent).toBe("Child"); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/useErrorBoundary.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo, useState } from "react"; 2 | import { assertErrorBoundaryContext } from "./assertErrorBoundaryContext"; 3 | import { ErrorBoundaryContext } from "./ErrorBoundaryContext"; 4 | 5 | type UseErrorBoundaryState = 6 | | { error: TError; hasError: true } 7 | | { error: null; hasError: false }; 8 | 9 | export type UseErrorBoundaryApi = { 10 | resetBoundary: () => void; 11 | showBoundary: (error: TError) => void; 12 | }; 13 | 14 | export function useErrorBoundary(): UseErrorBoundaryApi { 15 | const context = useContext(ErrorBoundaryContext); 16 | 17 | assertErrorBoundaryContext(context); 18 | 19 | const [state, setState] = useState>({ 20 | error: null, 21 | hasError: false, 22 | }); 23 | 24 | const memoized = useMemo( 25 | () => ({ 26 | resetBoundary: () => { 27 | context.resetErrorBoundary(); 28 | setState({ error: null, hasError: false }); 29 | }, 30 | showBoundary: (error: TError) => 31 | setState({ 32 | error, 33 | hasError: true, 34 | }), 35 | }), 36 | [context.resetErrorBoundary] 37 | ); 38 | 39 | if (state.hasError) { 40 | throw state.error; 41 | } 42 | 43 | return memoized; 44 | } 45 | -------------------------------------------------------------------------------- /src/withErrorBoundary.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, vi, it, expect } from "vitest"; 2 | import { Component, createRef, forwardRef, PropsWithChildren } from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | import { act } from "react-dom/test-utils"; 5 | import { withErrorBoundary } from "./withErrorBoundary"; 6 | 7 | describe("withErrorBoundary", () => { 8 | let container: HTMLDivElement; 9 | let root: ReturnType; 10 | let shouldThrow = true; 11 | let valueToThrow: any; 12 | 13 | beforeEach(() => { 14 | // @ts-expect-error This is a React internal 15 | global.IS_REACT_ACT_ENVIRONMENT = true; 16 | 17 | // Don't clutter the console with expected error text 18 | vi.spyOn(console, "error").mockImplementation(() => { 19 | // No-op 20 | }); 21 | 22 | container = document.createElement("div"); 23 | root = createRoot(container); 24 | shouldThrow = false; 25 | valueToThrow = new Error("💥💥💥"); 26 | }); 27 | 28 | function MaybeThrows({ children = "Children" }: PropsWithChildren) { 29 | if (shouldThrow) { 30 | throw valueToThrow; 31 | } 32 | return children as any; 33 | } 34 | 35 | function render() { 36 | const ErrorBoundary = withErrorBoundary(MaybeThrows, { 37 | fallback:
Error
, 38 | }); 39 | 40 | act(() => { 41 | root.render(); 42 | }); 43 | } 44 | 45 | it("should render children within the created HOC", () => { 46 | render(); 47 | expect(container.textContent).toBe("Children"); 48 | }); 49 | 50 | it("should catch errors with the created HOC", () => { 51 | shouldThrow = true; 52 | render(); 53 | expect(container.textContent).toBe("Error"); 54 | }); 55 | 56 | it("should forward refs", () => { 57 | type Props = { foo: string }; 58 | 59 | class Inner extends Component { 60 | test() { 61 | // No-op 62 | } 63 | render() { 64 | return this.props.foo; 65 | } 66 | } 67 | 68 | const Wrapped = withErrorBoundary(Inner, { 69 | fallback:
Error
, 70 | }); 71 | 72 | const ref = createRef(); 73 | 74 | act(() => { 75 | root.render(); 76 | }); 77 | 78 | expect(ref.current).not.toBeNull(); 79 | expect(typeof ref.current?.test).toBe("function"); 80 | }); 81 | 82 | it("should forward dom refs", () => { 83 | type Props = { foo: string }; 84 | 85 | const Div = forwardRef((props, ref) => { 86 | return
{props.foo}
; 87 | }); 88 | Div.displayName = "Div"; 89 | 90 | const Wrapped = withErrorBoundary(Div, { 91 | fallback:
Error
, 92 | }); 93 | 94 | const ref = createRef(); 95 | 96 | act(() => { 97 | root.render(); 98 | }); 99 | 100 | expect(ref.current).not.toBeNull(); 101 | expect(ref.current).toBeInstanceOf(HTMLDivElement); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/withErrorBoundary.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createElement, 3 | forwardRef, 4 | RefAttributes, 5 | ForwardRefExoticComponent, 6 | PropsWithoutRef, 7 | ComponentType, 8 | ComponentRef, 9 | ComponentProps, 10 | } from "react"; 11 | import { ErrorBoundary } from "./ErrorBoundary"; 12 | import { ErrorBoundaryProps } from "./types"; 13 | 14 | export function withErrorBoundary>( 15 | component: T, 16 | errorBoundaryProps: ErrorBoundaryProps 17 | ): ForwardRefExoticComponent< 18 | PropsWithoutRef> & RefAttributes> 19 | > { 20 | const Wrapped = forwardRef, ComponentProps>((props, ref) => 21 | createElement( 22 | ErrorBoundary, 23 | errorBoundaryProps, 24 | createElement(component, { ...props, ref }) 25 | ) 26 | ); 27 | 28 | // Format for display in DevTools 29 | const name = component.displayName || component.name || "Unknown"; 30 | Wrapped.displayName = `withErrorBoundary(${name})`; 31 | 32 | return Wrapped; 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["DOM", "ESNext"], 5 | "module": "es2020", 6 | "moduleResolution": "bundler", 7 | "noImplicitAny": true, 8 | "jsx": "react-jsx", 9 | "resolveJsonModule": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "target": "es2018", 13 | "typeRoots": ["node_modules/@types", "definitions"] 14 | }, 15 | "exclude": ["node_modules"], 16 | "include": ["src", "eslint.config.mjs"] 17 | } 18 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, defaultExclude } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: [...defaultExclude, "**/*.trunk"], 6 | environment: "jsdom", // Use for browser-like tests 7 | coverage: { 8 | reporter: ["text", "json", "html"], // Optional: Add coverage reports 9 | }, 10 | }, 11 | resolve: { 12 | conditions: ["development", "browser"], 13 | }, 14 | }); 15 | --------------------------------------------------------------------------------