From 0be00e66dff5c74340419e7fa0dbb4a61050d1d7 Mon Sep 17 00:00:00 2001 From: Jordan Young Date: Mon, 19 Oct 2026 14:04:15 -0400 Subject: [PATCH] feat(mui-error-boundary): add ErrorBoundary component Adds @availity/mui-error-boundary, a class-based React error boundary that shows an Availity error page instead of a blank screen when a child throws while rendering. - fullPage prop for an app-level boundary; inline by default for smaller sections - boundaries nest: the closest one above the crash handles it - fallback prop accepts an element or a render function receiving { error, reset } - onError and onReset callbacks; resetKeys resets automatically on change - default page reuses EmptyState variant="Error" with Try again and Reload page actions - re-exported from @availity/element --- packages/element/package.json | 1 + packages/element/src/index.ts | 6 +- packages/error-boundary/CHANGELOG.md | 0 packages/error-boundary/README.md | 98 ++++++ packages/error-boundary/introduction.mdx | 9 + packages/error-boundary/package.json | 62 ++++ packages/error-boundary/project.json | 38 ++ packages/error-boundary/src/index.ts | 1 + .../src/lib/ErrorBoundary.stories.tsx | 134 +++++++ .../src/lib/ErrorBoundary.test.tsx | 326 ++++++++++++++++++ .../error-boundary/src/lib/ErrorBoundary.tsx | 146 ++++++++ packages/error-boundary/tsconfig.json | 5 + tsconfig.base.json | 1 + yarn.lock | 22 +- 14 files changed, 847 insertions(+), 2 deletions(-) create mode 100644 packages/error-boundary/CHANGELOG.md create mode 100644 packages/error-boundary/README.md create mode 100644 packages/error-boundary/introduction.mdx create mode 100644 packages/error-boundary/package.json create mode 100644 packages/error-boundary/project.json create mode 100644 packages/error-boundary/src/index.ts create mode 100644 packages/error-boundary/src/lib/ErrorBoundary.stories.tsx create mode 100644 packages/error-boundary/src/lib/ErrorBoundary.test.tsx create mode 100644 packages/error-boundary/src/lib/ErrorBoundary.tsx create mode 100644 packages/error-boundary/tsconfig.json diff --git a/packages/element/package.json b/packages/element/package.json index fad141edf8..0df9dd7e5b 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -40,6 +40,7 @@ "@availity/mui-divider": "workspace:*", "@availity/mui-drawer": "workspace:*", "@availity/mui-empty-state": "workspace:*", + "@availity/mui-error-boundary": "workspace:*", "@availity/mui-event-tracker": "workspace:*", "@availity/mui-favorites": "workspace:*", "@availity/mui-feedback": "workspace:*", diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index e9ae7087fd..fed7ce2524 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -141,6 +141,10 @@ export type { DrawerProps } from '@availity/mui-drawer'; export { EmptyState, EmptyStateImages, EmptyStateImage } from '@availity/mui-empty-state'; export type { EmptyStateProps, EmptyStateImageProps } from '@availity/mui-empty-state'; +// ErrorBoundary +export { ErrorBoundary } from '@availity/mui-error-boundary'; +export type { ErrorBoundaryProps, ErrorBoundaryFallbackProps } from '@availity/mui-error-boundary'; + // Event Tracker export { EventTrackerContext, useEventTracker, EventTrackerProvider } from '@availity/mui-event-tracker'; export type { EventTrackerProps } from '@availity/mui-event-tracker'; @@ -377,7 +381,7 @@ export { MoneyBillIcon, CartShoppingIcon, BuildingIcon, - HospitalIcon + HospitalIcon, } from '@availity/mui-icon'; // Layout diff --git a/packages/error-boundary/CHANGELOG.md b/packages/error-boundary/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/error-boundary/README.md b/packages/error-boundary/README.md new file mode 100644 index 0000000000..d08ef508a1 --- /dev/null +++ b/packages/error-boundary/README.md @@ -0,0 +1,98 @@ +# @availity/mui-error-boundary + +> Availity MUI ErrorBoundary component to be used with @availity/element design system. + +[![Version](https://img.shields.io/npm/v/@availity/mui-error-boundary.svg?style=for-the-badge)](https://www.npmjs.com/package/@availity/mui-error-boundary) +[![NPM Downloads](https://img.shields.io/npm/dt/@availity/mui-error-boundary.svg?style=for-the-badge)](https://www.npmjs.com/package/@availity/mui-error-boundary) +[![Dependency Status](https://img.shields.io/librariesio/release/npm/@availity/mui-error-boundary?style=for-the-badge)](https://github.com/Availity/element/blob/main/packages/error-boundary/package.json) + +## Documentation + +`ErrorBoundary` catches errors thrown while rendering its children and shows an Availity error page instead of a blank screen. It is built on React's [error boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) feature and the Element `EmptyState` component. + +Live demo and documentation in our [Storybook](https://availity.github.io/element/?path=/docs/components-error-boundary-introduction--docs) + +Availity standards for design and usage can be found in the [Availity Design Guide](https://design.availity.com/2e36e50c7) + +## Installation + +### Import Through @availity/element (Recommended) + +#### NPM + +```bash +npm install @availity/element +``` + +#### Yarn + +```bash +yarn add @availity/element +``` + +### Direct Import + +#### NPM + +_This package has a few peer dependencies. Add `@mui/material` & `@emotion/react` to your project if not already installed._ + +```bash +npm install @availity/mui-error-boundary +``` + +#### Yarn + +```bash +yarn add @availity/mui-error-boundary +``` + +### Usage + +#### Import through @availity/element + +```tsx +import { ErrorBoundary } from '@availity/element'; +``` + +#### Direct import + +```tsx +import { ErrorBoundary } from '@availity/mui-error-boundary'; +``` + +#### Wrap your whole app + +Put one boundary inside your app container and use `fullPage` so the error page fills the screen. + +```tsx + + logError(error, info.componentStack)}> + + + +``` + +#### Wrap smaller sections + +A crash is handled by the closest boundary above it, so wrapping a section keeps the rest of the app running. + +```tsx + + + +``` + +#### Props + +| Prop | Description | +| ----------- | --------------------------------------------------------------------------------------------------------- | +| `fullPage` | Fill the screen (`min-height: 100vh`) and use an `h1` heading. Default is inline. | +| `fallback` | An element, or `({ error, reset }) => node`, to use instead of the default error page. | +| `onError` | `(error, info) => void`. Called once per caught error. Use it to log to your monitoring tool. | +| `onReset` | Called when the boundary resets, from "Try again" or a `resetKeys` change. | +| `resetKeys` | Array of values. When one changes while an error is showing, the boundary resets. Pass the current route. | +| `sx` | Styles for the default error page container. | + +#### What it does not catch + +Error boundaries only catch errors thrown **while rendering**. They do not catch errors in event handlers, asynchronous code (promises, `setTimeout`), or errors thrown by the boundary itself. Handle those where they happen. diff --git a/packages/error-boundary/introduction.mdx b/packages/error-boundary/introduction.mdx new file mode 100644 index 0000000000..028d660ec2 --- /dev/null +++ b/packages/error-boundary/introduction.mdx @@ -0,0 +1,9 @@ +import { Markdown, Meta } from '@storybook/addon-docs/blocks'; +import ReadMe from './README.md?raw'; +import CHANGELOG from './CHANGELOG.md?raw'; + + + +{ReadMe} + +{CHANGELOG} diff --git a/packages/error-boundary/package.json b/packages/error-boundary/package.json new file mode 100644 index 0000000000..ffe6c8bf62 --- /dev/null +++ b/packages/error-boundary/package.json @@ -0,0 +1,62 @@ +{ + "name": "@availity/mui-error-boundary", + "version": "0.0.0", + "description": "Availity MUI ErrorBoundary Component - part of the @availity/element design system", + "keywords": [ + "react", + "typescript", + "availity", + "mui" + ], + "homepage": "https://availity.github.io/element/?path=/docs/components-error-boundary-introduction--docs", + "bugs": { + "url": "https://github.com/Availity/element/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/Availity/element.git", + "directory": "packages/error-boundary" + }, + "license": "MIT", + "author": "Availity Developers ", + "browser": "./dist/index.js", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts", + "dev": "tsup src/index.ts --format esm --watch --dts", + "clean": "rm -rf dist", + "clean:nm": "rm -rf node_modules", + "publish": "yarn npm publish --tolerate-republish --access public", + "publish:canary": "yarn npm publish --access public --tag canary" + }, + "dependencies": { + "@availity/mui-button": "workspace:^", + "@availity/mui-empty-state": "workspace:^", + "@availity/mui-layout": "workspace:^", + "@availity/mui-typography": "workspace:^" + }, + "devDependencies": { + "@mui/material": "^7.3.11", + "react": "19.2.7", + "react-dom": "19.2.7", + "tsup": "^8.5.1", + "typescript": "^6.0.0" + }, + "peerDependencies": { + "@mui/material": "^7.0.0", + "react": ">=17.0.0" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "type": "module" +} diff --git a/packages/error-boundary/project.json b/packages/error-boundary/project.json new file mode 100644 index 0000000000..891ea6bd9d --- /dev/null +++ b/packages/error-boundary/project.json @@ -0,0 +1,38 @@ +{ + "name": "mui-error-boundary", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/error-boundary/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "silent": false, + "fix": false, + "cache": true, + "cacheLocation": "./node_modules/.cache/error-boundary/.eslintcache", + "maxWarnings": -1, + "quiet": false, + "hasTypeAwareRules": true, + "cacheStrategy": "metadata" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "vitest run packages/error-boundary" + } + }, + "version": { + "executor": "@jscutlery/semver:version", + "options": { + "preset": "conventional", + "commitMessageFormat": "chore({projectName}): release version ${version} [skip ci]", + "tagPrefix": "@availity/{projectName}@", + "trackDeps": true, + "skipCommitTypes": ["docs"] + } + } + } +} diff --git a/packages/error-boundary/src/index.ts b/packages/error-boundary/src/index.ts new file mode 100644 index 0000000000..1e56a69675 --- /dev/null +++ b/packages/error-boundary/src/index.ts @@ -0,0 +1 @@ +export * from './lib/ErrorBoundary'; diff --git a/packages/error-boundary/src/lib/ErrorBoundary.stories.tsx b/packages/error-boundary/src/lib/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..ff43d6da16 --- /dev/null +++ b/packages/error-boundary/src/lib/ErrorBoundary.stories.tsx @@ -0,0 +1,134 @@ +// Each exported component in the package should have its own stories file + +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Box } from '@availity/mui-layout'; +import { Button } from '@availity/mui-button'; +import { Typography } from '@availity/mui-typography'; +import { ErrorBoundary, ErrorBoundaryProps } from './ErrorBoundary'; + +/** Throws while rendering when `shouldThrow` is true, simulating a crash inside the app. */ +const Crasher = ({ shouldThrow }: { shouldThrow: boolean }) => { + if (shouldThrow) { + throw new Error('Simulated render error'); + } + return Everything is working.; +}; + +/** Wraps a section of the app and shows an error page if something inside it crashes while rendering, + * instead of leaving the user with a blank screen. + * + * - Put one boundary around your whole app and use `fullPage` so the error page fills the screen. + * - Put additional boundaries around smaller sections (a widget, a table, a modal) so a crash there does not take down the rest of the page. The closest boundary above the crash handles it. + * - Use `onError` to send the error to your logging or monitoring tool. + * - Use `resetKeys` (for example, the current route) so navigating away from a broken page recovers automatically. + * + * _Error boundaries only catch errors thrown while rendering. They do not catch errors in event handlers or in asynchronous code like promises and timers._ + */ +const meta: Meta = { + title: 'Components/ErrorBoundary/ErrorBoundary', + component: ErrorBoundary, + tags: ['autodocs'], +}; + +export default meta; + +export const _ErrorBoundary: StoryObj = { + render: (args: ErrorBoundaryProps) => ( + + + + ), +}; + +/** Use `fullPage` on the boundary that wraps your whole app. The error page fills the screen and the heading is an `h1`. */ +export const _FullPage: StoryObj = { + render: (args: ErrorBoundaryProps) => ( + + + + ), + parameters: { + layout: 'fullscreen', + }, +}; + +/** "Try again" clears the error and renders the children again. Here the crash is only simulated once, so the retry succeeds. */ +export const _TryAgain: StoryObj = { + render: (args: ErrorBoundaryProps) => { + const Example = () => { + const [shouldThrow, setShouldThrow] = useState(true); + return ( + setShouldThrow(false)}> + + + ); + }; + return ; + }, +}; + +/** Wrap smaller sections so a crash stays contained. The header and the working widget below keep running while the broken widget shows its own error page. */ +export const _Nested: StoryObj = { + render: (args: ErrorBoundaryProps) => ( + + + + Dashboard + + + + + + + + + + + + ), +}; + +/** `fallback` accepts an element, or a function that receives the `error` and a `reset` function. */ +export const _CustomFallback: StoryObj = { + render: (args: ErrorBoundaryProps) => ( + ( + + This widget could not load ({error.message}). + + + )} + > + + + ), +}; + +/** Pass `resetKeys` something that changes when the user moves on, like the current route. When a value changes, the boundary resets itself. */ +export const _ResetKeys: StoryObj = { + render: (args: ErrorBoundaryProps) => { + const Example = () => { + const [page, setPage] = useState<'broken' | 'working'>('broken'); + return ( + + + + + + + + + + ); + }; + return ; + }, +}; diff --git a/packages/error-boundary/src/lib/ErrorBoundary.test.tsx b/packages/error-boundary/src/lib/ErrorBoundary.test.tsx new file mode 100644 index 0000000000..489919456a --- /dev/null +++ b/packages/error-boundary/src/lib/ErrorBoundary.test.tsx @@ -0,0 +1,326 @@ +import { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ErrorBoundary } from './ErrorBoundary'; + +const Bomb = ({ message = 'Boom' }: { message?: string }): React.JSX.Element => { + throw new Error(message); +}; + +/** jsdom resolves viewport units to pixels, so compare against the resolved value instead of the "vh" string */ +const vh = (percent: number) => `${(window.innerHeight * percent) / 100}px`; + +/** Throws only while `shouldThrow.current` is true, so a test can "fix" the problem before retrying. */ +const shouldThrow = { current: true }; +const Flaky = (): React.JSX.Element => { + if (shouldThrow.current) throw new Error('Flaky failure'); + return
Recovered content
; +}; + +describe('ErrorBoundary', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + shouldThrow.current = true; + // React logs caught errors to console.error; keep test output readable + consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + test('should render children when nothing throws', () => { + render(Test); + expect(screen.getByText('Test')).toBeTruthy(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + describe('default fallback', () => { + test('should render the default error page when a child throws', () => { + render( + + + + ); + + expect(screen.getByRole('alert')).toBeTruthy(); + expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeTruthy(); + expect(screen.getByText(/1\.800\.AVAILITY/)).toBeTruthy(); + expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /reload page/i })).toBeTruthy(); + }); + + test('should not expose the raw error message to the user', () => { + render( + + + + ); + + expect(screen.queryByText(/secret internal detail/)).toBeNull(); + }); + + test('should move focus to the fallback so keyboard users are not left on a removed element', () => { + render( + + + + ); + + expect(screen.getByRole('alert')).toHaveFocus(); + }); + + test('should reload the page when "Reload page" is clicked', async () => { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + + render( + + + + ); + await userEvent.click(screen.getByRole('button', { name: /reload page/i })); + + expect(reload).toHaveBeenCalledTimes(1); + vi.unstubAllGlobals(); + }); + }); + + describe('layout', () => { + test('should be inline by default', () => { + render( + + + + ); + + expect(getComputedStyle(screen.getByRole('alert')).minHeight).not.toBe(vh(100)); + }); + + test('should fill the page when fullPage is set', () => { + render( + + + + ); + + expect(getComputedStyle(screen.getByRole('alert')).minHeight).toBe(vh(100)); + }); + + test('should allow sx to override the layout', () => { + render( + + + + ); + + expect(getComputedStyle(screen.getByRole('alert')).minHeight).toBe(vh(50)); + }); + }); + + describe('custom fallback', () => { + test('should render a fallback element in place of the default', () => { + render( + Custom fallback}> + + + ); + + expect(screen.getByText('Custom fallback')).toBeTruthy(); + expect(screen.queryByRole('heading', { name: /something went wrong/i })).toBeNull(); + }); + + test('should call a fallback render function with the error and a reset function', async () => { + render( + ( +
+ Failed: {error.message} + +
+ )} + > + +
+ ); + + expect(screen.getByText('Failed: Flaky failure')).toBeTruthy(); + + shouldThrow.current = false; + await userEvent.click(screen.getByRole('button', { name: 'Retry now' })); + + expect(screen.getByText('Recovered content')).toBeTruthy(); + }); + }); + + describe('onError', () => { + test('should be called once with the error and component stack', () => { + const onError = vi.fn(); + + render( + + + + ); + + expect(onError).toHaveBeenCalledTimes(1); + const [error, info] = onError.mock.calls[0]; + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('reported'); + expect(info.componentStack).toContain('Bomb'); + }); + + test('should not be called when nothing throws', () => { + const onError = vi.fn(); + render(Fine); + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe('reset', () => { + test('should re-render children when "Try again" is clicked', async () => { + render( + + + + ); + expect(screen.getByRole('alert')).toBeTruthy(); + + shouldThrow.current = false; + await userEvent.click(screen.getByRole('button', { name: /try again/i })); + + expect(screen.getByText('Recovered content')).toBeTruthy(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + test('should show the fallback again if the retry throws again', async () => { + render( + + + + ); + + await userEvent.click(screen.getByRole('button', { name: /try again/i })); + + expect(screen.getByRole('alert')).toBeTruthy(); + }); + + test('should call onReset when "Try again" is clicked', async () => { + const onReset = vi.fn(); + render( + + + + ); + + await userEvent.click(screen.getByRole('button', { name: /try again/i })); + + expect(onReset).toHaveBeenCalledTimes(1); + }); + + test('should reset automatically when a resetKeys value changes', async () => { + const Harness = () => { + const [route, setRoute] = useState('a'); + return ( + <> + + {route === 'a' ? :
Page B
}
+ + ); + }; + + render(); + expect(screen.getByRole('alert')).toBeTruthy(); + + await userEvent.click(screen.getByRole('button', { name: 'Navigate' })); + + expect(screen.getByText('Page B')).toBeTruthy(); + }); + + test('should stay in the error state when re-rendered with the same resetKeys', () => { + const { rerender } = render( + + + + ); + + rerender( + + + + ); + + expect(screen.getByRole('alert')).toBeTruthy(); + }); + + test('should not retry endlessly when resetKeys change but the child still throws', () => { + const onError = vi.fn(); + const { rerender } = render( + + + + ); + + rerender( + + + + ); + + expect(screen.getByRole('alert')).toBeTruthy(); + expect(onError).toHaveBeenCalledTimes(2); + }); + }); + + describe('nesting', () => { + test('should contain a crash to the nearest boundary', () => { + render( + +
App shell
+ Widget unavailable}> + + +
+ ); + + expect(screen.getByText('App shell')).toBeTruthy(); + expect(screen.getByText('Widget unavailable')).toBeTruthy(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + test('should leave siblings outside the boundary untouched', () => { + render( + <> +
Sibling
+ + + + + ); + + expect(screen.getByText('Sibling')).toBeTruthy(); + expect(screen.getByRole('alert')).toBeTruthy(); + }); + + test('should bubble to the parent boundary when the fallback itself throws', () => { + render( + Outer fallback}> + { + throw new Error('fallback exploded'); + }} + > + + + + ); + + expect(screen.getByText('Outer fallback')).toBeTruthy(); + }); + }); +}); diff --git a/packages/error-boundary/src/lib/ErrorBoundary.tsx b/packages/error-boundary/src/lib/ErrorBoundary.tsx new file mode 100644 index 0000000000..54f1608d27 --- /dev/null +++ b/packages/error-boundary/src/lib/ErrorBoundary.tsx @@ -0,0 +1,146 @@ +import { Component, useEffect, useRef } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { Box } from '@availity/mui-layout'; +import { Button } from '@availity/mui-button'; +import { EmptyState } from '@availity/mui-empty-state'; +import { Typography } from '@availity/mui-typography'; + +export interface ErrorBoundaryFallbackProps { + /** The error that was thrown while rendering the children. */ + error: Error; + /** Clears the error and re-renders the children. */ + reset: () => void; +} + +export interface ErrorBoundaryProps { + /** The part of the app this boundary protects. */ + children?: ReactNode; + /** Replaces the default error page. Pass an element, or a function that receives the `error` and a `reset` function. */ + fallback?: ReactNode | ((props: ErrorBoundaryFallbackProps) => ReactNode); + /** Called once each time the boundary catches an error. Use it to log to your error reporting tool. */ + onError?: (error: Error, info: ErrorInfo) => void; + /** Called when the boundary is reset, whether from "Try again" or from a `resetKeys` change. */ + onReset?: () => void; + /** When any value in this array changes while the boundary is showing an error, the boundary resets. + * Pass something that changes on navigation, like the current route, so moving to another page recovers. */ + resetKeys?: unknown[]; + /** Fill the whole page (at least `100vh`, content centered) instead of sizing to the content. + * Use this for the boundary that wraps your entire app. + * @default false */ + fullPage?: boolean; + /** Styles applied to the default error page container. Not used when you provide your own `fallback`. */ + sx?: SxProps; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +const initialState: ErrorBoundaryState = { hasError: false, error: null }; + +const hasChanged = (a: unknown[] = [], b: unknown[] = []) => + a.length !== b.length || a.some((item, index) => !Object.is(item, b[index])); + +interface DefaultFallbackProps { + reset: () => void; + fullPage: boolean; + sx?: SxProps; +} + +const DefaultFallback = ({ reset, fullPage, sx }: DefaultFallbackProps) => { + const containerRef = useRef(null); + + useEffect(() => { + // The content that had focus was just removed, so move focus to the error message for keyboard and screen reader users + containerRef.current?.focus(); + }, []); + + return ( + + + + Something went wrong + + + Oops! Something went wrong. Try your request again later. If the problem continues, contact Availity Client + Services at 1.800.AVAILITY (282.4548). + + + + + + ); +}; + +/** Catches errors thrown while rendering its children and shows an error page instead of a blank screen. + * + * Boundaries can be nested: an error is handled by the closest boundary above the component that threw, so wrapping + * a small section keeps the rest of the app running. + * + * Error boundaries do not catch errors in event handlers, asynchronous code (promises, `setTimeout`), or the boundary itself. + */ +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = initialState; + } + + static getDerivedStateFromError(thrown: unknown): ErrorBoundaryState { + // Anything can be thrown in JavaScript, so normalize to an Error + return { hasError: true, error: thrown instanceof Error ? thrown : new Error(String(thrown)) }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + this.props.onError?.(error, info); + } + + componentDidUpdate(prevProps: ErrorBoundaryProps, prevState: ErrorBoundaryState) { + // Only auto-reset if we were already showing an error before this update. Otherwise the render that just + // caught the error would immediately reset itself. + if (this.state.hasError && prevState.hasError && hasChanged(prevProps.resetKeys, this.props.resetKeys)) { + this.reset(); + } + } + + reset = () => { + this.props.onReset?.(); + this.setState(initialState); + }; + + render() { + const { hasError, error } = this.state; + const { children, fallback, fullPage = false, sx } = this.props; + + if (!hasError) return children; + + if (typeof fallback === 'function') { + return fallback({ error: error as Error, reset: this.reset }); + } + + if (fallback !== undefined) return fallback; + + return ; + } +} diff --git a/packages/error-boundary/tsconfig.json b/packages/error-boundary/tsconfig.json new file mode 100644 index 0000000000..fcaa546418 --- /dev/null +++ b/packages/error-boundary/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["."], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 0c1f348079..757ff8c5fe 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -46,6 +46,7 @@ "@availity/mui-divider": ["packages/divider/src/index.ts"], "@availity/mui-drawer": ["packages/drawer/src/index.ts"], "@availity/mui-empty-state": ["packages/empty-state/src/index.ts"], + "@availity/mui-error-boundary": ["packages/error-boundary/src/index.ts"], "@availity/mui-event-tracker": ["packages/event-tracker/src/index.ts"], "@availity/mui-favorites": ["packages/favorites/src/index.ts"], "@availity/mui-feedback": ["packages/feedback/src/index.ts"], diff --git a/yarn.lock b/yarn.lock index d5c9f8309d..70d555892b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -135,6 +135,7 @@ __metadata: "@availity/mui-divider": "workspace:*" "@availity/mui-drawer": "workspace:*" "@availity/mui-empty-state": "workspace:*" + "@availity/mui-error-boundary": "workspace:*" "@availity/mui-event-tracker": "workspace:*" "@availity/mui-favorites": "workspace:*" "@availity/mui-feedback": "workspace:*" @@ -604,7 +605,7 @@ __metadata: languageName: unknown linkType: soft -"@availity/mui-empty-state@workspace:*, @availity/mui-empty-state@workspace:packages/empty-state": +"@availity/mui-empty-state@workspace:*, @availity/mui-empty-state@workspace:^, @availity/mui-empty-state@workspace:packages/empty-state": version: 0.0.0-use.local resolution: "@availity/mui-empty-state@workspace:packages/empty-state" dependencies: @@ -621,6 +622,25 @@ __metadata: languageName: unknown linkType: soft +"@availity/mui-error-boundary@workspace:*, @availity/mui-error-boundary@workspace:packages/error-boundary": + version: 0.0.0-use.local + resolution: "@availity/mui-error-boundary@workspace:packages/error-boundary" + dependencies: + "@availity/mui-button": "workspace:^" + "@availity/mui-empty-state": "workspace:^" + "@availity/mui-layout": "workspace:^" + "@availity/mui-typography": "workspace:^" + "@mui/material": "npm:^7.3.11" + react: "npm:19.2.7" + react-dom: "npm:19.2.7" + tsup: "npm:^8.5.1" + typescript: "npm:^6.0.0" + peerDependencies: + "@mui/material": ^7.0.0 + react: ">=17.0.0" + languageName: unknown + linkType: soft + "@availity/mui-event-tracker@workspace:*, @availity/mui-event-tracker@workspace:packages/event-tracker": version: 0.0.0-use.local resolution: "@availity/mui-event-tracker@workspace:packages/event-tracker"