Files
PaperClipAI/ui/src/components/RouteErrorBoundary.test.tsx
T
Devin FoleyandPaperclip fff410dfe7 fix(ui): retain bounded context for browser render errors (#13904)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its browser error boundaries recover from failed renders and report
the exception when error monitoring is enabled.
> - The boundaries already receive the React component stack, but
discard it before reporting the error.
> - A minified DOM insertion error can therefore lack enough context to
identify the affected component.
> - Browser translation can replace text nodes that React still uses as
insertion anchors.
> - This pull request preserves bounded component names and browser
state for the next failure.
> - Maintainers can locate the failed component without collecting page
content or customer URLs.

## Linked Issues or Issue Description

Related: #13719 attributes browser errors to the loaded release. #13784
supplies the browser environment. This change adds context to
error-boundary reports.

**What happened?**

A browser error boundary reports a DOM `NotFoundError` with only a
minified JavaScript stack. The React component trace is logged to the
console, which production builds remove. Browser translation is a
plausible cause, but the report cannot identify the component or confirm
the translation marker.

**Expected behavior**

An error-boundary report includes a bounded component trace and limited
browser state. It excludes component props, text, HTML, element IDs,
arbitrary CSS classes, page URLs, and query strings.

**Steps to reproduce**

1. Render a component with conditional content before a text node.
2. Replace that text node with a translation element outside React.
3. Enable the preceding conditional content. React tries to insert
before the detached text node and throws `NotFoundError`.
4. The boundary shows its recovery UI, but the old report loses the
component trace.

**Paperclip version or commit**

Based on `6681c71b40`. The regression test reproduces the DOM mutation
with the installed React version.

**Deployment mode**

Built browser UI with optional Sentry monitoring enabled for the
signed-in session.

## What Changed

- Pass the component stack and boundary kind from both error boundaries.
- Keep at most 40 component names from at most 16 KiB of stack input.
Drop locations and unrecognized lines.
- Snapshot document readiness, visibility, and the browser translation
root-class marker before the asynchronous reporting queue runs.
- Attach diagnostics to that event only. Preserve the existing
monitoring gate, sign-out behavior, and original exception if
diagnostics fail.
- Preserve function names in production bundles. Test the actual Vite
production pipeline.
- Document the fields, privacy limits, and translation-marker
limitations.

## Verification

- Focused diagnostics, boundary, real Sentry SDK, and production-build
tests: 49 passed.
- The translation DOM-mutation test reproduces `NotFoundError`, verifies
the failed component trace, and keeps the recovery UI usable.
- The real SDK test checks emitted events, private fixture exclusion,
event isolation, and no capture after sign-out. Its transport stays
in-process.
- `pnpm check:token-gates`: passed.
- [Greptile
review](https://github.com/paperclipai/paperclip/pull/13904#issuecomment-5804220318):
5/5 on `92a2a23d9c`, with no review threads.
- `pnpm -r typecheck` and `pnpm build`: passed.
- Full CI unit and integration test matrix: passed, including all
server, chat, workspace, runner, and serialized suites.
- Local `pnpm test:run` was started, then stopped after the equivalent
full CI matrix passed. The unsharded local run was not completed and is
not claimed as a pass.
- `pnpm exec playwright test --config tests/e2e/playwright.config.ts
tests/e2e/project-repositories.spec.ts --repeat-each=3 --trace=on
--reporter=line`: six tests passed against the production build.
- Initial CI browser shard 8 timed out after the repository form
replaced an enabled Save button with a disabled one before the click.
The retained page snapshot shows the original repository selection. No
error-boundary fallback appeared. [CI attempt
2](https://github.com/paperclipai/paperclip/actions/runs/35929990870/attempts/2)
passed the failed jobs on the same commit. The full PR check matrix is
green. This confirms an intermittent failure, but does not establish the
cause of the first failure.
- Full browser builds with and without name preservation passed. The
initial JavaScript chunk grows from 1,571,919 to 1,668,453 gzip bytes
(+6.1%). Total JavaScript across all chunks grows by 219,610 gzip bytes
(+5.3%).

## Risks

- This adds diagnostics. It reproduces a translation failure mode but
does not identify or repair the specific application component from a
past report.
- The root-class marker is a hint. Other translation tools may omit it,
and its presence does not prove causation.
- Name preservation increases bundle size as measured above. No source
maps are published by this change.
- The error is still reported. DOM operations, browser translation, and
recovery behavior are unchanged.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, code editing, terminal
tools, and test execution. The exact serving model identifier and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-23 16:23:42 -07:00

144 lines
4.1 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
const navigateMock = vi.hoisted(() => vi.fn());
const routerLocation = vi.hoisted(() => ({ current: { pathname: "/co/agents/new", search: "?adapterType=claude_local" } }));
const captureBrowserExceptionMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
useLocation: () => routerLocation.current,
useNavigate: () => navigateMock,
}));
vi.mock("@/lib/sentry", () => ({
captureBrowserException: (...args: unknown[]) => captureBrowserExceptionMock(...args),
}));
function Boom(): never {
throw new Error("Maximum update depth exceeded");
}
describe("RouteErrorBoundary", () => {
let container: HTMLDivElement;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
navigateMock.mockReset();
routerLocation.current = { pathname: "/co/agents/new", search: "?adapterType=claude_local" };
container = document.createElement("div");
document.body.appendChild(container);
// React logs caught render errors to console.error; silence the expected noise.
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
container.remove();
captureBrowserExceptionMock.mockClear();
});
it("reports one captured error and keeps the recovery button", () => {
const root = createRoot(container);
act(() => {
root.render(
<RouteErrorBoundary>
<Boom />
</RouteErrorBoundary>,
);
});
expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1);
expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error), {
boundary: "route", componentStack: expect.stringContaining("Boom"),
});
expect(
Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent === "Go back",
),
).toBe(true);
act(() => {
root.unmount();
});
});
it("renders a recoverable error card instead of a blank page when a child throws", () => {
const root = createRoot(container);
act(() => {
root.render(
<RouteErrorBoundary>
<Boom />
</RouteErrorBoundary>,
);
});
expect(container.textContent).toContain("This page hit an error");
expect(container.textContent).toContain("Maximum update depth exceeded");
expect(container.querySelector("pre")).not.toBeNull();
act(() => {
root.unmount();
});
});
it("navigates back when the user clicks Go back", () => {
const root = createRoot(container);
act(() => {
root.render(
<RouteErrorBoundary>
<Boom />
</RouteErrorBoundary>,
);
});
const goBack = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Go back",
);
expect(goBack).toBeDefined();
act(() => {
goBack?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith(-1);
act(() => {
root.unmount();
});
});
it("recovers and renders children again after the route changes", () => {
const root = createRoot(container);
act(() => {
root.render(
<RouteErrorBoundary>
<Boom />
</RouteErrorBoundary>,
);
});
expect(container.textContent).toContain("This page hit an error");
// Simulate back-navigation to a different route, then re-render with a
// healthy child — the boundary should reset off the new resetKey.
routerLocation.current = { pathname: "/co/agents", search: "" };
act(() => {
root.render(
<RouteErrorBoundary>
<div>Agents list</div>
</RouteErrorBoundary>,
);
});
expect(container.textContent).toContain("Agents list");
expect(container.textContent).not.toContain("This page hit an error");
act(() => {
root.unmount();
});
});
});