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).
80 lines
4.0 KiB
TypeScript
80 lines
4.0 KiB
TypeScript
/**
|
|
* Measures what off-screen rendering actually costs the browser: image requests, bytes fetched,
|
|
* and time spent in style, layout and paint.
|
|
*
|
|
* DOM node counts and JS heap do not move when `content-visibility` is applied — nothing leaves the
|
|
* DOM — so measuring those says nothing about it. What changes is whether the browser fetches,
|
|
* decodes and rasterises content that is scrolled out of view, and decoded bitmaps are far larger
|
|
* in memory than the files they came from.
|
|
*
|
|
* STRESS_BASE_URL=http://127.0.0.1:PORT STRESS_BOARD=default \
|
|
* node --experimental-strip-types scripts/benchmarks/measure-board-paint.mts
|
|
*/
|
|
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 settleMs = Number(process.env.PAINT_SETTLE_MS ?? 10_000);
|
|
|
|
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 });
|
|
|
|
// Count image traffic only for the board navigation, not the login page.
|
|
const images: { url: string; bytes: number }[] = [];
|
|
page.on("response", (response) => {
|
|
const type = response.request().resourceType();
|
|
if (type !== "image") return;
|
|
const length = Number(response.headers()["content-length"] ?? 0);
|
|
images.push({ url: response.url(), bytes: Number.isFinite(length) ? length : 0 });
|
|
});
|
|
|
|
const client = await context.newCDPSession(page);
|
|
await client.send("Performance.enable");
|
|
|
|
const startedAt = Date.now();
|
|
await page.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
|
|
await page.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "attached", 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],[data-homarr-widget-error]"));
|
|
},
|
|
undefined,
|
|
{ timeout: 120_000 },
|
|
)
|
|
.catch(() => console.log("warn: widgets did not all settle"));
|
|
const settledMs = Date.now() - startedAt;
|
|
|
|
// Give lazy loading and raster a chance to do (or skip) their work.
|
|
await page.waitForTimeout(settleMs);
|
|
|
|
const metrics = await client.send("Performance.getMetrics");
|
|
const metric = (name: string) => metrics.metrics.find((entry) => entry.name === name)?.value ?? 0;
|
|
|
|
const totalBytes = images.reduce((sum, image) => sum + image.bytes, 0);
|
|
const mediaImages = images.filter((image) => /image|poster|backdrop|tmdb|thetvdb|fanart|_next\/image/i.test(image.url));
|
|
|
|
console.log(`\n=== board "${boardName}" ===`);
|
|
console.log(` all widgets settled ${settledMs} ms`);
|
|
console.log(` image requests ${images.length}`);
|
|
console.log(` of those, media artwork ${mediaImages.length}`);
|
|
console.log(` image bytes (declared) ${(totalBytes / 1048576).toFixed(2)} MiB`);
|
|
console.log(`\n time in style recalc ${(metric("RecalcStyleDuration") * 1000).toFixed(0)} ms`);
|
|
console.log(` time in layout ${(metric("LayoutDuration") * 1000).toFixed(0)} ms`);
|
|
console.log(` script duration ${(metric("ScriptDuration") * 1000).toFixed(0)} ms`);
|
|
console.log(` task duration ${(metric("TaskDuration") * 1000).toFixed(0)} ms`);
|
|
console.log(` JS heap used ${(metric("JSHeapUsedSize") / 1048576).toFixed(1)} MiB`);
|
|
console.log(` DOM nodes ${metric("Nodes")}`);
|
|
console.log(` layout objects ${metric("LayoutObjects")}`);
|
|
|
|
await browser.close();
|