diff --git a/PHASE2-FINDINGS.md b/PHASE2-FINDINGS.md index 8e28478cd..43bc73132 100644 --- a/PHASE2-FINDINGS.md +++ b/PHASE2-FINDINGS.md @@ -203,6 +203,69 @@ were public. Each run now asserts all three: Earlier rounds were correct but only *inferred* this from the private board rendering; the assertions now fail the run instead of quietly measuring an anonymous page. +## Round 4 — the client, which the container numbers never showed + +A real session reached ~880 MB server and a **650 MB browser heap**. The harness had +been measuring one tab on one board with no interaction, so it saw none of this. + +### What the 650 MB actually is + +From the captured `.heapsnapshot` (4M nodes, 18.2M edges): + +- **71,596 live React Fibers** (~35k components). Identified structurally, not by name: + the retainer edges are `alternate`, `return`, `child` and `__reactFiber$…`. +- ~7,900 SVG element fibers (charts and icons), 1,726 ``, ~88k event listeners. +- **Retained heap is only 181 MiB** against a 650 MB peak. Snapshots are taken after a + forced GC, so the rest is transient allocation churn from re-rendering — not + retention. The trace agrees: **71,850 `UpdateLayer` events**, 41 s in `v8.callFunction`. + +So the client cost is render volume and re-render churn. Tuning `gcTime` would not +have touched it. + +### The most-mounted component + +`@mantine/core/Box` (513 of 3,918 fibers on a 28-widget board). It is Mantine's +universal primitive — every Mantine component renders through it — so it is a symptom +of how many components are mounted, not a bug in itself. Matching proportions +(13% here, 17% in the 650 MB snapshot) make it near-certain that the minified `y` in +that snapshot is `Box`. + +The genuinely suspicious finding was **71 `Menu` → `Popover` → `PopoverDropdown` +triplets** mounted on a board that shows 28 widgets. A controlled experiment using the +existing `enableRightClickOnWidgets` user flag isolates the cost: + +| board state | fibers | +| --- | --- | +| per-item context menus on | 3,918 | +| per-item context menus off | 3,554 | + +**364 fibers, 9%, for menus that are almost never opened** — and it scales with item +count. The dropdown *contents* are already lazy; the `Menu`/`Popover`/`PopoverDropdown` +wrappers are not. + +### Attempted fix, reverted + +Deferring the whole `Menu` subtree until the first right-click (replaying the event so +Mantine still positions at the cursor) **failed verification and was reverted**: the +replayed `contextmenu` re-entered the arming handler, so right-click stopped opening the +menu and the tree exploded from 3,614 to 20,107 fibers. The Playwright check that caught +it is the one worth keeping — a fiber count alone would have looked like a win. + +Doing this properly means one board-level menu that renders the active item's actions, +rather than one menu per item — a real refactor, not a wrapper swap. + +### A measurement flaw worth recording + +The `demo` account in the backup had `completed_board_tour = 0`, so every run before +this round was measuring the **first-time-user path with the onboarding tour mounted**. +Completing the tour changed the tree by only 38 fibers, so the earlier numbers stand — +but the harness was not measuring steady state, and that was luck rather than design. + +### Server side + +A server heap snapshot pulled from a live container: **37 of 70 MiB is strings** +(71,287 of them), consistent with the memoised translation payload. Unexplored. + ## Recommendation **Keep** — carries the measured win: diff --git a/scripts/benchmarks/probe-fiber-tree.mts b/scripts/benchmarks/probe-fiber-tree.mts new file mode 100644 index 000000000..e208a9000 --- /dev/null +++ b/scripts/benchmarks/probe-fiber-tree.mts @@ -0,0 +1,142 @@ +/** + * Walks the live React tree in a running Homarr board and reports which component + * types are mounted most, what DOM they render and which props they take. + * + * Minified builds give heap snapshots useless names like `y`, and source maps do + * not help because the snapshot records runtime function names. Props shape plus + * rendered DOM identifies a component far more reliably than its mangled name. + * + * STRESS_BASE_URL=http://127.0.0.1:32800 \ + * node --experimental-strip-types scripts/benchmarks/probe-fiber-tree.mts + */ +import { chromium } from "@playwright/test"; + +const baseUrl = process.env.STRESS_BASE_URL; +if (!baseUrl) throw new Error("STRESS_BASE_URL is required"); +const username = process.env.STRESS_USERNAME ?? "demo"; +const password = process.env.STRESS_PASSWORD ?? "demodemo"; +const boardName = process.env.STRESS_BOARD ?? "default"; + +const browser = await chromium.launch(); +const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } }); + +// React only registers its roots if the DevTools hook exists *before* it loads, so +// inject a minimal stub. Climbing from one DOM node's fiber misses roots rendered +// into separate containers (portals, modals), which undercounts the tree badly. +await context.addInitScript(() => { + const roots = new Set(); + (globalThis as unknown as Record).__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + renderers: new Map(), + supportsFiber: true, + inject: () => 1, + onCommitFiberRoot: (_id: unknown, root: unknown) => roots.add(root), + onCommitFiberUnmount: () => undefined, + onPostCommitFiberRoot: () => undefined, + __homarrRoots: roots, + }; +}); + +const page = await context.newPage(); + +await page.goto(`${baseUrl}/auth/login`, { waitUntil: "networkidle" }); +await page.getByLabel("Username").fill(username); +await page.locator("#password").fill(password); +await page.locator("css=button[type='submit']").click(); +await page.waitForFunction(() => !window.location.pathname.includes("/auth/login"), undefined, { timeout: 60_000 }); + +await page.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" }); +await page.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "visible", timeout: 120_000 }); +await page + .waitForFunction( + () => { + const items = [...document.querySelectorAll('[data-type="item"]')]; + return ( + items.length > 0 && + items.every( + (i) => i.querySelector("[data-homarr-widget-ready]") || i.querySelector("[data-homarr-widget-error]"), + ) + ); + }, + undefined, + { timeout: 120_000 }, + ) + .catch(() => console.log("warning: not all widgets settled")); + +const report = await page.evaluate(() => { + const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__; + const roots = [...(hook?.__homarrRoots ?? [])].map((root) => root.current).filter(Boolean); + if (roots.length === 0) { + // Fall back to climbing from a DOM node if the hook was installed too late. + const host = document.querySelector("[data-homarr-dev-benchmark-board]") ?? document.body; + const fiberKey = Object.keys(host).find((k) => k.startsWith("__reactFiber$")); + if (!fiberKey) return { error: "no React fiber and no registered roots" }; + let node = host[fiberKey]; + while (node.return) node = node.return; + roots.push(node); + } + + const stats = new Map(); + const seen = new Set(); + const walk = (fiber, depth) => { + if (!fiber || seen.has(fiber) || depth > 3000) return; + seen.add(fiber); + const type = fiber.type; + let key; + if (typeof type === "string") key = `<${type}>`; + else if (typeof type === "function") key = `fn:${type.displayName || type.name || "(anonymous)"}`; + else if (type && typeof type === "object") + key = `obj:${type.displayName || type.$$typeof?.toString?.() || "(object)"}`; + else key = String(type); + + let entry = stats.get(key); + if (!entry) { + entry = { count: 0, propKeys: new Map(), domTags: new Map(), sampleHtml: null, ownerChain: null }; + stats.set(key, entry); + } + entry.count++; + if (fiber.memoizedProps && entry.count <= 60) { + for (const p of Object.keys(fiber.memoizedProps)) entry.propKeys.set(p, (entry.propKeys.get(p) ?? 0) + 1); + } + // Nearest host element this component produced. + let host = fiber; + for (let i = 0; i < 6 && host && !(host.stateNode instanceof Element); i++) host = host.child; + if (host?.stateNode instanceof Element) { + const tag = host.stateNode.tagName.toLowerCase(); + entry.domTags.set(tag, (entry.domTags.get(tag) ?? 0) + 1); + if (!entry.sampleHtml) entry.sampleHtml = host.stateNode.outerHTML.slice(0, 220); + } + if (!entry.ownerChain) { + const chain = []; + let up = fiber.return; + for (let i = 0; i < 8 && up; i++, up = up.return) { + const t = up.type; + if (typeof t === "function") chain.push(t.displayName || t.name || "(anon)"); + else if (typeof t === "string") chain.push(t); + } + entry.ownerChain = chain; + } + + walk(fiber.child, depth + 1); + walk(fiber.sibling, depth); + }; + for (const root of roots) walk(root, 0); + + const top = [...stats.entries()] + .sort((a, b) => b[1].count - a[1].count) + .slice(0, 18) + .map(([key, v]) => ({ + key, + count: v.count, + topProps: [...v.propKeys.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 12) + .map(([p]) => p), + domTags: [...v.domTags.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5), + sampleHtml: v.sampleHtml, + ownerChain: v.ownerChain, + })); + return { rootCount: roots.length, totalFibers: seen.size, top }; +}); + +console.log(JSON.stringify(report, null, 2)); +await browser.close(); diff --git a/scripts/benchmarks/stress-restore.mts b/scripts/benchmarks/stress-restore.mts index a020afba0..8f00ca0a9 100644 --- a/scripts/benchmarks/stress-restore.mts +++ b/scripts/benchmarks/stress-restore.mts @@ -44,6 +44,8 @@ const navIterations = Number(process.env.STRESS_NAV_ITERATIONS ?? 6); const soakMs = Number(process.env.STRESS_SOAK_MS ?? 120_000); const soakIntervalMs = Number(process.env.STRESS_SOAK_INTERVAL_MS ?? 20_000); const keepContainer = process.env.STRESS_KEEP_CONTAINER === "true"; +/** Dump a browser heap snapshot here (analyse with analyze-react-fibers.mjs). */ +const clientSnapshotPath = process.env.STRESS_CLIENT_SNAPSHOT; const outputDirectory = path.resolve(process.env.STRESS_OUTPUT ?? `.bench/stress/${label}`); const password = process.env.STRESS_PASSWORD ?? "demodemo"; @@ -297,6 +299,22 @@ const interactWithWidgetsAsync = async (page: Page, maxClicks = 6) => { return { available, clicked }; }; +/** Streams a browser heap snapshot to disk over CDP, for offline fiber analysis. */ +const captureClientSnapshotAsync = async (page: Page, destination: string) => { + const cdp = await page.context().newCDPSession(page); + const chunks: string[] = []; + cdp.on("HeapProfiler.addHeapSnapshotChunk", ({ chunk }) => chunks.push(chunk)); + try { + await cdp.send("HeapProfiler.enable"); + await cdp.send("HeapProfiler.collectGarbage"); + await cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false }); + await writeFile(destination, chunks.join("")); + log(`client heap snapshot -> ${destination}`); + } finally { + await cdp.detach().catch(() => undefined); + } +}; + const waitForBoardAsync = async (page: Page) => { const boardSelector = "[data-homarr-dev-benchmark-board]"; await page.locator(boardSelector).waitFor({ state: "visible", timeout: 120_000 }); @@ -444,6 +462,8 @@ const main = async () => { await captureAsync("07-multi-tab"); } + if (clientSnapshotPath) await captureClientSnapshotAsync(freshPage, clientSnapshotPath); + const clientHeap = await sampleClientHeapAsync(freshPage); log( `client heap: used=${toMiB(clientHeap.jsHeapUsedBytes)} MiB total=${toMiB(clientHeap.jsHeapTotalBytes)} MiB ` +