import { Component, type ErrorInfo, type ReactNode } from "react"; import { errorAttrs, log } from "../../lib/log"; import { reloadingForNewBuild } from "../../lib/staleBuild"; import { Button } from "./Button"; export interface CrashCopy { title: string; subtitle: string; reload: string; } interface Props { children: ReactNode; /** Read at crash time, so the screen speaks the language active then. */ copy: () => CrashCopy; /** What the entry does beyond the log line (the app keeps a fatal notice in its center). */ onCrash?: () => void; } interface State { crashed: boolean; } // Catches render-time crashes anywhere in the tree, logs them and offers a reload. Free of the app's // i18n and notification store, so the public /watch entry wraps its page in it without loading // either; `ErrorBoundary` is the app's wrapper around it. export class CrashBoundary extends Component { state: State = { crashed: false }; static getDerivedStateFromError(): State { return { crashed: true }; } componentDidCatch(error: Error, info: ErrorInfo) { // The tab is already reloading onto a newer build (lib/staleBuild): this is the chunk it could not // load, not a crash — no fatal notice, which would outlive the reload. if (reloadingForNewBuild()) return; log.fatal("ui.crash", "the app crashed while rendering", { ...errorAttrs(error), ...(info.componentStack ? { component_stack: info.componentStack } : {}), }); this.props.onCrash?.(); } render() { if (this.state.crashed) { if (reloadingForNewBuild()) return null; const copy = this.props.copy(); return (
{copy.title}
{copy.subtitle}
); } return this.props.children; } }