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:
parent
14da75dfc7
commit
83765f08d1
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue