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).
86 lines
3.8 KiB
JavaScript
86 lines
3.8 KiB
JavaScript
/**
|
|
* Rendered rows versus visible rows, per widget.
|
|
*
|
|
* A dashboard tile shows a handful of rows at a time, but a widget that maps over its whole result
|
|
* set renders every row into the DOM regardless. Those off-screen rows cost nodes, fibers and heap
|
|
* while painting nothing, and totals like "9,293 DOM nodes" never reveal it — only the ratio does.
|
|
*
|
|
* Rows are found by looking for the deepest container whose children are numerous and similarly
|
|
* sized, rather than by guessing at the markup: widgets nest differently and a fixed selector finds
|
|
* nothing.
|
|
*
|
|
* STRESS_BASE_URL=http://127.0.0.1:PORT node scripts/benchmarks/measure-widget-overdraw.mjs
|
|
*/
|
|
import { chromium } from "@playwright/test";
|
|
|
|
const baseUrl = process.env.STRESS_BASE_URL;
|
|
if (!baseUrl) throw new Error("STRESS_BASE_URL is required");
|
|
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();
|
|
|
|
await page.goto(`${baseUrl}/auth/login`, { waitUntil: "networkidle" });
|
|
await page.getByLabel("Username").fill(process.env.STRESS_USERNAME ?? "demo");
|
|
await page.locator("#password").fill(process.env.STRESS_PASSWORD ?? "demodemo");
|
|
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: "attached", timeout: 120_000 });
|
|
await page.waitForTimeout(8_000);
|
|
|
|
const result = await page.evaluate(() => {
|
|
const report = [];
|
|
for (const item of document.querySelectorAll('[data-type="item"]')) {
|
|
const kind = item.getAttribute("data-kind") ?? "?";
|
|
const tile = item.getBoundingClientRect();
|
|
|
|
// The repeated-row container is whichever element has the most children that are all roughly
|
|
// the same height — that is what a mapped list looks like regardless of wrapper markup.
|
|
let best = null;
|
|
for (const element of item.querySelectorAll("*")) {
|
|
const children = [...element.children];
|
|
if (children.length < 4) continue;
|
|
const heights = children.map((c) => c.getBoundingClientRect().height).filter((h) => h > 4);
|
|
if (heights.length < 4) continue;
|
|
const min = Math.min(...heights);
|
|
const max = Math.max(...heights);
|
|
if (max > min * 3) continue; // not a uniform list
|
|
if (!best || children.length > best.rows) {
|
|
best = { rows: children.length, container: element };
|
|
}
|
|
}
|
|
if (!best) continue;
|
|
|
|
const rows = [...best.container.children];
|
|
const visible = rows.filter((row) => {
|
|
const r = row.getBoundingClientRect();
|
|
return r.bottom > tile.top && r.top < tile.bottom && r.height > 0;
|
|
});
|
|
const nodes = item.querySelectorAll("*").length;
|
|
report.push({
|
|
kind,
|
|
rows: rows.length,
|
|
visibleRows: visible.length,
|
|
nodes,
|
|
nodesPerRow: Math.round(nodes / rows.length),
|
|
wastedNodes: Math.round((nodes / rows.length) * (rows.length - visible.length)),
|
|
});
|
|
}
|
|
return report.sort((a, b) => b.wastedNodes - a.wastedNodes);
|
|
});
|
|
|
|
console.log(
|
|
`${"nodes".padStart(6)} ${"rows".padStart(5)} ${"vis".padStart(4)} ${"n/row".padStart(6)} ${"offscreen".padStart(10)} kind`,
|
|
);
|
|
let wasted = 0;
|
|
for (const r of result) {
|
|
wasted += r.wastedNodes;
|
|
console.log(
|
|
`${String(r.nodes).padStart(6)} ${String(r.rows).padStart(5)} ${String(r.visibleRows).padStart(4)} ${String(r.nodesPerRow).padStart(6)} ${String(r.wastedNodes).padStart(10)} ${r.kind}`,
|
|
);
|
|
}
|
|
console.log(`\nDOM elements rendered for rows that are scrolled out of view: ~${wasted}`);
|
|
await browser.close();
|