Files
homarr/scripts/benchmarks/measure-rerenders.mts
T
Thomas Camlong 9f1a15b9bf docs(findings): the App Router churn claim was wrong; idle churn is Beszel
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.
2026-08-10 17:45:12 +02:00

201 lines
9.0 KiB
TypeScript

/**
* Counts React commits and per-component re-renders on an idle board.
*
* The 650 MB browser heap was mostly transient allocation, i.e. re-render churn rather
* than retention, so the number that matters is how often components re-render while
* nobody is touching the page. Hooks into the devtools commit callback and attributes
* each commit to the components that actually re-rendered in it.
*
* STRESS_BASE_URL=http://127.0.0.1:PORT \
* node --experimental-strip-types scripts/benchmarks/measure-rerenders.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.RERENDER_OBSERVE_MS ?? 60_000);
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
await context.addInitScript(() => {
const state = {
commits: 0,
byComponent: new Map<string, number>(),
roots: new Map<string, number>(),
started: 0,
/**
* Whether any fiber carried `actualDuration`. React only populates it in a profiling
* build, so on a production build every fiber fails the re-render test and the
* attribution comes back empty — which reads as "nothing re-rendered" rather than
* "this build cannot tell you". Tracked so the report can say which it is.
*/
sawTimings: false,
};
(globalThis as unknown as Record<string, unknown>).__homarrRenderStats = state;
const nameOf = (fiber: { type?: unknown; elementType?: unknown }) => {
const type = (fiber.type ?? fiber.elementType) as
| string
| { displayName?: string; name?: string }
| null
| undefined;
if (typeof type === "string") return `<${type}>`;
if (typeof type === "function" || (type && typeof type === "object")) {
return (
(type as { displayName?: string; name?: string }).displayName ??
(type as { name?: string }).name ??
"(anonymous)"
);
}
return null;
};
(globalThis as unknown as Record<string, unknown>).__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
renderers: new Map(),
supportsFiber: true,
inject: () => 1,
onCommitFiberUnmount: () => undefined,
onPostCommitFiberRoot: () => undefined,
onCommitFiberRoot: (_id: unknown, root: { current?: unknown }) => {
if (!state.started) return;
state.commits++;
// Walk only fibers React flagged as updated in this commit.
const seen = new Set<unknown>();
// Shallowest re-rendering component in this commit — i.e. where the update
// entered the tree. Everything below it re-rendered as a consequence.
let shallowest: { name: string; depth: number; fiber: Record<string, unknown> } | null = null;
const walk = (fiber: Record<string, unknown> | null | undefined, depth: number) => {
if (!fiber || seen.has(fiber) || depth > 2000) return;
seen.add(fiber);
const alternate = fiber.alternate as Record<string, unknown> | null;
if (fiber.actualDuration !== undefined) state.sawTimings = true;
// A fiber re-rendered if it committed new work over a previous version.
if (alternate && fiber.actualDuration !== undefined && (fiber.actualDuration as number) > 0) {
const name = nameOf(fiber as never);
if (name) {
state.byComponent.set(name, (state.byComponent.get(name) ?? 0) + 1);
if (!shallowest || depth < shallowest.depth) shallowest = { name, depth, fiber };
}
}
walk(fiber.child as never, depth + 1);
walk(fiber.sibling as never, depth);
};
walk((root.current ?? null) as never, 0);
if (shallowest) {
const found = shallowest as { name: string; depth: number; fiber?: Record<string, unknown> };
// Anonymous components are common; the ancestor chain identifies them.
const chain: string[] = [];
let up = found.fiber?.return as Record<string, unknown> | undefined;
for (let i = 0; i < 8 && up; i++, up = up.return as Record<string, unknown> | undefined) {
const name = nameOf(up as never);
if (name) chain.push(name);
}
// At the very top the ancestor chain is empty, so describe it by what it
// renders and which props it takes instead.
const descendants: string[] = [];
let down = found.fiber?.child as Record<string, unknown> | undefined;
for (let i = 0; i < 6 && down; i++, down = down.child as Record<string, unknown> | undefined) {
const name = nameOf(down as never);
if (name) descendants.push(name);
}
const props = Object.keys((found.fiber?.memoizedProps as object) ?? {}).slice(0, 8);
// For a Context.Provider the value's shape names the context far better
// than a minified component name does.
const rawValue = (found.fiber?.memoizedProps as { value?: unknown } | undefined)?.value;
const valueShape =
rawValue && typeof rawValue === "object"
? `value{${Object.keys(rawValue).slice(0, 10).join(",")}}`
: `value:${typeof rawValue}`;
const key =
`${found.name} @depth${found.depth}` +
` props[${props.join(",")}] ${valueShape}` +
` > ${descendants.join(" > ")}` +
(chain.length ? ` < ${chain.join(" < ")}` : "");
state.roots.set(key, (state.roots.get(key) ?? 0) + 1);
}
},
__start: () => {
state.started = Date.now();
state.commits = 0;
state.byComponent.clear();
state.roots.clear();
},
};
});
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" });
// Attached, not visible: a board with zero items renders this container hidden, and the
// empty-board case is exactly the control needed to tell shell churn from widget churn.
await page.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "attached", timeout: 120_000 });
await page
.waitForFunction(
() => {
const items = [...document.querySelectorAll('[data-type="item"]')];
// An empty board legitimately has zero items; only require settling if any exist.
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);
await page.evaluate(() => {
(globalThis as unknown as Record<string, { __start: () => void }>).__REACT_DEVTOOLS_GLOBAL_HOOK__.__start();
});
console.log(`observing an idle board for ${observeMs / 1000}s (no interaction)...`);
await page.waitForTimeout(observeMs);
const stats = await page.evaluate(() => {
const state = (
globalThis as unknown as Record<string, { commits: number; byComponent: Map<string, number>; started: number }>
).__homarrRenderStats as unknown as {
commits: number;
byComponent: Map<string, number>;
roots: Map<string, number>;
started: number;
sawTimings: boolean;
};
return {
commits: state.commits,
sawTimings: (state as unknown as { sawTimings: boolean }).sawTimings,
elapsedMs: Date.now() - state.started,
top: [...state.byComponent.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20),
roots: [...state.roots.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12),
};
});
const perMinute = (stats.commits / stats.elapsedMs) * 60_000;
console.log(
`\ncommits while idle: ${stats.commits} over ${(stats.elapsedMs / 1000).toFixed(0)}s (${perMinute.toFixed(1)}/min)`,
);
if (!stats.sawTimings) {
// Without this the empty tables below read as "nothing re-rendered", which is the
// opposite of the truth when the commit count is high.
console.log(
`\nNO PER-COMPONENT ATTRIBUTION: this is a production React build, which does not populate` +
`\nfiber.actualDuration. The commit count above is still valid. For attribution, build with` +
`\n docker build --build-arg HOMARR_PROFILING=true . -t homarr:performance`,
);
} else {
console.log("\nupdate entered the tree at (commit roots):");
for (const [name, count] of stats.roots) console.log(String(count).padStart(8), ` ${name}`);
console.log("\nre-renders by component:");
for (const [name, count] of stats.top) console.log(String(count).padStart(8), ` ${name}`);
}
console.log(`\n${JSON.stringify({ commits: stats.commits, perMinute: Number(perMinute.toFixed(1)) })}`);
await browser.close();