Corrects the largest wrong claim in this work. Earlier rounds reported that ~80% of idle React commits entered at Next's internal HeadManagerContext and that "the App Router root re-renders about once a second", and named it the highest-value next step. Both are false. Measuring the same idle churn against boards of different sizes: board items idle commits/min hey 0 0.0 footbol 3 0.0 default 28 76.0 A 3-item board commits zero times per minute. If the router re-rendered on a timer, every board would churn regardless of its contents. The original attribution was a tooling artefact. measure-rerenders.mts decides a fiber re-rendered from fiber.actualDuration, which only a React *profiling* build populates. On a production build every fiber fails that test and both attribution tables come back empty - and an empty table reads as "nothing re-rendered" rather than "this build cannot tell you". The harness now says which case it is, and waits for the board container to be attached rather than visible so a zero-item board can be measured at all - that control is what disproved the claim. Adds measure-widget-churn.mts, which attributes churn on a production build via DOM mutations keyed to the nearest [data-type="item"] ancestor. On the 28-item board, idle for 45s: 470 beszelSystemStats 64% 77 beszelSystemTable 11% 52 beszelSystemGrid 7% 22 dockerContainers 3% 93 (outside any widget) 13% Three Beszel widgets are 82% of all idle churn, one of them 64% on its own at 10.4 mutations per second. Also logs what Next.js itself costs, since it was assumed to be the problem: its loaded source is a flat 3.47 MiB (364 framework files + 32 vendored) and does not grow at all between boot and a fully loaded multi-tab session, while Homarr's own chunks grow from 3.73 to 15.44 MiB. Next is 16.1 MiB on disk; the largest things it vendors are its next-server runtime (1.60), @edge-runtime (0.85), edge-runtime (0.51) and compression (0.30). The framework is not where this application's memory goes.
96 lines
4.3 KiB
TypeScript
96 lines
4.3 KiB
TypeScript
/**
|
|
* Attributes idle re-render churn to individual widgets, on a production build.
|
|
*
|
|
* `measure-rerenders.mts` counts React commits but cannot say which component caused them:
|
|
* attribution needs `fiber.actualDuration`, which only a React profiling build populates.
|
|
* DOM mutations need no such thing. Every re-render that changes anything observable shows up
|
|
* as a mutation, and the mutated node's nearest `[data-type="item"]` ancestor names the widget.
|
|
*
|
|
* This is what established that idle churn is widget-driven rather than app-shell driven: a
|
|
* board with 3 items commits 0 times per minute while a 28-item board commits 76.
|
|
*
|
|
* STRESS_BASE_URL=http://127.0.0.1:PORT STRESS_BOARD=default \
|
|
* node --experimental-strip-types scripts/benchmarks/measure-widget-churn.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 observeMs = Number(process.env.CHURN_OBSERVE_MS ?? 45_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(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: "attached", timeout: 120_000 });
|
|
await page
|
|
.waitForFunction(
|
|
() => {
|
|
const items = [...document.querySelectorAll('[data-type="item"]')];
|
|
return 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"));
|
|
|
|
// Let the initial render storm finish before counting steady-state churn.
|
|
await page.waitForTimeout(5_000);
|
|
|
|
const result = await page.evaluate(async (durationMs) => {
|
|
/** Names a widget from whatever the item element carries, falling back to its classes. */
|
|
const describe = (item: Element | null) => {
|
|
if (!item) return "(outside any widget)";
|
|
const attributes = [...item.attributes]
|
|
.filter((attribute) => /kind|widget|type|id/i.test(attribute.name) && attribute.value.length < 60)
|
|
.map((attribute) => `${attribute.name}=${attribute.value}`);
|
|
const marker = item.querySelector("[class*='-widget'],[class*='widget-']");
|
|
const className = marker?.className;
|
|
const hint = typeof className === "string" ? className.split(/\s+/).find((part) => part.includes("widget")) : "";
|
|
return `${hint ?? ""} ${attributes.join(" ")}`.trim() || "(unidentified item)";
|
|
};
|
|
|
|
const counts = new Map<string, number>();
|
|
let total = 0;
|
|
const observer = new MutationObserver((records) => {
|
|
for (const record of records) {
|
|
total++;
|
|
const node = record.target.nodeType === 1 ? (record.target as Element) : record.target.parentElement;
|
|
const key = describe(node?.closest('[data-type="item"]') ?? null);
|
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
}
|
|
});
|
|
observer.observe(document.body, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
characterData: true,
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
observer.disconnect();
|
|
|
|
return {
|
|
total,
|
|
itemCount: document.querySelectorAll('[data-type="item"]').length,
|
|
byWidget: [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20),
|
|
};
|
|
}, observeMs);
|
|
|
|
console.log(`\nboard "${boardName}": ${result.itemCount} items, observed idle for ${observeMs / 1000}s`);
|
|
console.log(`DOM mutations while idle: ${result.total} (${((result.total / observeMs) * 60_000).toFixed(0)}/min)\n`);
|
|
console.log(`${"mutations".padStart(10)} widget`);
|
|
for (const [name, count] of result.byWidget) console.log(`${String(count).padStart(10)} ${name.slice(0, 100)}`);
|
|
|
|
await browser.close();
|