Files
homarr/scripts/benchmarks/probe-fiber-tree.mts
T
Thomas Camlong fd7bcb2231 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.
2026-08-09 10:37:33 +02:00

143 lines
5.6 KiB
TypeScript

/**
* 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();