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:
3 files changed
+211
-1
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user