catch render errors instead of showing a white screen

The repo had no error boundary anywhere, so a single malformed stored layout took the whole app down
and the only recovery was a psql session. Two boundaries, because "recover" means different things:

- around the routed screen in DashboardLayout, with the dock and header deliberately left outside so
  navigating away is itself a way out, plus a two-click reset of every `screens/*` layout for when it
  fails again in the same place. Dashboards are not touched — they are user-created and hold content.
- around each panel app in PanelSlot, so one bad app leaves the rest of the workspace running. Its
  recovery is "clear this panel", offered only when the layout is the user's to edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 09:04:03 +00:00
co-authored by Claude Opus 5
parent 30eef86972
commit 64961d49f5
5 changed files with 224 additions and 3 deletions
@@ -0,0 +1,55 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
type FallbackArgs = { error: Error; reset: () => void };
type ErrorBoundaryProps = {
children: ReactNode;
/** Rendered in place of the children once something below has thrown. */
fallback: (args: FallbackArgs) => ReactNode;
/**
* Clear the error whenever any of these changes — pass the pathname to recover on navigation, or the
* id of the thing being rendered. Without it a boundary latches: the screen stays broken until reload
* even after the user has navigated somewhere that would render fine.
*/
resetKeys?: readonly unknown[];
onError?: (error: Error, info: ErrorInfo) => void;
};
type ErrorBoundaryState = { error: Error | null };
/**
* The repo had none of these. A single malformed value anywhere below the router took the whole app to a
* white screen, and the only recovery from a bad stored layout was SQL — which is the wrong thing to ask
* of someone who has just opened the dashboard to read what happened overnight.
*
* Deliberately a render-prop rather than a fixed panel: what "recover" means is different for a screen
* (go somewhere else) and for one panel inside a workspace (clear the app, keep the rest running).
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
override state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[error-boundary]', error, info.componentStack);
this.props.onError?.(error, info);
}
override componentDidUpdate(prev: ErrorBoundaryProps): void {
if (!this.state.error) return;
const a = prev.resetKeys ?? [];
const b = this.props.resetKeys ?? [];
if (a.length !== b.length || a.some((key, i) => !Object.is(key, b[i]))) this.setState({ error: null });
}
reset = (): void => this.setState({ error: null });
override render(): ReactNode {
const { error } = this.state;
if (error) return this.props.fallback({ error, reset: this.reset });
return this.props.children;
}
}