Skip to content

Commit 44c1831

Browse files
committed
Add React error boundary fallback
1 parent 457cd39 commit 44c1831

7 files changed

Lines changed: 150 additions & 13 deletions

File tree

.agents/skills/exceptionless-javascript/references/client-react.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class App extends Component {
2727

2828
render() {
2929
return (
30-
<ExceptionlessErrorBoundary>
30+
<ExceptionlessErrorBoundary fallback={<div>Something went wrong.</div>}>
3131
<div>Application content</div>
3232
</ExceptionlessErrorBoundary>
3333
);

example/react/src/App.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@
3333
margin: auto;
3434
}
3535

36+
.error-container {
37+
max-width: 720px;
38+
}
39+
40+
.error-eyebrow {
41+
color: #61dafb;
42+
font-size: 0.7em;
43+
font-weight: 700;
44+
letter-spacing: 0.08em;
45+
margin-bottom: 0;
46+
text-transform: uppercase;
47+
}
48+
3649
@keyframes App-logo-spin {
3750
from {
3851
transform: rotate(0deg);

example/react/src/App.jsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,29 @@ function ExceptionlessExampleContent({ error, message, errorInfo, onThrowCompone
3737
);
3838
}
3939

40+
function ErrorFallback({ onReset }) {
41+
return (
42+
<div className="App">
43+
<header className="App-header">
44+
<div className="container error-container">
45+
<p className="error-eyebrow">Component error captured</p>
46+
<h1 className="App-title">Something went wrong</h1>
47+
<p>The error was submitted to Exceptionless. You can reset this sample and keep testing without refreshing the page.</p>
48+
<button onClick={onReset}>Try again</button>
49+
</div>
50+
</header>
51+
</div>
52+
);
53+
}
54+
4055
class App extends Component {
4156
constructor(props) {
4257
super(props);
4358
this.state = {
4459
error: false,
45-
message: "",
46-
errorInfo: ""
60+
errorBoundaryKey: 0,
61+
errorInfo: "",
62+
message: ""
4763
};
4864
}
4965
async componentDidMount() {
@@ -60,6 +76,13 @@ class App extends Component {
6076
this.setState({ error: true });
6177
};
6278

79+
resetComponentError = () => {
80+
this.setState((state) => ({
81+
error: false,
82+
errorBoundaryKey: state.errorBoundaryKey + 1
83+
}));
84+
};
85+
6386
submitMessage = async () => {
6487
const message = "Hello, world!";
6588
this.setState({ message: "", errorInfo: "" });
@@ -83,7 +106,7 @@ class App extends Component {
83106

84107
render() {
85108
return (
86-
<ExceptionlessErrorBoundary>
109+
<ExceptionlessErrorBoundary key={this.state.errorBoundaryKey} fallback={<ErrorFallback onReset={this.resetComponentError} />}>
87110
<ExceptionlessExampleContent
88111
error={this.state.error}
89112
message={this.state.message}

packages/react/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class App extends Component {
3131

3232
render() {
3333
return (
34-
<ExceptionlessErrorBoundary>
34+
<ExceptionlessErrorBoundary fallback={<div>Something went wrong.</div>}>
3535
<div>// YOUR APP COMPONENTS HERE</div>
3636
</ExceptionlessErrorBoundary>
3737
);

packages/react/src/ExceptionlessErrorBoundary.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
import { Component, type ErrorInfo as ReactErrorInfo, type PropsWithChildren } from "react";
1+
import { Component, type ErrorInfo as ReactErrorInfo, type PropsWithChildren, type ReactNode } from "react";
22
import { Exceptionless } from "@exceptionless/browser";
33

44
const ReactComponentStackContextKey = "@@_ComponentStack";
55

6-
type ErrorState = {
6+
interface ErrorBoundaryProps {
7+
fallback?: ReactNode;
8+
}
9+
10+
interface ErrorState {
711
hasError: boolean;
8-
};
12+
}
913

10-
export class ExceptionlessErrorBoundary extends Component<PropsWithChildren, ErrorState> {
11-
constructor(props: Readonly<Record<PropertyKey, unknown>> | Record<PropertyKey, unknown>) {
14+
export class ExceptionlessErrorBoundary extends Component<PropsWithChildren<ErrorBoundaryProps>, ErrorState> {
15+
constructor(props: Readonly<PropsWithChildren<ErrorBoundaryProps>>) {
1216
super(props);
1317
this.state = { hasError: false };
1418
}
@@ -17,7 +21,7 @@ export class ExceptionlessErrorBoundary extends Component<PropsWithChildren, Err
1721
return { hasError: true };
1822
}
1923

20-
async componentDidCatch(error: Error, errorInfo: ReactErrorInfo) {
24+
async componentDidCatch(error: Error, errorInfo: ReactErrorInfo): Promise<void> {
2125
const builder = Exceptionless.createException(error);
2226
if (errorInfo.componentStack) {
2327
builder.setContextProperty(ReactComponentStackContextKey, errorInfo.componentStack);
@@ -26,9 +30,9 @@ export class ExceptionlessErrorBoundary extends Component<PropsWithChildren, Err
2630
await builder.submit();
2731
}
2832

29-
render() {
33+
render(): ReactNode {
3034
if (this.state.hasError) {
31-
return null;
35+
return this.props.fallback ?? null;
3236
}
3337

3438
return this.props.children;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import React, { act } from "react";
2+
import { createRoot, type Root } from "react-dom/client";
3+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4+
5+
import { ExceptionlessErrorBoundary } from "../src/ExceptionlessErrorBoundary.js";
6+
7+
const mocks = vi.hoisted(() => ({
8+
createException: vi.fn(),
9+
setContextProperty: vi.fn(),
10+
submit: vi.fn().mockResolvedValue(undefined)
11+
}));
12+
13+
vi.mock("@exceptionless/browser", () => ({
14+
Exceptionless: {
15+
createException: mocks.createException.mockImplementation(() => ({
16+
setContextProperty: mocks.setContextProperty,
17+
submit: mocks.submit
18+
}))
19+
}
20+
}));
21+
22+
function Crash(): React.ReactNode {
23+
throw new Error("Boom");
24+
}
25+
26+
describe("ExceptionlessErrorBoundary", () => {
27+
let container: HTMLDivElement;
28+
let root: Root;
29+
let consoleError: ReturnType<typeof vi.spyOn>;
30+
31+
beforeEach(() => {
32+
vi.clearAllMocks();
33+
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
34+
container = document.createElement("div");
35+
document.body.appendChild(container);
36+
root = createRoot(container);
37+
});
38+
39+
afterEach(() => {
40+
act(() => {
41+
root.unmount();
42+
});
43+
container.remove();
44+
consoleError.mockRestore();
45+
});
46+
47+
test("should render fallback content when a child throws", async () => {
48+
await act(async () => {
49+
root.render(
50+
<ExceptionlessErrorBoundary fallback={<p>Something went wrong.</p>}>
51+
<Crash />
52+
</ExceptionlessErrorBoundary>
53+
);
54+
await Promise.resolve();
55+
});
56+
57+
expect(container.textContent).toBe("Something went wrong.");
58+
expect(mocks.createException).toHaveBeenCalledWith(expect.any(Error));
59+
expect(mocks.setContextProperty).toHaveBeenCalled();
60+
expect(mocks.submit).toHaveBeenCalled();
61+
});
62+
63+
test("should render nothing by default when a child throws", async () => {
64+
await act(async () => {
65+
root.render(
66+
<ExceptionlessErrorBoundary>
67+
<Crash />
68+
</ExceptionlessErrorBoundary>
69+
);
70+
await Promise.resolve();
71+
});
72+
73+
expect(container.textContent).toBe("");
74+
expect(mocks.createException).toHaveBeenCalledWith(expect.any(Error));
75+
expect(mocks.submit).toHaveBeenCalled();
76+
});
77+
});

vitest.config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ export default defineConfig({
5959
}
6060
}
6161
},
62+
{
63+
test: {
64+
name: "react",
65+
root: "packages/react",
66+
environment: "jsdom",
67+
environmentOptions: {
68+
jsdom: {
69+
url: "http://localhost/"
70+
}
71+
}
72+
},
73+
resolve: {
74+
conditions: ["source"],
75+
alias: {
76+
"@exceptionless/core": path.resolve(__dirname, "packages/core/src"),
77+
"@exceptionless/browser": path.resolve(__dirname, "packages/browser/src"),
78+
"@exceptionless/react": path.resolve(__dirname, "packages/react/src")
79+
}
80+
}
81+
},
6282
{
6383
test: {
6484
name: "react-native",

0 commit comments

Comments
 (0)