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.
143 lines
5.6 KiB
TypeScript
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();
|