fix(ui): reap React 19.2 performance-track measures (memory leak) (#9827)

## 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)
This commit is contained in:
Dotta 2026-07-18 14:05:25 -05:00 committed by GitHub
parent 14da75dfc7
commit 83765f08d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 104 additions and 0 deletions

View File

@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startPerfMeasureReaper } from "./perf-measure-reaper";
describe("startPerfMeasureReaper", () => {
let clearMeasures: ReturnType<typeof vi.fn>;
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();
});
});

View File

@ -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);
}

View File

@ -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");