Skip to content

Commit 3ac8da2

Browse files
feat: implement comprehensive React error boundary framework
Add a reusable ErrorBoundary class component with getDerivedStateFromError and componentDidCatch, dynamic fallback injection, a reset mechanism, and automatic route-based reset via Next.js Router events. Add accessible fallback UIs: a full-page GlobalErrorFallback (icon, explanation, Try Again, home link) and a lightweight ContextualErrorFallback card for widgets/sidebars, both WCAG 2.1 AA keyboard/screen-reader friendly. Wire boundaries as a global safety net in pages/_app.tsx and around content containers in Layout, DocsLayout, and BlogLayout so navigation and sidebars stay functional when page content crashes. Includes Storybook stories. Closes #5559 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 71e7892 commit 3ac8da2

9 files changed

Lines changed: 410 additions & 9 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import React from 'react';
2+
3+
import type { ErrorFallbackProps } from '@/types/components/error/ErrorBoundaryProps';
4+
5+
import IconExclamation from '../icons/Exclamation';
6+
7+
type IContextualErrorFallbackProps = ErrorFallbackProps & {
8+
// Optional short label describing which widget failed, e.g. "sidebar".
9+
label?: string;
10+
};
11+
12+
/**
13+
* @description Lightweight, inline fallback for widgets, sidebars, and other
14+
* non-critical sections. It fails gracefully without disrupting the parent
15+
* page and lets the user retry just the affected region.
16+
* @param {IContextualErrorFallbackProps} props - The error, reset handler and label.
17+
*/
18+
export default function ContextualErrorFallback({
19+
reset,
20+
label = 'section'
21+
}: IContextualErrorFallbackProps): React.JSX.Element {
22+
return (
23+
<div
24+
role='alert'
25+
aria-live='polite'
26+
data-testid='ContextualErrorFallback'
27+
className='flex flex-col gap-2 rounded-md border border-yellow-300 bg-yellow-50 p-4 text-left
28+
dark:border-yellow-700/60 dark:bg-yellow-900/20'
29+
>
30+
<div className='flex items-start gap-2'>
31+
<IconExclamation className='mt-0.5 size-5 shrink-0 text-yellow-500 dark:text-yellow-400' aria-hidden='true' />
32+
<div>
33+
<p className='font-sans text-sm font-medium text-yellow-800 antialiased dark:text-yellow-200'>
34+
This {label} could not be displayed.
35+
</p>
36+
<p className='mt-1 font-sans text-sm text-yellow-700 antialiased dark:text-yellow-300/80'>
37+
The rest of the page is still available. You can try loading it again.
38+
</p>
39+
</div>
40+
</div>
41+
<div>
42+
<button
43+
type='button'
44+
onClick={reset}
45+
data-testid='ContextualErrorFallback-retry'
46+
className='rounded-sm font-sans text-sm font-semibold text-yellow-800 underline underline-offset-2
47+
transition-colors hover:text-yellow-900 focus:outline-none focus-visible:ring-2
48+
focus-visible:ring-yellow-500 focus-visible:ring-offset-1 dark:text-yellow-200 dark:hover:text-yellow-100'
49+
>
50+
Try again
51+
</button>
52+
</div>
53+
</div>
54+
);
55+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import React from 'react';
3+
4+
import ContextualErrorFallback from './ContextualErrorFallback';
5+
import ErrorBoundary from './ErrorBoundary';
6+
import GlobalErrorFallback from './GlobalErrorFallback';
7+
8+
/**
9+
* @description A helper component that always throws so the boundary renders
10+
* its fallback inside Storybook.
11+
*/
12+
function Bomb(): React.JSX.Element {
13+
throw new Error('💥 Simulated rendering crash for demonstration purposes.');
14+
}
15+
16+
const meta: Meta<typeof ErrorBoundary> = {
17+
title: 'Components/ErrorBoundary',
18+
component: ErrorBoundary
19+
};
20+
21+
export default meta;
22+
23+
type Story = StoryObj<typeof ErrorBoundary>;
24+
25+
export const DefaultGlobalFallback: Story = {
26+
render: () => (
27+
<ErrorBoundary resetOnRouteChange={false}>
28+
<Bomb />
29+
</ErrorBoundary>
30+
)
31+
};
32+
33+
export const ContextualFallback: Story = {
34+
render: () => (
35+
<ErrorBoundary
36+
resetOnRouteChange={false}
37+
fallback={({ reset }) => <ContextualErrorFallback error={null} reset={reset} label='widget' />}
38+
>
39+
<Bomb />
40+
</ErrorBoundary>
41+
)
42+
};
43+
44+
export const CustomRenderFallback: Story = {
45+
render: () => (
46+
<ErrorBoundary
47+
resetOnRouteChange={false}
48+
fallback={({ error, reset }) => (
49+
<div className='rounded-md border border-red-300 bg-red-50 p-4'>
50+
<p className='font-sans text-sm text-red-800'>Custom fallback: {error?.message}</p>
51+
<button type='button' onClick={reset} className='mt-2 text-sm font-semibold text-red-700 underline'>
52+
Retry
53+
</button>
54+
</div>
55+
)}
56+
>
57+
<Bomb />
58+
</ErrorBoundary>
59+
)
60+
};
61+
62+
export const HealthyChildren: Story = {
63+
render: () => (
64+
<ErrorBoundary resetOnRouteChange={false}>
65+
<p className='font-sans text-sm text-gray-700'>Everything rendered fine — no fallback shown.</p>
66+
</ErrorBoundary>
67+
)
68+
};
69+
70+
export const GlobalFallbackStandalone: Story = {
71+
render: () => <GlobalErrorFallback error={new Error('Example error message')} reset={() => {}} />
72+
};
73+
74+
export const ContextualFallbackStandalone: Story = {
75+
render: () => <ContextualErrorFallback error={new Error('Example error message')} reset={() => {}} label='sidebar' />
76+
};

components/error/ErrorBoundary.tsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import Router from 'next/router';
2+
import type { ErrorInfo } from 'react';
3+
import React, { Component } from 'react';
4+
5+
import type {
6+
ErrorBoundaryProps,
7+
ErrorBoundaryState,
8+
ErrorFallbackProps
9+
} from '@/types/components/error/ErrorBoundaryProps';
10+
11+
import GlobalErrorFallback from './GlobalErrorFallback';
12+
13+
/**
14+
* @description Determine whether the ordered list of reset keys has changed
15+
* between two renders. A change triggers an automatic reset of the boundary.
16+
* @param {unknown[]} previous - The previous reset keys.
17+
* @param {unknown[]} next - The next reset keys.
18+
*/
19+
function haveResetKeysChanged(previous: unknown[] = [], next: unknown[] = []): boolean {
20+
if (previous.length !== next.length) return true;
21+
22+
return previous.some((key, index) => !Object.is(key, next[index]));
23+
}
24+
25+
/**
26+
* @description A reusable React error boundary that isolates rendering failures
27+
* in its subtree, keeps the surrounding layout shell interactive, and renders
28+
* an accessible recovery UI. It supports custom fallbacks, imperative resets via
29+
* `resetKeys`, and automatic recovery on route changes.
30+
*/
31+
export default class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
32+
constructor(props: ErrorBoundaryProps) {
33+
super(props);
34+
this.state = { hasError: false, error: null, errorInfo: null };
35+
this.reset = this.reset.bind(this);
36+
}
37+
38+
/**
39+
* @description Update state so the next render shows the fallback UI.
40+
* @param {Error} error - The error thrown by a descendant component.
41+
*/
42+
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
43+
return { hasError: true, error };
44+
}
45+
46+
componentDidMount(): void {
47+
const { resetOnRouteChange = true } = this.props;
48+
49+
if (resetOnRouteChange) {
50+
Router.events.on('routeChangeComplete', this.reset);
51+
}
52+
}
53+
54+
/**
55+
* @description Log the caught error and forward it to the optional handler.
56+
* @param {Error} error - The error thrown by a descendant component.
57+
* @param {ErrorInfo} errorInfo - React error info with the component stack.
58+
*/
59+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
60+
const { onError } = this.props;
61+
62+
this.setState({ errorInfo });
63+
onError?.(error, errorInfo);
64+
65+
// eslint-disable-next-line no-console
66+
console.error('ErrorBoundary caught an error:', error, errorInfo);
67+
}
68+
69+
componentDidUpdate(prevProps: ErrorBoundaryProps): void {
70+
const { hasError } = this.state;
71+
const { resetKeys } = this.props;
72+
73+
if (hasError && haveResetKeysChanged(prevProps.resetKeys, resetKeys)) {
74+
this.reset();
75+
}
76+
}
77+
78+
componentWillUnmount(): void {
79+
const { resetOnRouteChange = true } = this.props;
80+
81+
if (resetOnRouteChange) {
82+
Router.events.off('routeChangeComplete', this.reset);
83+
}
84+
}
85+
86+
/**
87+
* @description Clear the error state so the protected subtree re-mounts.
88+
*/
89+
reset(): void {
90+
const { hasError } = this.state;
91+
const { onReset } = this.props;
92+
93+
if (!hasError) return;
94+
95+
this.setState({ hasError: false, error: null, errorInfo: null });
96+
onReset?.();
97+
}
98+
99+
render(): React.ReactNode {
100+
const { hasError, error, errorInfo } = this.state;
101+
const { children, fallback } = this.props;
102+
103+
if (!hasError) return children;
104+
105+
const fallbackProps: ErrorFallbackProps = { error, errorInfo, reset: this.reset };
106+
107+
if (typeof fallback === 'function') {
108+
return fallback(fallbackProps);
109+
}
110+
111+
if (fallback !== undefined && fallback !== null) {
112+
return fallback;
113+
}
114+
115+
return <GlobalErrorFallback {...fallbackProps} />;
116+
}
117+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import Link from 'next/link';
2+
import React from 'react';
3+
4+
import type { ErrorFallbackProps } from '@/types/components/error/ErrorBoundaryProps';
5+
6+
import IconExclamation from '../icons/Exclamation';
7+
import IconHome from '../icons/Home';
8+
9+
/**
10+
* @description Full-page fallback rendered by the global ErrorBoundary when an
11+
* unrecoverable rendering error escapes the page content. It preserves the
12+
* layout shell around it and offers accessible recovery actions.
13+
* @param {ErrorFallbackProps} props - The error and the reset handler.
14+
*/
15+
export default function GlobalErrorFallback({ error, reset }: ErrorFallbackProps): React.JSX.Element {
16+
const isDevelopment = process.env.NODE_ENV === 'development';
17+
18+
return (
19+
<div
20+
role='alert'
21+
aria-live='assertive'
22+
data-testid='GlobalErrorFallback'
23+
className='flex min-h-[60vh] w-full items-center justify-center bg-white px-4 py-16 dark:bg-dark-background'
24+
>
25+
<div className='w-full max-w-xl text-center'>
26+
<div className='mx-auto flex size-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30'>
27+
<IconExclamation className='size-8 text-red-600 dark:text-red-400' aria-hidden='true' />
28+
</div>
29+
<h1 className='mt-6 font-sans text-2xl font-bold text-gray-900 antialiased dark:text-dark-heading'>
30+
Something went wrong
31+
</h1>
32+
<p className='mt-3 font-sans text-base text-gray-600 antialiased dark:text-dark-text'>
33+
An unexpected error interrupted this page. The rest of the site is still available, so you can retry or head
34+
back to the homepage.
35+
</p>
36+
37+
{isDevelopment && error?.message && (
38+
<pre
39+
data-testid='GlobalErrorFallback-details'
40+
className='mt-6 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-gray-100 p-4 text-left font-mono
41+
text-sm text-red-700 dark:bg-dark-card dark:text-red-300'
42+
>
43+
{error.message}
44+
</pre>
45+
)}
46+
47+
<div className='mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row'>
48+
<button
49+
type='button'
50+
onClick={reset}
51+
data-testid='GlobalErrorFallback-retry'
52+
className='inline-flex w-full items-center justify-center rounded-md bg-primary-500 px-5 py-3 text-md
53+
font-semibold tracking-heading text-white transition-all duration-500 ease-in-out hover:bg-primary-400
54+
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2
55+
dark:focus-visible:ring-offset-dark-background sm:w-auto'
56+
>
57+
Try Again
58+
</button>
59+
<Link
60+
href='/'
61+
data-testid='GlobalErrorFallback-home'
62+
className='inline-flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 px-5 py-3
63+
text-md font-semibold tracking-heading text-gray-700 transition-all duration-500 ease-in-out
64+
hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500
65+
focus-visible:ring-offset-2 dark:border-border dark:text-dark-text dark:hover:bg-muted
66+
dark:focus-visible:ring-offset-dark-background sm:w-auto'
67+
>
68+
<IconHome className='size-5' aria-hidden='true' />
69+
Back to homepage
70+
</Link>
71+
</div>
72+
</div>
73+
</div>
74+
);
75+
}

