Skip to content
File

Blob: src/client/components/error-boundary.tsx

typescript75 lines
1import { AlertTriangle, RefreshCw } from "lucide-react";
2import { Component, type ErrorInfo, type ReactNode } from "react";
3 
4import { Button } from "@/client/components/ui/button";
5 
6interface Props {
7 children: ReactNode;
8 // Used by route-level boundaries to reset on navigation. When the key
9 // changes, the boundary remounts and clears its captured error.
10 resetKey?: string;
11}
12 
13interface State {
14 error: Error | null;
15}
16 
17// Class component because React's error-boundary contract still lives on
18// componentDidCatch / getDerivedStateFromError. Wraps the route Outlet
19// in app-shell so a runtime exception in any page renders a recoverable
20// surface instead of blanking the whole app.
21export class ErrorBoundary extends Component<Props, State> {
22 state: State = { error: null };
23 
24 static getDerivedStateFromError(error: Error): State {
25 return { error };
26 }
27 
28 componentDidCatch(error: Error, info: ErrorInfo): void {
29 // Surface in the dev console so the stack is reachable; production
30 // observability can hook here if/when we add a reporter.
31 console.error("[error-boundary]", error, info.componentStack);
32 }
33 
34 componentDidUpdate(prev: Props): void {
35 if (prev.resetKey !== this.props.resetKey && this.state.error) {
36 this.setState({ error: null });
37 }
38 }
39 
40 render(): ReactNode {
41 if (!this.state.error) return this.props.children;
42 return (
43 <div className="mx-auto max-w-md py-12">
44 <div className="rounded-2xl border border-red-500/30 bg-red-500/5 p-6">
45 <div className="mb-3 flex items-center gap-2 text-red-400">
46 <AlertTriangle className="h-5 w-5" aria-hidden="true" />
47 <h1 className="font-display text-lg font-semibold">Something went wrong</h1>
48 </div>
49 <p className="mb-4 text-sm text-zinc-400">
50 This page hit an unexpected error. The details below are useful when reporting the issue.
51 </p>
52 <pre className="mb-4 max-h-48 overflow-auto rounded-lg bg-zinc-900/80 p-3 font-mono text-xs text-zinc-300">
53 {this.state.error.message}
54 </pre>
55 <div className="flex gap-2">
56 <Button variant="secondary" size="sm" onClick={() => this.setState({ error: null })}>
57 <RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
58 Try again
59 </Button>
60 <Button
61 variant="ghost"
62 size="sm"
63 onClick={() => {
64 window.location.href = "/";
65 }}
66 >
67 Go home
68 </Button>
69 </div>
70 </div>
71 </div>
72 );
73 }
74}