test(perf): fiber-tree probe, and the client-side diagnosis it produced

probe-fiber-tree.mts walks the live React tree and reports which components are
mounted, what DOM they render and their props. Heap snapshots only give minified
names like `y`, and source maps do not help because the snapshot records runtime
names — props shape plus rendered DOM identifies a component reliably. It injects
a DevTools hook stub before load so React registers every root; climbing from one
DOM node's fiber missed most of the tree (2,093 fibers vs 3,918).

What it found, on a 28-widget board:

- The most-mounted component is @mantine/core/Box (513). It is the primitive every
  Mantine component renders through, so it is a symptom of how much is mounted
  rather than a bug. Proportions match the 650 MB session snapshot (13% vs 17%),
  so the minified `y` there is almost certainly Box.
- 71 Menu -> Popover -> PopoverDropdown triplets are mounted for context menus that
  are almost never opened. Isolated with the existing enableRightClickOnWidgets
  flag: 3,918 fibers with them, 3,554 without — 364 fibers, 9%, scaling with item
  count. The dropdown contents are already lazy; the wrappers are not.

A fix that deferred the Menu until first right-click was reverted: it broke
right-click and inflated the tree to 20,107 fibers, because the replayed
contextmenu event re-entered the arming handler. Doing it properly needs one
board-level menu instead of one per item. The Playwright check that caught this is
in the findings — a fiber count alone would have read as a win.

Also records that the demo account had completed_board_tour = 0, so earlier rounds
measured the first-time-user path; completing it moves the tree by 38 fibers, so
the published numbers stand.
This commit is contained in:
Thomas Camlong committed 2026-08-09 10:37:33 +02:00
1 parent 8f18b17207
commit fd7bcb2231
3 files changed
+225

No files matched your search

+63
View File
@@ -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 `<td>`, ~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:
+142
View File
@@ -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<string, unknown>).__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();
+20
View File
@@ -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 ` +