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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user