perf(common): stop useTimeAgo re-rendering every second, add re-render profiler

measure-rerenders.mts counts React commits on an untouched board and attributes each
to the shallowest component that re-rendered, i.e. where the update entered the tree.

What it found: an idle board commits 73-96 times per minute, re-rendering
@mantine/core/Box over 30,000 times in 60s. That is the churn behind the 650 MB
browser peak — transient allocation rather than retention. About 80% of those commits
enter at the same anonymous depth-2 provider, props [value, children], value {appDir}:
Next.js's internal HeadManagerContext, whose value is a fresh object literal on every
App Router render, so each render re-renders the whole app beneath it. The rest enter
at __next_metadata_boundary__ / <title>, pointing the same way.

The trigger is inside Next's router/metadata handling — application code has no
polling router.refresh() — so nothing here fixes it. It is the next thing to chase and
it dominates everything downstream of it.

useTimeAgo called setState on a 1s interval even when the label was unchanged ("3
minutes ago" holds for a full minute), re-rendering its subtree ~60x/minute per
consumer for an identical string. It now compares before setting. Recorded as a
correctness fix and NOT a measured win: idle commits stayed inside their 73-96 range
afterwards, because the app-root churn dominates.

pnpm test 1794/1795 (pre-existing pi-hole Testcontainers flake); typecheck clean
across 37 packages; no new lint errors.
This commit is contained in:
Thomas Camlong committed 2026-08-09 15:18:38 +02:00
1 parent 9bebc28102
commit 63e65bbc6c
3 files changed
+211 -1

No files matched your search

+28
View File
@@ -213,6 +213,34 @@ payload to a stub hook that is byte-identical for profiling and production build
that probe returns a false negative for both. Confirm the Profiler tab with the real
extension.
## Where the re-render churn actually comes from
`scripts/benchmarks/measure-rerenders.mts` counts React commits on a board nobody is
touching, and attributes each commit to the shallowest component that re-rendered — the
point where the update entered the tree.
**An idle board commits 73–96 times per minute**, and in 60 s `@mantine/core/Box`
re-renders over 30,000 times. That is the churn behind the 650 MB peak: it is transient
allocation, not retention.
Roughly 80% of those commits enter at the *same place*: an anonymous provider at depth 2
whose props are `[value, children]` and whose value is `{appDir}` — Next.js's internal
`HeadManagerContext`. Its value is a fresh object literal on every App Router render, so
each one re-renders the entire application below it. The remainder enter around
`__next_metadata_boundary__` / `<title>`, which points the same way.
So the dominant cost is the **App Router root re-rendering about once a second**, not any
individual widget. No polling `router.refresh()` exists in application code, so the
trigger is inside Next's own router/metadata handling and is not fixed by anything in
this PR. That is the next thing to chase, and it is worth more than any further
component-level tuning: everything else is downstream of it.
`useTimeAgo` was calling `setState` every second even when the rendered label was
unchanged ("3 minutes ago" holds for a minute), so it now compares before setting. This
is a correctness fix — idle commits stayed within their 73–96 range afterwards, because
the app-root churn dominates and this hook is not what drives it. Recorded as *not* a
measured win.
## Measured but not pursued
| lever | measured cost | why not pursued |
+8 -1
View File
@@ -18,7 +18,14 @@ export const useTimeAgo = (timestamp: Date, updateFrequency = 1000) => {
}, [timestamp]);
useEffect(() => {
const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), updateFrequency);
// Only re-render when the rendered text actually changes. The label is coarse
// ("3 minutes ago" holds for a whole minute) but the timer ticks every second,
// so setting state unconditionally re-rendered the subtree ~60x/minute per
// consumer for an identical string.
const intervalId = setInterval(() => {
const next = calculateTimeAgo(timestamp);
setTimeAgo((previous) => (previous === next ? previous : next));
}, updateFrequency);
return () => clearInterval(intervalId); // clear interval on hook unmount
}, [timestamp, updateFrequency]);
+175
View File
@@ -0,0 +1,175 @@
/**
* 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 };
(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;
// 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" });
await page.locator("[data-homarr-dev-benchmark-board]").waitFor({ state: "visible", timeout: 120_000 });
await page
.waitForFunction(
() => {
const items = [...document.querySelectorAll('[data-type="item"]')];
return (
items.length > 0 &&
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;
};
return {
commits: state.commits,
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)`,
);
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();