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:
@@ -1,6 +1,9 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useDock, MusicPlayerHost } from 'officerdev';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||
import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
||||
@@ -19,6 +22,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const permitted = useMemo(() => ALL_DOCK_ITEMS.filter((item) => canVisit(item.to)), [canVisit]);
|
||||
const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS);
|
||||
const isTouch = useIsTouch();
|
||||
const { pathname } = useLocation();
|
||||
usePageTitleSync();
|
||||
// The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its
|
||||
// reveal boundary from this element, so it always sits just above whatever's at the bottom.
|
||||
@@ -35,7 +39,16 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
<section ref={regionRef} className="relative min-h-0 flex-1 snap-start overflow-clip">
|
||||
<Background />
|
||||
{!isTouch && <Dock items={visibleItems} boundaryRef={regionRef} className="hidden md:flex" />}
|
||||
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">{children}</div>
|
||||
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
|
||||
{/* Chrome stays outside: whatever broke, the dock and the header still navigate you off it.
|
||||
Keyed on the pathname so leaving a broken screen is itself a recovery. */}
|
||||
<ErrorBoundary
|
||||
resetKeys={[pathname]}
|
||||
fallback={({ error, reset }) => <ScreenErrorFallback error={error} reset={reset} />}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</section>
|
||||
<MusicPlayerHost />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type ScreenErrorFallbackProps = { error: Error; reset: () => void };
|
||||
|
||||
/**
|
||||
* What the owner sees instead of a white screen.
|
||||
*
|
||||
* The recovery that matters is the last one. Every screen in the app renders a Workspace whose layout is
|
||||
* a jsonb blob written by the client and never validated on the way back in, so a malformed one throws
|
||||
* during render — and until this existed, the only way out was a psql session. Dashboards are left alone:
|
||||
* they are user-created and hold content, whereas a screen's layout is just where its panes sit.
|
||||
*/
|
||||
export const ScreenErrorFallback = ({ error, reset }: ScreenErrorFallbackProps) => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
const resetScreenLayouts = async () => {
|
||||
if (!confirming) return setConfirming(true);
|
||||
setResetting(true);
|
||||
try {
|
||||
const state = await client.get<Record<string, unknown>>('/dashboards');
|
||||
const patch = Object.fromEntries(
|
||||
Object.keys(state)
|
||||
.filter((key) => key.startsWith('screens/'))
|
||||
.map((key) => [key, null]),
|
||||
);
|
||||
if (Object.keys(patch).length > 0) await client.patch('/dashboards', patch);
|
||||
queryClient.removeQueries({ queryKey: ['DASHBOARD_STATE'] });
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error('[screen-error] could not reset screen layouts', err);
|
||||
setResetting(false);
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-2xl flex-col justify-center gap-6 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-1 size-6 shrink-0 text-amber-500" />
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold">This screen failed to render</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Nothing was lost — this is a display failure, not a data one. Agent transcripts, jobs and files are
|
||||
untouched.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="bg-muted/40 rounded-md border p-3 text-sm">
|
||||
<summary className="cursor-pointer select-none font-medium">{error.message || 'Unknown error'}</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap text-xs opacity-70">{error.stack}</pre>
|
||||
</details>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={reset}>Try again</Button>
|
||||
<Button variant="secondary" onClick={() => window.location.reload()}>
|
||||
Reload
|
||||
</Button>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to="/">Go home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If it fails again in the same place, a saved panel layout is the usual cause. Resetting puts every screen's
|
||||
panes back to their defaults. <strong>Your dashboards are not touched.</strong>
|
||||
</p>
|
||||
<Button variant={confirming ? 'destructive' : 'outline'} disabled={resetting} onClick={resetScreenLayouts}>
|
||||
{resetting ? 'Resetting…' : confirming ? 'Yes — reset every screen layout' : 'Reset screen layouts'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
type PanelErrorFallbackProps = {
|
||||
error: Error;
|
||||
reset: () => void;
|
||||
/** Null when the panel is not interactive — then there is nothing to clear it back to. */
|
||||
onClearApp: (() => void) | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One panel's app threw. The workspace around it keeps running — the other panels are still live, and
|
||||
* that is the whole point: a dashboard left running overnight should not be taken to a white screen by
|
||||
* whichever app happened to receive a malformed value.
|
||||
*
|
||||
* Compact on purpose. A panel can be 200px tall, so the stack lives behind a `<details>` and the two
|
||||
* recoveries are the only things guaranteed to be visible.
|
||||
*/
|
||||
export const PanelErrorFallback = ({ error, reset, onClearApp }: PanelErrorFallbackProps) => (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 overflow-auto p-3 text-center">
|
||||
<AlertTriangle className="size-5 shrink-0 text-amber-500" />
|
||||
<p className="text-sm font-medium">This panel failed to render</p>
|
||||
<p className="text-muted-foreground max-w-full truncate text-xs" title={error.message}>
|
||||
{error.message || 'Unknown error'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="cursor-pointer rounded border px-2 py-1 text-xs transition-colors hover:bg-black/5"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
{onClearApp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearApp}
|
||||
className="cursor-pointer rounded border px-2 py-1 text-xs transition-colors hover:bg-black/5"
|
||||
>
|
||||
Clear this panel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error.stack && (
|
||||
<details className="w-full text-left">
|
||||
<summary className="text-muted-foreground cursor-pointer select-none text-[11px]">Details</summary>
|
||||
<pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap text-[10px] opacity-70">{error.stack}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -5,7 +5,9 @@ import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from './layout-utils';
|
||||
import { Card } from '@/components/Card';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { AppPicker } from './AppPicker';
|
||||
import { PanelErrorFallback } from './PanelErrorFallback';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
@@ -382,6 +384,24 @@ export const PanelSlot = ({
|
||||
)
|
||||
: (content: React.ReactNode) => <>{content}</>;
|
||||
|
||||
// One app throwing must not take the workspace with it — the other panels are still live, and on a
|
||||
// dashboard left running overnight they are the result. Keyed on the panel's identity, so swapping the
|
||||
// app or clearing it is itself a recovery. A locked layout offers no "clear": it isn't the user's to edit.
|
||||
const guardApp = (node: React.ReactNode) => (
|
||||
<ErrorBoundary
|
||||
resetKeys={[panel.id, panel.appType]}
|
||||
fallback={({ error, reset }) => (
|
||||
<PanelErrorFallback
|
||||
error={error}
|
||||
reset={reset}
|
||||
onClearApp={interactive && !locked ? () => onSetApp(panel.id, null) : null}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{node}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const overlays =
|
||||
interactive && !locked ? (
|
||||
<>
|
||||
@@ -413,7 +433,7 @@ export const PanelSlot = ({
|
||||
return contextMenu(
|
||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||
<div className="@container relative h-full w-full overflow-hidden">
|
||||
<AppComponent panelId={panel.id} />
|
||||
{guardApp(<AppComponent panelId={panel.id} />)}
|
||||
</div>
|
||||
{overlays}
|
||||
</div>,
|
||||
@@ -518,7 +538,7 @@ export const PanelSlot = ({
|
||||
<div className="flex-1 min-h-0">
|
||||
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
|
||||
<div className="@container h-full w-full" style={zoom !== null && zoom !== 1 ? { zoom } : undefined}>
|
||||
<AppComponent panelId={panel.id} />
|
||||
{guardApp(<AppComponent panelId={panel.id} />)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user