From 63e65bbc6c3ea7ade0a01290e43117570afefaf9 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 15:18:30 +0200 Subject: [PATCH] perf(common): stop useTimeAgo re-rendering every second, add re-render profiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__ / , 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. --- PHASE2-FINDINGS.md | 28 ++++ packages/common/src/hooks.ts | 9 +- scripts/benchmarks/measure-rerenders.mts | 175 +++++++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 scripts/benchmarks/measure-rerenders.mts diff --git a/PHASE2-FINDINGS.md b/PHASE2-FINDINGS.md index 2bb4b9031..9e549fab1 100644 --- a/PHASE2-FINDINGS.md +++ b/PHASE2-FINDINGS.md @@ -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 | diff --git a/packages/common/src/hooks.ts b/packages/common/src/hooks.ts index 969c04c43..ecae6faa0 100644 --- a/packages/common/src/hooks.ts +++ b/packages/common/src/hooks.ts @@ -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]); diff --git a/scripts/benchmarks/measure-rerenders.mts b/scripts/benchmarks/measure-rerenders.mts new file mode 100644 index 000000000..bfbb394e1 --- /dev/null +++ b/scripts/benchmarks/measure-rerenders.mts @@ -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();