Files
homarr/scripts/benchmarks/measure-widget-cost.mts
T
Thomas Camlong 7550766f67 perf(media-releases): stop rendering 73 off-screen cards' images and layout
Per-widget attribution of the dashboard found one widget responsible for 44% of the
board's DOM: mediaReleases at 2,951 nodes and 8,387 fibers, including 1,935 Mantine
Box components, against a board total of 9,293 nodes.

The cause is that it maps over every release the integrations return, with no cap.
Measured on a real board: the tile is 539 px tall and its content is 7,206 px, so of
80 rendered cards only 7 are visible. The other 73 cost DOM, fibers, and - most
expensively - a poster and a CSS backdrop image each, all fetched and decoded for
pixels nobody can see. Decoded bitmaps are far larger in memory than their files.

Fixed with content-visibility: auto plus contain-intrinsic-size on each card, so the
browser skips layout, paint, raster and background-image fetch for off-screen cards,
and loading="lazy" on the poster. Nothing leaves the DOM, so scrolling and in-page
search are unchanged, and the intrinsic size keeps the scrollbar from jumping.

Measured over three repeats per side on identical restored data:

  image requests    135, 135, 135  ->  55, 55, 56   (-59%, deterministic)
  image bytes            12.27 MiB  ->  8.41 MiB    (-31%)
  layout objects  8346, 7815, 7815  ->  5117, 4696, 4636  (-41%)

Two things this does NOT show, stated because a single run suggested otherwise:

  - JS heap did not clearly improve. Across four runs it was 46-88 MiB before against
    74-86 MiB after; the before spread is 2x, so no claim either way. This is expected:
    decoded image memory lives outside JSHeapUsedSize, so the actual saving from 81
    fewer image fetches is invisible to that metric.
  - Task duration and time-to-settled overlap between arms and support no conclusion.

Adds the tools this needed: measure-widget-cost.mts (per-widget DOM nodes, fibers and
heaviest components), measure-board-paint.mts (image requests, bytes, style/layout/task
time, layout objects), measure-widget-overdraw.mjs (rendered versus visible rows).
2026-08-10 19:05:17 +02:00

