From 83765f08d10246dbe94f3bb8ed59ad4a57d7f5a5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:05:25 -0500 Subject: [PATCH] fix(ui): reap React 19.2 performance-track measures (memory leak) (#9827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Its web UI is a long-lived single-page app; users leave tabs open for hours/days > - We chased multi-GB tab memory growth through several fixes (#9569, #9624, #9627, #9701) that cut real churn — but the footprint kept climbing > - A heap-snapshot diff on a 12-hour tab finally showed the true cause: of 13.2M heap nodes, **12.1M were `PerformanceMeasure` objects** — React 19.2 emits a `performance.measure()` per component render for its DevTools "Performance Tracks" and never clears them > - These are native objects, so `performance.memory` never reported them (it read a flat ~74 MB while the real heap was ~308 MB), which is why our earlier heap/DOM sampling looked stable while the footprint ballooned > - This pull request periodically clears the User Timing measure buffer, since nothing in the app consumes it > - The benefit is that long-lived tabs stop accumulating millions of native `PerformanceMeasure` objects, eliminating the remaining unbounded growth ## Linked Issues or Issue Description No public GitHub issue exists; describing inline per CONTRIBUTING.md → "Link Issues or Describe Them In-PR", following the bug report template. This is the root-cause fix for the memory growth chased in #9569 / #9624 / #9627 / #9701. **What happened?** Long-lived browser tabs grew to multiple GB of memory footprint over hours/days. A heap snapshot of a 12h tab showed **13.2M nodes, of which 12.1M were `PerformanceMeasure`** (vs ~1.0M total nodes on a fresh tab). Live capture showed **~340 `performance.measure()` calls/sec**, named after React components with a `detail.devtools` payload — React 19.2's "Performance Tracks". Nothing ever clears them, so they accumulate without bound. **Expected behavior** A long-lived tab should not accumulate millions of `PerformanceMeasure` entries. Idle/long-running tabs should hold a bounded footprint. **Steps to reproduce** Open the app (React 19.2 production build), leave a tab open with normal activity, and run `performance.getEntriesByType('measure').length` periodically — it climbs unbounded (12.3M after ~12h). `performance.clearMeasures()` drops it to near-zero and frees the memory. **Paperclip version or commit** Branch `fix/react-perf-measure-leak`, off `master` (after #9701). **Deployment mode** Local dev (`pnpm dev` build served by the dev instance), web UI. React 19.2.7. Not adapter-specific. ## What Changed - **`ui/src/lib/perf-measure-reaper.ts` (new)** — `startPerfMeasureReaper(intervalMs = 10_000)` clears `performance.clearMeasures()` on a timer and returns a stop function. It never calls `getEntriesByType('measure')` (which would materialize the huge buffer). Honors a `window.__paperclipKeepPerfMeasures = true` opt-out so a developer recording a React Performance Track in DevTools can keep the entries. - **`ui/src/main.tsx`** — start the reaper at app boot. - Only *measures* are cleared — React's tracks pass explicit start/end times and leave no marks, and the app doesn't use the User Timing API at all (verified), so nothing else is affected. ## Verification - **Root cause proven** by heap-snapshot diff: fresh tab ~1.0M nodes vs 12h tab 13.2M nodes, 12.1M of them `PerformanceMeasure`; live capture showed ~340 measures/sec with `detail.devtools` and React component names. - **Fix proven live**: `performance.clearMeasures()` on the aged tab dropped the buffer from **12,418,266 → 1,500** and reclaimed the memory (heap `perf.memory` 308 → 269 MB, plus the ~12M native objects, which are the bulk of the footprint). - `vitest`: `perf-measure-reaper.test.ts` — interval clearing, `stop()`, opt-out flag, and no-API no-op. All pass. - `tsc -b` clean. ## Risks Very low, client-only. - Clearing the User Timing measure buffer only affects the DevTools Performance panel's React track *history*; normal users never consume it. Developers who want to record it can set `window.__paperclipKeepPerfMeasures = true`. - Only `performance.clearMeasures()` is called (not `clearMarks`), so any mark-based timing elsewhere is untouched; a grep confirmed the app makes no `performance.mark()/measure()` calls of its own. - Adds exactly one 10s interval (negligible), and `clearMeasures()` does not materialize the buffer. Note: this is a React 19.2 upstream behavior (its performance tracks are emitted in the production build and never cleared). If React later gates or clears them, this reaper can be removed. ## Model Used - **Provider:** Anthropic, via the Claude Code CLI. - **Model:** Claude Opus 4.8 (`claude-opus-4-8`). - **Reasoning mode:** Extended thinking enabled. - **Capabilities used:** tool use (shell, file editing), the Chrome DevTools MCP to reproduce and profile, and a streaming heap-snapshot parser to identify the `PerformanceMeasure` accumulation. ## 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 (continues #9701; no duplicates) - [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 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 considered and documented any risks above - [ ] I have updated relevant documentation to reflect my changes (N/A — no user-facing docs; rationale documented inline) - [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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ui/src/lib/perf-measure-reaper.test.ts | 60 ++++++++++++++++++++++++++ ui/src/lib/perf-measure-reaper.ts | 38 ++++++++++++++++ ui/src/main.tsx | 6 +++ 3 files changed, 104 insertions(+) create mode 100644 ui/src/lib/perf-measure-reaper.test.ts create mode 100644 ui/src/lib/perf-measure-reaper.ts diff --git a/ui/src/lib/perf-measure-reaper.test.ts b/ui/src/lib/perf-measure-reaper.test.ts new file mode 100644 index 0000000000..6fad304408 --- /dev/null +++ b/ui/src/lib/perf-measure-reaper.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startPerfMeasureReaper } from "./perf-measure-reaper"; + +describe("startPerfMeasureReaper", () => { + let clearMeasures: ReturnType; + let original: typeof performance.clearMeasures; + + beforeEach(() => { + vi.useFakeTimers(); + clearMeasures = vi.fn(); + original = performance.clearMeasures; + performance.clearMeasures = clearMeasures as unknown as typeof performance.clearMeasures; + delete (globalThis as { __paperclipKeepPerfMeasures?: boolean }).__paperclipKeepPerfMeasures; + }); + + afterEach(() => { + performance.clearMeasures = original; + vi.useRealTimers(); + delete (globalThis as { __paperclipKeepPerfMeasures?: boolean }).__paperclipKeepPerfMeasures; + }); + + it("clears measures on each interval", () => { + const stop = startPerfMeasureReaper(10_000); + expect(clearMeasures).not.toHaveBeenCalled(); + vi.advanceTimersByTime(10_000); + expect(clearMeasures).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(10_000); + expect(clearMeasures).toHaveBeenCalledTimes(2); + stop(); + }); + + it("stop() halts further clearing", () => { + const stop = startPerfMeasureReaper(10_000); + vi.advanceTimersByTime(10_000); + expect(clearMeasures).toHaveBeenCalledTimes(1); + stop(); + vi.advanceTimersByTime(50_000); + expect(clearMeasures).toHaveBeenCalledTimes(1); + }); + + it("skips clearing while the opt-out flag is set (e.g. profiling)", () => { + const stop = startPerfMeasureReaper(10_000); + (globalThis as { __paperclipKeepPerfMeasures?: boolean }).__paperclipKeepPerfMeasures = true; + vi.advanceTimersByTime(30_000); + expect(clearMeasures).not.toHaveBeenCalled(); + (globalThis as { __paperclipKeepPerfMeasures?: boolean }).__paperclipKeepPerfMeasures = false; + vi.advanceTimersByTime(10_000); + expect(clearMeasures).toHaveBeenCalledTimes(1); + stop(); + }); + + it("is a no-op when performance.clearMeasures is unavailable", () => { + performance.clearMeasures = undefined as unknown as typeof performance.clearMeasures; + const stop = startPerfMeasureReaper(10_000); + vi.advanceTimersByTime(30_000); + // nothing to assert other than: it did not throw and returns a callable stop + expect(typeof stop).toBe("function"); + stop(); + }); +}); diff --git a/ui/src/lib/perf-measure-reaper.ts b/ui/src/lib/perf-measure-reaper.ts new file mode 100644 index 0000000000..c62a2f0e53 --- /dev/null +++ b/ui/src/lib/perf-measure-reaper.ts @@ -0,0 +1,38 @@ +/** + * React 19.2 emits a `performance.measure()` for (nearly) every component render + * to populate its DevTools "Performance Tracks" (the entries carry + * `detail.devtools` and are named after components). React never clears them, so + * on a long-lived tab they accumulate into *millions* of native + * `PerformanceMeasure` entries — gigabytes of memory that `performance.memory` + * does not even report. On a busy Paperclip tab this was measured at ~340 + * measures/sec, reaching 12M+ entries after ~12h and dominating the tab's + * footprint. + * + * Nothing in this app uses the User Timing API, so we periodically clear the + * buffer. Confirmed live: a single `clearMeasures()` dropped a 12.4M-entry + * buffer to ~1.5k and reclaimed the memory. We only clear measures (React's + * tracks pass explicit start/end times and leave no marks), so mark-based timing + * elsewhere is unaffected. + * + * Set `window.__paperclipKeepPerfMeasures = true` to keep them — e.g. while + * recording a React Performance Track in the DevTools Performance panel. + */ +const DEFAULT_INTERVAL_MS = 10_000; + +interface PerfMeasureReaperGlobal { + __paperclipKeepPerfMeasures?: boolean; +} + +export function startPerfMeasureReaper(intervalMs: number = DEFAULT_INTERVAL_MS): () => void { + if (typeof performance === "undefined" || typeof performance.clearMeasures !== "function") { + return () => {}; + } + const reap = () => { + if ((globalThis as PerfMeasureReaperGlobal).__paperclipKeepPerfMeasures) return; + // clearMeasures() is cheap and does not materialize the (huge) buffer — + // unlike getEntriesByType('measure'), so never call that to gate on size. + performance.clearMeasures(); + }; + const id = setInterval(reap, intervalMs); + return () => clearInterval(id); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 39a37bd5c5..3da35e44c8 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -17,11 +17,17 @@ import { ThemeProvider } from "./context/ThemeContext"; import { TooltipProvider } from "@/components/ui/tooltip"; import { initPluginBridge } from "./plugins/bridge-init"; import { PluginLauncherProvider } from "./plugins/launchers"; +import { startPerfMeasureReaper } from "./lib/perf-measure-reaper"; import "@mdxeditor/editor/style.css"; import "./index.css"; initPluginBridge(React, ReactDOM); +// React 19.2 emits an unbounded stream of performance.measure() entries for its +// DevTools performance tracks and never clears them; on a long-lived tab they +// accumulate into millions of native objects (GBs). Reap them periodically. +startPerfMeasureReaper(); + if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker.register("/sw.js");