components/layout/BlogLayout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { IPosts } from '@/types/post';
99
import BlogContext from '../../context/BlogContext';
1010
import AuthorAvatars from '../AuthorAvatars';
1111
import AnnouncementHero from '../campaigns/AnnouncementHero';
12+
import ErrorBoundary from '../error/ErrorBoundary';
1213
import Head from '../Head';
1314
import TOC from '../TOC';
1415
import Container from './Container';
@@ -91,7 +92,7 @@ export default function BlogLayout({ post, children }: IBlogLayoutProps) {
9192
</HtmlHead>
9293
)}
9394
<img src={post.cover} alt={post.coverCaption} title={post.coverCaption} className='my-6 w-full' />
94-
{children}
95+
<ErrorBoundary>{children}</ErrorBoundary>
9596
</article>
9697
</main>
9798
</Container>

components/layout/DocsLayout.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import DocsContext from '../../context/DocsContext';
1212
import { getAllPosts } from '../../utils/api';
1313
import Button from '../buttons/Button';
1414
import DocsButton from '../buttons/DocsButton';
15+
import ErrorBoundary from '../error/ErrorBoundary';
1516
import Feedback from '../Feedback';
1617
import Head from '../Head';
1718
import ArrowRight from '../icons/ArrowRight';
@@ -111,7 +112,9 @@ export default function DocsLayout({ post, navItems = {}, children }: IDocsLayou
111112
/>
112113
{explorerDocMenu && <div className='explorer-menu-wrapper mt-2'>{sidebar}</div>}
113114
</div>
114-
<article>{children}</article>
115+
<article>
116+
<ErrorBoundary>{children}</ErrorBoundary>
117+
</article>
115118
</div>
116119
);
117120
}
@@ -200,7 +203,7 @@ export default function DocsLayout({ post, navItems = {}, children }: IDocsLayou
200203
)}
201204
<article className='my-12 overflow-x-auto'>
202205
<Head title={post.title} description={post.excerpt} image={post.cover} />
203-
{children}
206+
<ErrorBoundary>{children}</ErrorBoundary>
204207
</article>
205208
<div>
206209
<DocsButton post={post} />

