mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 16:02:10 +00:00
138 lines
6.3 KiB
TypeScript
138 lines
6.3 KiB
TypeScript
// Route-level error boundary.
|
|
//
|
|
// Without this, an unhandled render error in any page unmounts the entire
|
|
// subtree to the next React boundary (which is "none" in this app), so the
|
|
// content panel goes silent-white with no signal about what broke. This
|
|
// boundary catches it, prints the actual error inside the panel using the
|
|
// same brae-density chrome as the rest of the app, and offers a retry.
|
|
//
|
|
// Wrap each route element with <RouteBoundary>...</RouteBoundary> or use
|
|
// the <withBoundary> helper to opt a page in.
|
|
|
|
import React from "react";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import { AlertTriangleIcon, RefreshCcwIcon } from "lucide-react";
|
|
import { captureException } from "@/lib/observability";
|
|
import { SENTRY_RELEASE } from "@/lib/information";
|
|
|
|
interface State {
|
|
error: Error | null;
|
|
info: React.ErrorInfo | null;
|
|
/** Last `resetKey` seen, so a change clears the error without a remount. */
|
|
seenResetKey?: string;
|
|
}
|
|
|
|
interface BoundaryProps {
|
|
children: React.ReactNode;
|
|
onReset?: () => void;
|
|
/** Change this to drop a caught error and re-render the children. */
|
|
resetKey?: string;
|
|
}
|
|
|
|
export class ErrorBoundary extends React.Component<BoundaryProps, State> {
|
|
state: State = { error: null, info: null };
|
|
|
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
|
return { error };
|
|
}
|
|
|
|
// Clearing the error here rather than remounting the boundary is what lets
|
|
// a healthy page keep its state across a URL change.
|
|
static getDerivedStateFromProps(props: BoundaryProps, state: State): Partial<State> | null {
|
|
if (state.seenResetKey === props.resetKey) return null;
|
|
return { seenResetKey: props.resetKey, error: null, info: null };
|
|
}
|
|
|
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
|
this.setState({ info });
|
|
// Through lib/observability, so the error reaches whichever backend
|
|
// the deployment configured. Reading a global off `window` found the
|
|
// SDK only when it had put itself there, which a bundled one does not.
|
|
captureException(error);
|
|
console.error("[ErrorBoundary]", error, info?.componentStack);
|
|
}
|
|
|
|
reset = () => {
|
|
this.setState({ error: null, info: null });
|
|
this.props.onReset?.();
|
|
};
|
|
|
|
render() {
|
|
if (!this.state.error) return this.props.children;
|
|
return <BoundaryFallback error={this.state.error} info={this.state.info} reset={this.reset} />;
|
|
}
|
|
}
|
|
|
|
function BoundaryFallback({ error, info, reset }: { error: Error; info: React.ErrorInfo | null; reset: () => void }) {
|
|
const navigate = useNavigate();
|
|
return (
|
|
<div className="flex flex-col min-h-full bg-white">
|
|
<div className="min-h-12 md:h-12 px-5 py-1.5 md:py-0 border-b border-slate-200 flex flex-wrap md:flex-nowrap items-center gap-3 gap-y-1.5 shrink-0 bg-white">
|
|
<span className="text-[10px] uppercase tracking-[0.14em] text-red-500 font-medium">
|
|
Page error
|
|
</span>
|
|
<div className="h-4 w-px bg-slate-200" />
|
|
<span className="text-[12.5px] text-slate-600 truncate">
|
|
{error.message || "Something broke while rendering this page"}
|
|
</span>
|
|
<div className="ml-auto flex items-center gap-1.5">
|
|
<button
|
|
onClick={() => navigate(-1)}
|
|
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 text-[12px] font-medium transition-colors"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
onClick={reset}
|
|
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
|
>
|
|
<RefreshCcwIcon className="w-3 h-3" />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-auto px-5 py-6">
|
|
<div className="max-w-3xl">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<AlertTriangleIcon className="w-3.5 h-3.5 text-red-500 shrink-0" />
|
|
<span className="text-[12.5px] font-semibold text-slate-900">
|
|
{error.name || "Error"}
|
|
</span>
|
|
</div>
|
|
<p className="text-[12px] text-slate-700 mb-4 leading-relaxed">
|
|
{error.message || "No message provided."}
|
|
</p>
|
|
{(error.stack || info?.componentStack) && (
|
|
<details className="border border-slate-200 rounded-md bg-slate-50 overflow-hidden">
|
|
<summary className="px-3 py-2 text-[11px] font-medium text-slate-700 cursor-pointer hover:bg-slate-100 transition-colors">
|
|
Stack
|
|
</summary>
|
|
<pre className="px-3 py-3 text-[10.5px] font-mono text-slate-700 leading-relaxed overflow-x-auto whitespace-pre-wrap border-t border-slate-200">
|
|
{SENTRY_RELEASE ? `Build: ${SENTRY_RELEASE}\n\n` : ""}
|
|
{error.stack || ""}
|
|
{info?.componentStack ? `\n\nComponent stack:${info.componentStack}` : ""}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* RouteBoundary — react-router compatible: resets the boundary on
|
|
* pathname change so navigating away from a broken page recovers
|
|
* automatically.
|
|
*/
|
|
export function RouteBoundary({ children }: { children: React.ReactNode }) {
|
|
const { pathname } = useLocation();
|
|
// resetKey, not key: a `key` here would tear down and re-create the whole
|
|
// routed subtree on every URL change, which throws away the scroll offset
|
|
// of any list whose page encodes in-page state in the path (issue #396).
|
|
// Mounting the next page cleanly is the Suspense boundary's job in
|
|
// AppShell, which keys off the route identity instead.
|
|
return <ErrorBoundary resetKey={pathname}>{children}</ErrorBoundary>;
|
|
}
|