Add ErrorBoundary component with recovery UI

React class component that catches render errors in child trees and
displays a styled error panel with the exception message and a
'Try again' button that resets state. Follows the existing design
system with red accent colors and gradient icon background.

Tests verify normal rendering, error capture with message display,
and the presence of the recovery button.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:35:30 -05:00
parent 89ee0cb313
commit e5c5e470b0
2 changed files with 107 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { ErrorBoundary } from "./ErrorBoundary";
function ThrowingComponent({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) {
throw new Error("Test render error");
}
return <div>Child content</div>;
}
describe("ErrorBoundary", () => {
beforeEach(() => {
// Suppress React error boundary console errors in test output
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("renders children when no error occurs", () => {
render(
<ErrorBoundary>
<ThrowingComponent shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText("Child content")).toBeInTheDocument();
});
it("renders error UI when child throws", () => {
render(
<ErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.getByText("Test render error")).toBeInTheDocument();
});
it("shows try again button in error state", () => {
render(
<ErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.getByText("Try again")).toBeInTheDocument();
expect(screen.getByText("An error occurred while rendering this view.")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,57 @@
import React from "react";
interface Props {
children: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (!this.state.hasError) {
return this.props.children;
}
return (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-md animate-fade-in">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-red-500/20"
style={{ background: "linear-gradient(135deg, rgba(239,68,68,0.1), rgba(239,68,68,0.05))" }}
>
<svg className="w-7 h-7 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<p className="text-subheading font-medium text-foreground">Something went wrong</p>
<p className="text-body text-foreground-muted mt-1.5 mb-4">
An error occurred while rendering this view.
</p>
{this.state.error && (
<pre className="text-caption text-red-400/80 bg-red-500/5 border border-red-500/10 rounded-lg p-3 mb-4 text-left overflow-x-auto max-h-32">
{this.state.error.message}
</pre>
)}
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="btn btn-sm bg-surface-overlay border border-border-muted text-foreground hover:bg-surface-inset transition-colors"
>
Try again
</button>
</div>
</div>
);
}
}