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