mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## 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)
39 lines
1.7 KiB
TypeScript
39 lines
1.7 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|