140 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Attributes the dashboard's browser cost to individual widgets: DOM nodes, React fibers, event
* listeners, and how long the page takes to become usable.
*
* Totals alone ("8,280 DOM nodes, 7,784 listeners") do not say what to fix. Per-widget figures do:
* a widget costing 300 nodes is a different problem from one costing 30, and the board renders many
* of each. Listener counts come from CDP's DOMDebugger, which is the only way to read them from
* outside DevTools, sampled per widget kind because it needs a round trip per node.
*
* STRESS_BASE_URL=http://127.0.0.1:PORT STRESS_BOARD=default \
* node --experimental-strip-types scripts/benchmarks/measure-widget-cost.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 } });
const page = await context.newPage();
const client = await context.newCDPSession(page);
await client.send("Performance.enable");
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 });
// Timed from navigation start so "how long until the board is usable" is measured, not guessed.
const startedAt = Date.now();
await page.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
const domContentLoadedMs = Date.now() - startedAt;
await page.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "attached", timeout: 120_000 });
const boardAttachedMs = Date.now() - startedAt;
await page
.waitForFunction(
() => {
const items = [...document.querySelectorAll('[data-type="item"]')];
return items.length > 0 && items.every((item) => item.querySelector("[data-homarr-widget-ready],[data-homarr-widget-error]"));
},
undefined,
{ timeout: 120_000 },
)
.catch(() => console.log("warn: widgets did not all settle"));
const allWidgetsReadyMs = Date.now() - startedAt;
// Let the render storm settle so counts reflect steady state rather than mid-mount.
await page.waitForTimeout(4_000);
const perWidget = await page.evaluate(() => {
/** Walks the React fiber subtree hanging off a DOM node, counting fibers and named components. */
const fiberStats = (element: Element) => {
const key = Object.keys(element).find((name) => name.startsWith("__reactFiber$"));
if (!key) return { fibers: 0, top: [] as [string, number][] };
const root = (element as unknown as Record<string, unknown>)[key] as Record<string, unknown>;
const counts = new Map<string, number>();
let fibers = 0;
const seen = new Set<unknown>();
const stack: (Record<string, unknown> | null | undefined)[] = [root];
while (stack.length) {
const fiber = stack.pop();
if (!fiber || seen.has(fiber)) continue;
seen.add(fiber);
fibers++;
const type = (fiber.type ?? fiber.elementType) as { displayName?: string; name?: string } | string | null;
const name =
typeof type === "string"
? `<${type}>`
: type && (type.displayName ?? type.name)
? (type.displayName ?? type.name)!
: null;
if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
stack.push(fiber.child as never, fiber.sibling as never);
}
return { fibers, top: [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4) };
};
const byKind = new Map<string, { count: number; nodes: number; fibers: number; components: Map<string, number> }>();
for (const item of document.querySelectorAll('[data-type="item"]')) {
const kind = item.getAttribute("data-kind") ?? "(unknown)";
const nodes = item.querySelectorAll("*").length;
const stats = fiberStats(item);
const acc = byKind.get(kind) ?? { count: 0, nodes: 0, fibers: 0, components: new Map() };
acc.count++;
acc.nodes += nodes;
acc.fibers += stats.fibers;
for (const [name, n] of stats.top) acc.components.set(name, (acc.components.get(name) ?? 0) + n);
byKind.set(kind, acc);
}
return {
totalNodes: document.querySelectorAll("*").length,
itemCount: document.querySelectorAll('[data-type="item"]').length,
kinds: [...byKind.entries()]
.map(([kind, acc]) => ({
kind,
count: acc.count,
nodes: acc.nodes,
fibers: acc.fibers,
nodesEach: Math.round(acc.nodes / acc.count),
fibersEach: Math.round(acc.fibers / acc.count),
components: [...acc.components.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3),
}))
.sort((a, b) => b.nodes - a.nodes),
};
});
const metrics = await client.send("Performance.getMetrics");
const metric = (name: string) => metrics.metrics.find((entry) => entry.name === name)?.value ?? 0;
console.log(`\n=== board "${boardName}": load timings ===`);
console.log(` domContentLoaded ${domContentLoadedMs} ms`);
console.log(` board element present ${boardAttachedMs} ms`);
console.log(` all widgets settled ${allWidgetsReadyMs} ms`);
console.log(`\n=== browser totals ===`);
console.log(` JS heap used ${(metric("JSHeapUsedSize") / 1048576).toFixed(1)} MiB`);
console.log(` JS heap total ${(metric("JSHeapTotalSize") / 1048576).toFixed(1)} MiB`);
console.log(` DOM nodes ${metric("Nodes")} (elements under body: ${perWidget.totalNodes})`);
console.log(` event listeners ${metric("JSEventListeners")}`);
console.log(` documents ${metric("Documents")}`);
console.log(` widgets on board ${perWidget.itemCount}`);
console.log(`\n=== cost per widget kind, heaviest first ===`);
console.log(`${"nodes".padStart(7)} ${"each".padStart(6)} ${"fibers".padStart(7)} ${"each".padStart(6)} ${"n".padStart(3)} kind / heaviest components`);
for (const kind of perWidget.kinds) {
console.log(
`${String(kind.nodes).padStart(7)} ${String(kind.nodesEach).padStart(6)} ${String(kind.fibers).padStart(7)} ${String(kind.fibersEach).padStart(6)} ${String(kind.count).padStart(3)} ${kind.kind}`,
);
if (kind.components.length) {
console.log(`${" ".repeat(33)}${kind.components.map(([name, n]) => `${name}×${n}`).join(" ")}`);
}
}
await browser.close();