From fff410dfe777ae0427385e8297df992ba9aed4ce Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 23 Sep 2026 16:23:42 -0700 Subject: [PATCH] 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 --- doc/observability.md | 19 ++++ ui/src/components/AppErrorBoundary.test.tsx | 48 ++++++++- ui/src/components/AppErrorBoundary.tsx | 2 +- ui/src/components/RouteErrorBoundary.test.tsx | 6 +- ui/src/components/RouteErrorBoundary.tsx | 2 +- ui/src/lib/browser-error-context.test.ts | 73 ++++++++++++++ ui/src/lib/browser-error-context.ts | 57 +++++++++++ ui/src/lib/sentry.test.ts | 97 +++++++++++++++++++ ui/src/lib/sentry.ts | 13 ++- ui/src/lib/vite-component-names.test.ts | 41 ++++++++ ui/vite.config.ts | 3 + 11 files changed, 353 insertions(+), 8 deletions(-) create mode 100644 ui/src/lib/browser-error-context.test.ts create mode 100644 ui/src/lib/browser-error-context.ts create mode 100644 ui/src/lib/vite-component-names.test.ts diff --git a/doc/observability.md b/doc/observability.md index e4fd4d61787..7b5bef23c88 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -411,6 +411,25 @@ not inherit a previous run's identity or fingerprint. The browser sends no page URL, no referrer, no user agent, and no breadcrumb. +Application and route error-boundary reports also include: + +- `react_error_boundary`: `app` or `route`. +- `react.componentStack`: up to 40 React component names. Frame locations, + URLs, arguments, and unrecognized lines are omitted. Parsing examines at + most 16 KiB of input. Production builds preserve function names so this + trace remains useful after minification; this adds some bundle size. +- `browser_state`: document readiness, visibility, and a boolean indicating + the `translated-ltr` or `translated-rtl` root class used by browser translation. + The marker is evidence of DOM translation, not proof of the error's cause; + its absence does not exclude other translators or DOM-changing extensions. + +These fields are captured at the failure, before asynchronous reporting, and +attached only to that event. They include no component props, DOM text, HTML, +element identifiers, arbitrary CSS classes, route, or query string. Failed +diagnostic reads do not prevent the original exception from being reported. +The monitoring gate and sign-out behavior still apply. This context does not +suppress errors, change DOM operations, or disable browser translation. + ### Fail-open behavior A failed Sentry import or a failed init never stops the server and never diff --git a/ui/src/components/AppErrorBoundary.test.tsx b/ui/src/components/AppErrorBoundary.test.tsx index 285b9c595fa..ecb0d9a2dfe 100644 --- a/ui/src/components/AppErrorBoundary.test.tsx +++ b/ui/src/components/AppErrorBoundary.test.tsx @@ -8,7 +8,7 @@ import { AppErrorBoundary } from "./AppErrorBoundary"; const captureBrowserExceptionMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/sentry", () => ({ - captureBrowserException: (error: unknown) => captureBrowserExceptionMock(error), + captureBrowserException: (...args: unknown[]) => captureBrowserExceptionMock(...args), })); function BoomRender(): never { @@ -36,6 +36,7 @@ describe("AppErrorBoundary", () => { afterEach(() => { consoleErrorSpy.mockRestore(); container.remove(); + document.documentElement.classList.remove("translated-ltr"); captureBrowserExceptionMock.mockClear(); }); @@ -50,7 +51,9 @@ describe("AppErrorBoundary", () => { }); expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); - expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error)); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error), { + boundary: "app", componentStack: expect.stringContaining("BoomRender"), + }); expect( Array.from(container.querySelectorAll("button")).some( (button) => button.textContent === "Reload page", @@ -119,4 +122,45 @@ describe("AppErrorBoundary", () => { root.unmount(); }); }); + + it("preserves the original DOM insertion error and its failing component", () => { + function BrokenDomInsertion() { + useEffect(() => { + document.createElement("div").insertBefore( + document.createElement("span"), document.createElement("span"), + ); + }, []); + return null; + } + const root = createRoot(container); + act(() => root.render()); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "NotFoundError" }), + { boundary: "app", componentStack: expect.stringContaining("BrokenDomInsertion") }, + ); + expect(container.textContent).toContain("Paperclip hit an error"); + act(() => root.unmount()); + }); + + it("reports the component when translation replaces a React-owned insertion anchor", () => { + function TranslatedStatus({ showIcon }: { showIcon: boolean }) { + return
{showIcon &&
; + } + const root = createRoot(container); + act(() => root.render()); + const parent = container.querySelector('[data-testid="translated-status"]')!; + // Translation replaces the text node; React still holds that old node as + // the insertion anchor when it later adds a sibling before the text. + const translated = document.createElement("font"); + translated.textContent = "Translated status"; + parent.replaceChild(translated, parent.firstChild!); + document.documentElement.classList.add("translated-ltr"); + act(() => root.render()); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "NotFoundError" }), + { boundary: "app", componentStack: expect.stringContaining("TranslatedStatus") }, + ); + expect(container.textContent).toContain("Reload page"); + act(() => root.unmount()); + }); }); diff --git a/ui/src/components/AppErrorBoundary.tsx b/ui/src/components/AppErrorBoundary.tsx index 68884f06141..671259edbdb 100644 --- a/ui/src/components/AppErrorBoundary.tsx +++ b/ui/src/components/AppErrorBoundary.tsx @@ -25,7 +25,7 @@ export class AppErrorBoundary extends Component<{ children: ReactNode }, AppErro override componentDidCatch(error: unknown, info: ErrorInfo): void { console.error("App shell crashed", { error, componentStack: info.componentStack }); - captureBrowserException(error); + captureBrowserException(error, { boundary: "app", componentStack: info.componentStack }); } override render() { diff --git a/ui/src/components/RouteErrorBoundary.test.tsx b/ui/src/components/RouteErrorBoundary.test.tsx index ba8f7d34cdd..6177da5f2a5 100644 --- a/ui/src/components/RouteErrorBoundary.test.tsx +++ b/ui/src/components/RouteErrorBoundary.test.tsx @@ -15,7 +15,7 @@ vi.mock("@/lib/router", () => ({ })); vi.mock("@/lib/sentry", () => ({ - captureBrowserException: (error: unknown) => captureBrowserExceptionMock(error), + captureBrowserException: (...args: unknown[]) => captureBrowserExceptionMock(...args), })); function Boom(): never { @@ -52,7 +52,9 @@ describe("RouteErrorBoundary", () => { }); expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); - expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error)); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error), { + boundary: "route", componentStack: expect.stringContaining("Boom"), + }); expect( Array.from(container.querySelectorAll("button")).some( (button) => button.textContent === "Go back", diff --git a/ui/src/components/RouteErrorBoundary.tsx b/ui/src/components/RouteErrorBoundary.tsx index 2c2bd9403aa..5787df15561 100644 --- a/ui/src/components/RouteErrorBoundary.tsx +++ b/ui/src/components/RouteErrorBoundary.tsx @@ -22,7 +22,7 @@ class RouteErrorBoundaryInner extends Component { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.documentElement.className = ""; +}); + +describe("browser error context", () => { + it("keeps React component names without locations or unrecognized content", () => { + const stack = ` + at TaskDetail (https://tenant.example/assets/app.js?token=private-value:42:7) + at div () + in AppShell (created by Router) +TaskPanel@https://tenant.example/tasks/private-task:12:3 + at ErrorBoundary (/workspace/private/src/Boundary.tsx:15:9) + at https://tenant.example/private-page:17:3 +unrecognized private content`; + expect(sanitizeComponentStack(stack)).toBe( + "\n at TaskDetail\n at div\n at AppShell\n at TaskPanel\n at ErrorBoundary", + ); + }); + + it("bounds malformed and oversized component stacks", () => { + expect(sanitizeComponentStack(` at ${"a".repeat(101)} (private)`)).toBeUndefined(); + expect(sanitizeComponentStack("x".repeat(16_384) + "\n at SecretSuffix")).toBeUndefined(); + expect(sanitizeComponentStack("\n at TaskDetail (private)".repeat(100))?.match(/at TaskDetail/g)).toHaveLength(40); + }); + + it.each([undefined, null, "", "private unrecognized data"])("omits absent or unrecognized stacks: %s", (stack) => { + expect(sanitizeComponentStack(stack)).toBeUndefined(); + }); + + it.each(["translated-ltr", "translated-rtl"])("records only a translation-marker boolean for %s", (marker) => { + document.documentElement.className = `${marker} private-customer-class`; + const result = buildBrowserErrorContext({ boundary: "route", componentStack: "\n at TaskDetail" }); + expect(result).toMatchObject({ + tags: { react_error_boundary: "route" }, + contexts: { react: { componentStack: "\n at TaskDetail" }, browser_state: { translation_marker: true } }, + }); + expect(JSON.stringify(result)).not.toContain("private-customer-class"); + }); + + it("captures a snapshot rather than reading changed DOM state later", () => { + vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + vi.spyOn(document, "readyState", "get").mockReturnValue("interactive"); + const result = buildBrowserErrorContext({ boundary: "app" }); + document.documentElement.classList.add("translated-ltr"); + expect(result.contexts.browser_state).toEqual({ + ready_state: "interactive", visibility_state: "hidden", translation_marker: false, + }); + }); + + it("keeps the component trace when browser-state reads throw", () => { + vi.spyOn(document, "readyState", "get").mockImplementation(() => { throw new Error("unavailable"); }); + vi.spyOn(document.documentElement, "classList", "get").mockImplementation(() => { throw new Error("unavailable"); }); + expect(buildBrowserErrorContext({ boundary: "app", componentStack: "\n at TaskDetail" })).toEqual({ + tags: { react_error_boundary: "app" }, + contexts: { + react: { componentStack: "\n at TaskDetail" }, + browser_state: { visibility_state: document.visibilityState }, + }, + }); + }); + + it("does not require a browser document", () => { + vi.stubGlobal("document", undefined); + expect(buildBrowserErrorContext({ boundary: "app" }).contexts.browser_state).toEqual({}); + }); +}); diff --git a/ui/src/lib/browser-error-context.ts b/ui/src/lib/browser-error-context.ts new file mode 100644 index 00000000000..b0b7b896448 --- /dev/null +++ b/ui/src/lib/browser-error-context.ts @@ -0,0 +1,57 @@ +export interface BrowserErrorDetails { + boundary: "app" | "route"; + componentStack?: string | null; +} + +const MAX_STACK_LENGTH = 16_384; +const MAX_COMPONENT_FRAMES = 40; + +/** Keep component names only: React stack locations can contain tenant URLs. */ +export function sanitizeComponentStack(stack: string | null | undefined): string | undefined { + if (!stack) return undefined; + const frames: string[] = []; + for (const line of stack.slice(0, MAX_STACK_LENGTH).split("\n").slice(0, MAX_COMPONENT_FRAMES + 1)) { + // V8/React and Firefox/Safari component-frame formats. Never retain the + // location, arguments, or an unrecognized line as a fallback. + const name = /^\s*(?:at|in) ([A-Za-z_$][\w$.-]{0,99})(?:\s|$)/.exec(line)?.[1] + ?? /^([A-Za-z_$][\w$.-]{0,99})@/.exec(line)?.[1]; + if (name) frames.push(` at ${name}`); + if (frames.length === MAX_COMPONENT_FRAMES) break; + } + return frames.length ? `\n${frames.join("\n")}` : undefined; +} + +/** A bounded snapshot, taken at the error rather than when the capture queue runs. */ +export function buildBrowserErrorContext(details: BrowserErrorDetails) { + const componentStack = sanitizeComponentStack(details.componentStack); + const browserState: { + ready_state?: "loading" | "interactive" | "complete"; + visibility_state?: "visible" | "hidden"; + translation_marker?: boolean; + } = {}; + if (typeof document !== "undefined") { + // Each read is optional. A DOM wrapper or extension must not prevent the + // original exception and component trace from reaching the error monitor. + try { + const state = document.readyState; + if (state === "loading" || state === "interactive" || state === "complete") { + browserState.ready_state = state; + } + } catch { /* diagnostic unavailable */ } + try { + const state = document.visibilityState; + if (state === "visible" || state === "hidden") browserState.visibility_state = state; + } catch { /* diagnostic unavailable */ } + try { + const classes = document.documentElement.classList; + browserState.translation_marker = classes.contains("translated-ltr") || classes.contains("translated-rtl"); + } catch { /* diagnostic unavailable */ } + } + return { + tags: { react_error_boundary: details.boundary }, + contexts: { + react: componentStack ? { componentStack } : {}, + browser_state: browserState, + }, + }; +} diff --git a/ui/src/lib/sentry.test.ts b/ui/src/lib/sentry.test.ts index a887cff0965..734be7edf19 100644 --- a/ui/src/lib/sentry.test.ts +++ b/ui/src/lib/sentry.test.ts @@ -276,6 +276,103 @@ describe("captureBrowserException", () => { expect(mocks.captureException).toHaveBeenCalledWith(expect.any(Error)); }); + + it("sends sanitized per-error context captured before the queue runs", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, captureBrowserException } = await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + document.documentElement.classList.add("translated-ltr"); + const error = new DOMException("insertBefore failed", "NotFoundError"); + captureBrowserException(error, { + boundary: "route", componentStack: "\n at TaskDetail (https://tenant.example/tasks/private-id?token=private-value:1:2)", + }); + document.documentElement.classList.remove("translated-ltr"); + await Promise.resolve(); + await Promise.resolve(); + expect(mocks.captureException).toHaveBeenCalledWith(error, { + tags: { react_error_boundary: "route" }, + contexts: { + react: { componentStack: "\n at TaskDetail" }, + browser_state: expect.objectContaining({ translation_marker: true }), + }, + }); + expect(JSON.stringify(mocks.captureException.mock.calls)).not.toContain("private-value"); + captureBrowserException(new Error("unrelated error")); + // Initialization is idempotent and provides a barrier on the same queue. + await initBrowserErrorMonitoring(DSN); + expect(mocks.captureException).toHaveBeenLastCalledWith(expect.any(Error)); + }); + + it("still captures the original error if component diagnostics fail", async () => { + const mocks = mockSentryPackage(); + const { initBrowserErrorMonitoring, captureBrowserException } = await importFreshSentry(); + await initBrowserErrorMonitoring(DSN); + const error = new Error("original error"); + expect(() => captureBrowserException(error, { + boundary: "app", + get componentStack(): string { throw new Error("diagnostic failure"); }, + })).not.toThrow(); + await Promise.resolve(); + await Promise.resolve(); + expect(mocks.captureException).toHaveBeenCalledWith(error); + }); +}); + +describe("browser error diagnostics with the real SDK", () => { + it("emits bounded component and translation context without page data or scope leakage", async () => { + const Sentry = await vi.importActual("@sentry/browser"); + const events: Array> = []; + vi.doMock("@sentry/browser", () => ({ + ...Sentry, + init: (options: Parameters[0]) => Sentry.init({ + ...options, + transport: () => ({ send: async () => ({}), flush: async () => true }), + beforeSend: (event) => { events.push(event as unknown as Record); return event; }, + }), + })); + const gate = await importFreshSentry(); + await gate.initBrowserErrorMonitoring(DSN, "staging"); + const previousPath = window.location.pathname + window.location.search; + window.history.replaceState({}, "", "/tasks/private-task?token=private-capability"); + document.documentElement.classList.add("translated-ltr", "private-customer-class"); + try { + gate.captureBrowserException(new DOMException("insertBefore failed", "NotFoundError"), { + boundary: "route", + componentStack: "\n at TaskDetail (https://tenant.example/tasks/private-task?token=private-capability:3:4)", + }); + document.documentElement.classList.remove("translated-ltr"); + gate.captureBrowserException(new Error("unrelated failure")); + await gate.initBrowserErrorMonitoring(DSN, "staging"); + await Sentry.flush(2000); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + environment: "staging", + tags: { react_error_boundary: "route" }, + contexts: { + react: { componentStack: "\n at TaskDetail" }, + browser_state: { translation_marker: true }, + }, + }); + expect(events[1]).not.toHaveProperty("contexts.react"); + expect(events[1]).not.toHaveProperty("contexts.browser_state"); + expect(events[1]).not.toHaveProperty("tags.react_error_boundary"); + for (const event of events) { + expect(event).not.toHaveProperty("request"); + expect(event.breadcrumbs).toBeUndefined(); + } + for (const secret of ["tenant.example", "private-task", "private-capability", "private-customer-class"]) { + expect(JSON.stringify(events)).not.toContain(secret); + } + await gate.teardownBrowserErrorMonitoring(); + gate.captureBrowserException(new Error("after sign-out"), { boundary: "app", componentStack: "\n at TaskDetail" }); + await gate.teardownBrowserErrorMonitoring(); + expect(events).toHaveLength(2); + } finally { + await gate.teardownBrowserErrorMonitoring(); + document.documentElement.classList.remove("translated-ltr", "private-customer-class"); + window.history.replaceState({}, "", previousPath); + } + }); }); /** diff --git a/ui/src/lib/sentry.ts b/ui/src/lib/sentry.ts index 0c3a8e6c63b..7d714793dca 100644 --- a/ui/src/lib/sentry.ts +++ b/ui/src/lib/sentry.ts @@ -38,6 +38,8 @@ // (`GlobalHandlers`), the two React error boundaries, deduplicates a repeat // event (`Dedupe`), and links a caused-by chain (`LinkedErrors`). +import { buildBrowserErrorContext, type BrowserErrorDetails } from "./browser-error-context"; + let queue: Promise = Promise.resolve(); /** Run gate operations one at a time, in call order. */ @@ -135,10 +137,17 @@ export function teardownBrowserErrorMonitoring(): Promise { * control flow. A no-op before the gate opens, when the gate never opens (no * DSN on the session), or when bootstrap failed. */ -export function captureBrowserException(error: unknown): void { +export function captureBrowserException(error: unknown, details?: BrowserErrorDetails): void { + let context: ReturnType | undefined; + try { + if (details) context = buildBrowserErrorContext(details); + } catch { + // Diagnostics must never replace the original exception or its recovery UI. + } void enqueue(async () => { try { - sentry?.captureException(error); + if (context) sentry?.captureException(error, context); + else sentry?.captureException(error); } catch (err) { // eslint-disable-next-line no-console console.error("[paperclip] Sentry captureBrowserException failed", err); diff --git a/ui/src/lib/vite-component-names.test.ts b/ui/src/lib/vite-component-names.test.ts new file mode 100644 index 00000000000..bdccf05b82d --- /dev/null +++ b/ui/src/lib/vite-component-names.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; +import { build } from "vite"; +import { expect, it } from "vitest"; + +it("keeps component names through the production bundle for React error traces", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-component-names-")); + try { + await mkdir(path.join(root, "public")); + await writeFile(path.join(root, "public/sw.js"), 'const buildId = "__PAPERCLIP_BUILD_ID__";'); + const entry = path.join(root, "entry.js"); + await writeFile(entry, ` + function DiagnosticComponent() { return null; } + class DiagnosticBoundary { render() { return null; } } + const DiagnosticArrow = () => null; + globalThis.componentNames = [DiagnosticComponent.name, DiagnosticBoundary.name, DiagnosticArrow.name]; + `); + const result = await build({ + configFile: fileURLToPath(new URL("../../vite.config.ts", import.meta.url)), + root, + mode: "production", + logLevel: "silent", + build: { + outDir: path.join(root, "dist"), + rolldownOptions: { input: entry, output: { format: "iife" } }, + }, + }); + const output = (Array.isArray(result) ? result[0] : result) as { output: Array<{ type: string; fileName: string }> }; + const chunk = output.output.find((item) => item.type === "chunk"); + expect(chunk).toBeDefined(); + const code = await readFile(path.join(root, "dist", chunk!.fileName), "utf8"); + const scope: { componentNames?: string[] } = {}; + runInNewContext(code, scope); + expect(scope.componentNames).toEqual(["DiagnosticComponent", "DiagnosticBoundary", "DiagnosticArrow"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 2ecc84ec774..6ac1be642c9 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -22,6 +22,9 @@ export default defineConfig(({ mode }) => ({ esbuild: mode === "production" ? { + // React's component trace uses function names. Keep those useful in + // error reports without publishing source maps or page context. + keepNames: true, drop: ["console", "debugger"], legalComments: "none", }