fix(frontend): contain viewer/widget crashes with error boundaries

A render error in one viewer or gate widget unmounted the whole React root
(white screen). Add a reusable ErrorBoundary (class component, no new dep) with
resetKeys + an on-brand fallback, and wire it at two surfaces:
- WidgetHost: isolates the gate widget (reset on descriptor id) so a malformed
  gate payload no longer blanks the conversation
- SessionDocumentsPanel: wraps each document (reset on doc key/content) so one
  crashing viewer degrades only its section; siblings and the panel survive

The observed crash: a schema-linking doc that parses but lacks `candidates`
makes SchemaLinkingViewer throw. Verified live via Playwright against the mock
backend. TDD throughout; tsc clean, 123/123 vitest (+8 new tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 13:18:51 +02:00
co-authored by Claude Fable 5
parent 864b7ee29c
commit 2f68b0d109
6 changed files with 274 additions and 2 deletions
@@ -50,3 +50,26 @@ test("hides Resume for an archived session", async () => {
await screen.findByText("Original question");
expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument();
});
test("a malformed document renders a fallback without taking down its siblings", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Valid JSON but missing `candidates` — SchemaLinkingViewer crashes on this shape.
// Without a per-document boundary this crash blanks the whole panel (React 18
// unmounts the tree); the good sibling document must still render.
// resetHandlers(...) replaces the beforeEach handler so this response is used.
server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () =>
HttpResponse.json([
{ phase: "F4", key: "schema", title: "Schema linking", format: "schema-linking", content: '{"joins":[]}' },
{ phase: "—", key: "question", title: "Original question", format: "text", content: "SIBLING-SURVIVES" },
]),
),
);
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
// The healthy sibling rendered — the panel did not white-screen.
expect(await screen.findByText("SIBLING-SURVIVES")).toBeInTheDocument();
// The broken document degraded to a fallback in place.
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
errorSpy.mockRestore();
});
+7 -1
View File
@@ -3,6 +3,7 @@ import { X } from "lucide-react";
import { getSessionDocuments } from "../api/sessions";
import type { SessionDocument, SessionSummary } from "../api/types";
import { Button } from "../components/ui/button";
import { ErrorBoundary } from "../components/ErrorBoundary";
import { SqlViewer } from "../viewers/SqlViewer";
import { SchemaLinkingViewer } from "../viewers/SchemaLinkingViewer";
import { MarkdownView } from "../viewers/MarkdownView";
@@ -110,7 +111,12 @@ export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
</span>
)}
</div>
<DocBody doc={doc} />
{/* Isolate each document: a viewer that crashes on malformed
content degrades to a fallback in place, leaving the rest of
the panel intact instead of blanking the whole tree. */}
<ErrorBoundary resetKeys={[doc.key, doc.content]} label="document">
<DocBody doc={doc} />
</ErrorBoundary>
</section>
))}
</div>
+34
View File
@@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/react";
import { WidgetHost } from "./WidgetHost";
import { useSessionStore } from "../store/sessionStore";
import type { WidgetDescriptor } from "../api/types";
// Replace the widget registry with a renderer that always throws, simulating a
// viewer/widget that crashes on malformed gate data (the real white-screen path).
vi.mock("../widgets", () => ({
resolve: () => function Exploding() {
throw new Error("bad gate payload");
},
}));
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
useSessionStore.getState().resetSession();
});
afterEach(() => {
errorSpy.mockRestore();
useSessionStore.getState().resetSession();
});
const gate: WidgetDescriptor = { id: "g1", widget: "select", title: "Choose" };
test("a crashing widget shows a fallback instead of unmounting the app", () => {
useSessionStore.setState({ pendingWidget: gate });
render(<WidgetHost sessionId="s1" />);
// No throw escaped: the fallback rendered.
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
// The store (and thus the surrounding app) is still alive and interactive.
expect(useSessionStore.getState().pendingWidget).toEqual(gate);
});
+8 -1
View File
@@ -2,6 +2,7 @@ import { useSessionStore } from "../store/sessionStore";
import { resolve } from "../widgets";
import { postResponse } from "../api/sessions";
import type { UiResponse } from "../api/types";
import { ErrorBoundary } from "../components/ErrorBoundary";
export function WidgetHost({ sessionId }: { sessionId: string | null }) {
const pending = useSessionStore((s) => s.pendingWidget);
@@ -32,5 +33,11 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) {
});
clearPending();
};
return <Renderer descriptor={pending} onRespond={onRespond} />;
// A malformed gate payload must not white-screen the whole app: isolate the
// widget render. resetKeys on the descriptor id so the next gate starts clean.
return (
<ErrorBoundary resetKeys={[pending.id]} label="step">
<Renderer descriptor={pending} onRespond={onRespond} />
</ErrorBoundary>
);
}