mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +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)
86 lines
3.3 KiB
TypeScript
86 lines
3.3 KiB
TypeScript
import * as React from "react";
|
|
import { StrictMode } from "react";
|
|
import * as ReactDOM from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { BrowserRouter } from "@/lib/router";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { App } from "./App";
|
|
import { CompanyProvider, useCompany } from "./context/CompanyContext";
|
|
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
|
|
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
|
import { PanelProvider } from "./context/PanelContext";
|
|
import { SidebarProvider } from "./context/SidebarContext";
|
|
import { DialogProvider } from "./context/DialogContext";
|
|
import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext";
|
|
import { ToastProvider } from "./context/ToastContext";
|
|
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");
|
|
});
|
|
}
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 30_000,
|
|
// Explicit so cross-tab-published cache entries for resources this tab
|
|
// isn't observing get collected promptly rather than lingering. Single
|
|
// tuning point if we need to trim the cache footprint further.
|
|
gcTime: 5 * 60_000,
|
|
refetchOnWindowFocus: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
function CompanyAwareBreadcrumbProvider({ children }: { children: React.ReactNode }) {
|
|
const { selectedCompany } = useCompany();
|
|
return <BreadcrumbProvider companyName={selectedCompany?.name ?? null}>{children}</BreadcrumbProvider>;
|
|
}
|
|
|
|
createRoot(document.getElementById("root")!).render(
|
|
<StrictMode>
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider>
|
|
<BrowserRouter>
|
|
<CompanyProvider>
|
|
<EditorAutocompleteProvider>
|
|
<ToastProvider>
|
|
<LiveUpdatesProvider>
|
|
<TooltipProvider>
|
|
<CompanyAwareBreadcrumbProvider>
|
|
<SidebarProvider>
|
|
<PanelProvider>
|
|
<PluginLauncherProvider>
|
|
<DialogProvider>
|
|
<App />
|
|
</DialogProvider>
|
|
</PluginLauncherProvider>
|
|
</PanelProvider>
|
|
</SidebarProvider>
|
|
</CompanyAwareBreadcrumbProvider>
|
|
</TooltipProvider>
|
|
</LiveUpdatesProvider>
|
|
</ToastProvider>
|
|
</EditorAutocompleteProvider>
|
|
</CompanyProvider>
|
|
</BrowserRouter>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>
|
|
</StrictMode>
|
|
);
|