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 ` +
|