Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions app/src/components/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import ErrorBoundary from "./ErrorBoundary";

/* A component that throws on first render, then works after reset. */
let shouldThrow = true;
function Thrower() {
if (shouldThrow) {
throw new Error("test render explosion");
}
return <p>recovered</p>;
}

function Good() {
return <p>all good</p>;
}

beforeEach(() => {
shouldThrow = true;
});

afterEach(() => {
cleanup();
});

describe("ErrorBoundary", () => {
it("renders children when there is no error", () => {
render(
<ErrorBoundary>
<Good />
</ErrorBoundary>,
);
expect(screen.getByText("all good")).toBeDefined();
});

it("shows fallback UI when a child throws", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});

render(
<ErrorBoundary>
<Thrower />
</ErrorBoundary>,
);

expect(screen.getByText("Something went wrong")).toBeDefined();
expect(screen.getByText("test render explosion")).toBeDefined();
expect(screen.getByText("Try again")).toBeDefined();
expect(screen.getByText("Refresh page")).toBeDefined();

spy.mockRestore();
});

it("resets and re-renders children when 'Try again' is clicked", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});

render(
<ErrorBoundary>
<Thrower />
</ErrorBoundary>,
);

expect(screen.getByText("Something went wrong")).toBeDefined();

// Stop throwing so the next render succeeds
shouldThrow = false;
fireEvent.click(screen.getByText("Try again"));

expect(screen.getByText("recovered")).toBeDefined();

spy.mockRestore();
});

it("logs the error via console.error", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});

render(
<ErrorBoundary>
<Thrower />
</ErrorBoundary>,
);

const boundaryCall = spy.mock.calls.find(
(call) => call[0] === "ErrorBoundary caught an error:",
);
expect(boundaryCall).toBeDefined();

spy.mockRestore();
});
});
62 changes: 62 additions & 0 deletions app/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Component, ReactNode, ErrorInfo } from "react";

interface Props {
children: ReactNode;
}

interface State {
hasError: boolean;
error: Error | null;
}

export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, info.componentStack);
}

private handleReset = () => {
this.setState({ hasError: false, error: null });
};

private handleRefresh = () => {
window.location.reload();
};

render() {
if (this.state.hasError) {
return (
<div className="error-boundary">
<div className="error-boundary-card">
<span className="error-boundary-icon" aria-hidden="true">
⚠️
</span>
<h1>Something went wrong</h1>
<p>An unexpected error prevented the page from rendering.</p>
{this.state.error && (
<pre className="error-boundary-detail">
{this.state.error.message}
</pre>
)}
<div className="error-boundary-actions">
<button onClick={this.handleReset}>Try again</button>
<button className="ghost" onClick={this.handleRefresh}>
Refresh page
</button>
</div>
</div>
</div>
);
}

return this.props.children;
}
}
17 changes: 10 additions & 7 deletions app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MotionConfig } from "motion/react";
import { I18nProvider } from "./lib/i18n";
import ErrorBoundary from "./components/ErrorBoundary";
import App from "./App";
import "./styles.css";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<MotionConfig reducedMotion="user">
<I18nProvider>
<App />
</I18nProvider>
</MotionConfig>
</StrictMode>,
<ErrorBoundary>
<StrictMode>
<MotionConfig reducedMotion="user">
<I18nProvider>
<App />
</I18nProvider>
</MotionConfig>
</StrictMode>
</ErrorBoundary>,
);
57 changes: 57 additions & 0 deletions app/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,63 @@ select.lang-select:focus {
text-decoration: underline;
}

.error-boundary {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}

.error-boundary-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 36px 32px;
max-width: 440px;
width: 100%;
text-align: center;
}

.error-boundary-icon {
font-size: 40px;
display: block;
margin-bottom: 16px;
}

.error-boundary-card h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 8px;
}

.error-boundary-card p {
color: var(--muted);
font-size: 14px;
margin-bottom: 20px;
}

.error-boundary-detail {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 9px;
padding: 12px 14px;
font-family: ui-monospace, Consolas, monospace;
font-size: 13px;
color: var(--muted);
text-align: left;
overflow-wrap: anywhere;
white-space: pre-wrap;
margin-bottom: 20px;
}

.error-boundary-actions {
display: flex;
gap: 10px;
justify-content: center;
}

@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
Expand Down
Loading