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:
3 files changed
+225
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
@@ -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 ` +
|
||||
|
||||
Reference in new issue
Block a user