- The raw-source contract tests read files from disk (rawSources.testkit) instead of import.meta.glob, which knip counted as importing every src file — its unused-files lane could never name an orphan. knip now names src/watchMain.tsx as an entry (registered in docs/gates.md). - vite build fails when index.html or watch.html is not emitted. - The watch-entry import walk follows dynamic import() too. - audit_metrics.py's plan-code patterns are held equal to the guard's. - CrashBoundary is domain-free: it moves into components/ui.
63 lines
2.1 KiB
TypeScript
63 lines
2.1 KiB
TypeScript
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<Props, State> {
|
|
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 (
|
|
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center">
|
|
<div>
|
|
<div className="text-lg font-semibold text-fg mb-1">{copy.title}</div>
|
|
<div className="text-sm mb-3">{copy.subtitle}</div>
|
|
<Button variant="link" onClick={() => location.reload()} className="text-sm">
|
|
{copy.reload}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|