components/layout/Layout.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { IPost, IPosts } from '@/types/post';
66

77
import BlogContext from '../../context/BlogContext';
88
import { getAllPosts, getDocBySlug, getPostBySlug } from '../../utils/api';
9+
import ErrorBoundary from '../error/ErrorBoundary';
910
import BlogLayout from './BlogLayout';
1011
import DocsLayout from './DocsLayout';
1112
import GenericPostLayout from './GenericPostLayout';
@@ -55,8 +56,16 @@ export default function Layout({ children }: ILayoutProps): React.JSX.Element {
5556
const post = getPostBySlug(pathname);
5657

5758
if (post) {
58-
return <GenericPostLayout post={post as unknown as IPosts['blog'][number]}>{children}</GenericPostLayout>;
59+
return (
60+
<GenericPostLayout post={post as unknown as IPosts['blog'][number]}>
61+
<ErrorBoundary>{children}</ErrorBoundary>
62+
</GenericPostLayout>
63+
);
5964
}
6065

61-
return <div className='min-h-screen bg-white dark:bg-dark-background transition-colors duration-300'>{children}</div>;
66+
return (
67+
<div className='min-h-screen bg-white dark:bg-dark-background transition-colors duration-300'>
68+
<ErrorBoundary>{children}</ErrorBoundary>
69+
</div>
70+
);
6271
}

0 commit comments

Comments
 (0)