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:
50
src/client/components/ErrorBoundary.test.tsx
Normal file
50
src/client/components/ErrorBoundary.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
57
src/client/components/ErrorBoundary.tsx
Normal file
57
src/client/components/ErrorBoundary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user