Second instance of the pattern fixed in mediaReleases. The overdraw tool, corrected to
find row containers by child count rather than by uniform height, found only one other
widget genuinely rendering more than its tile can show:
nodes rows vis offscr imgs tile content kind
2951 80 7 2693 80 539 7206 mediaReleases (already fixed)
393 20 6 275 60 405 1512 mediaRequests-requestList
1,512 px of cards in a 405 px tile, and 20 cards carrying 60 images between them - each
card renders a full-tile backdrop plus a poster. Same treatment: content-visibility with
contain-intrinsic-size on the card, loading="lazy" on both images.
IMPORTANT: this is typechecked but **not measured**. Every other performance claim in this
branch was verified by an A/B before being committed; this one was not, because the session
ended first. It is committed so the work is not lost, and it is the first thing to verify
next - the same measurement used for mediaReleases applies:
STRESS_BASE_URL=http://127.0.0.1:PORT node --experimental-strip-types \
scripts/benchmarks/measure-board-paint.mts
Expect image requests to fall; do not expect JS heap to move, since decoded bitmaps live
outside JSHeapUsedSize.
Every other widget on the board fits its tile and needs nothing. The tool's "visible"
column misreads chart widgets (beszelSystemStats reports 28 rows / 0 visible because its
container is an SVG whose groups have no layout height) - content height versus tile height
is the trustworthy signal, and by that measure only these two overdraw.
88 lines
4.1 KiB
JavaScript
88 lines
4.1 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.
|
|
*
|
|
* The row container is found as the element with the most children, which is what a mapped list
|
|
* looks like whatever the surrounding markup. An earlier version required children to be of similar
|
|
* height; that reported a chart's SVG groups as rows and missed the worst widget entirely, because
|
|
* its cards vary in height.
|
|
*
|
|
* 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
|
|
.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"));
|
|
await page.waitForTimeout(6_000);
|
|
|
|
const rows = await page.evaluate(() => {
|
|
const report = [];
|
|
for (const item of document.querySelectorAll('[data-type="item"]')) {
|
|
const tile = item.getBoundingClientRect();
|
|
let container = null;
|
|
for (const element of item.querySelectorAll("*")) {
|
|
if (!container || element.children.length > container.children.length) container = element;
|
|
}
|
|
if (!container || container.children.length < 4) continue;
|
|
|
|
const children = [...container.children];
|
|
const visible = children.filter((child) => {
|
|
const rect = child.getBoundingClientRect();
|
|
return rect.height > 0 && rect.bottom > tile.top && rect.top < tile.bottom;
|
|
});
|
|
const nodes = item.querySelectorAll("*").length;
|
|
const perRow = nodes / children.length;
|
|
report.push({
|
|
kind: item.getAttribute("data-kind") ?? "?",
|
|
rendered: children.length,
|
|
visible: visible.length,
|
|
nodes,
|
|
offscreenNodes: Math.round(perRow * (children.length - visible.length)),
|
|
images: item.querySelectorAll("img").length,
|
|
contentPx: Math.round(container.scrollHeight),
|
|
tilePx: Math.round(tile.height),
|
|
contained: getComputedStyle(children[0]).contentVisibility ?? "",
|
|
});
|
|
}
|
|
return report.sort((a, b) => b.offscreenNodes - a.offscreenNodes);
|
|
});
|
|
|
|
console.log(
|
|
`\n${"nodes".padStart(6)} ${"rows".padStart(5)} ${"vis".padStart(4)} ${"offscr".padStart(7)} ${"imgs".padStart(5)} ${"tile".padStart(6)} ${"content".padStart(8)} kind`,
|
|
);
|
|
let offscreen = 0;
|
|
for (const row of rows) {
|
|
offscreen += row.offscreenNodes;
|
|
console.log(
|
|
`${String(row.nodes).padStart(6)} ${String(row.rendered).padStart(5)} ${String(row.visible).padStart(4)} ${String(row.offscreenNodes).padStart(7)} ${String(row.images).padStart(5)} ${String(row.tilePx).padStart(6)} ${String(row.contentPx).padStart(8)} ${row.kind}${row.contained === "auto" ? " [content-visibility: auto]" : ""}`,
|
|
);
|
|
}
|
|
console.log(`\nDOM elements rendered for rows scrolled out of view: ~${offscreen}`);
|
|
await browser.close();
|