// Dev-only render counter — answers "what actually re-rendered, and why?". // // Loaded from `main.tsx` BEFORE `react-dom` (see the import-order note there). // That ordering is load-bearing: react-dom decides at module-init whether a // devtools hook exists, so installing after it has already initialised leaves // `bippy._renderers` empty and every commit goes unseen. // // Why not ``: React invokes `onRender` for EVERY Profiler in a // committed tree, including subtrees that bailed out. Counting those callbacks // "proves" the sidebar re-rendered when it did not. `actualDuration` is not a // discriminator either — a bailed-out subtree still reports a small nonzero // duration. `didFiberRender` is the honest signal. // // Why not react-scan: its `lite` subpath is a thin wrapper over bippy, while // the package pulls ~217 transitive deps (babel, preact) and floats // `react-grab`/`react-doctor` on `latest`, which makes installs // non-reproducible and breaks Vite with a JSON import-attribute error. import { didFiberRender, type Fiber, getDisplayName, instrument, isCompositeFiber, traverseRenderedFibers } from 'bippy' /** Why a component re-rendered, attributed per commit. */ export interface RenderRecord { /** Commits in which this component actually re-rendered (mount excluded). */ renders: number /** ...of those, how many had at least one changed prop reference. */ propsChanged: number /** ...of those, how many had changed hook state (useState/useMemo/store). */ stateChanged: number /** * ...of those, how many consumed a context whose value changed. `memo()` * cannot block these — the fix is to narrow or split the provider, not to * add a memo boundary. */ contextChanged: number /** * ...of those, how many had NEITHER changed props, changed state, nor a * changed context. These re-rendered purely because a parent did — the * wasted work a `memo()` or a narrower store subscription would eliminate. */ wasted: number /** Sum of `actualDuration` across counted renders, in ms. */ totalMs: number } const counts = new Map() let commits = 0 let recording = false // explain() state: while set, every wasted render of this component walks up // the fiber tree to find the ancestor whose props/state/context actually // changed — the origin of the cascade. let explainTarget: null | string = null const explainCauses = new Map() const blank = (): RenderRecord => ({ contextChanged: 0, propsChanged: 0, renders: 0, stateChanged: 0, totalMs: 0, wasted: 0 }) /** Did any prop's reference identity change between the two fiber versions? */ function propsChanged(fiber: Fiber): boolean { const prev = fiber.alternate?.memoizedProps as Record | null | undefined const next = fiber.memoizedProps as Record | null | undefined if (!prev || !next) { return false } for (const key of Object.keys(next)) { if (!Object.is(prev[key], next[key])) { return true } } return Object.keys(prev).length !== Object.keys(next).length } /** Did any hook's memoizedState change? Covers useState, useSyncExternalStore * (so nanostores `useStore`), useMemo, and useReducer alike. */ function stateChanged(fiber: Fiber): boolean { return changedHookIndices(fiber).length > 0 } /** Indices (source order) of the hooks whose memoizedState changed. The index * maps straight onto the component's hook call order, so "hook #3 changed" * identifies the exact useStore/useState line without guessing. */ function changedHookIndices(fiber: Fiber): number[] { let next: Fiber['memoizedState'] | null | undefined = fiber.memoizedState let prev: Fiber['memoizedState'] | null | undefined = fiber.alternate?.memoizedState const changed: number[] = [] let index = 0 while (next && prev) { if (!Object.is(next.memoizedState, prev.memoizedState)) { changed.push(index) } next = next.next prev = prev.next index += 1 } return changed } /** Names of the props whose identity changed — the cascade origin's smoking * gun. Used by explain() so the answer is "Streamdown (props: children)" and * not just "Streamdown (props)". */ function changedPropKeys(fiber: Fiber): string[] { const prev = fiber.alternate?.memoizedProps as Record | null | undefined const next = fiber.memoizedProps as Record | null | undefined if (!prev || !next) { return [] } const keys: string[] = [] for (const key of Object.keys(next)) { if (!Object.is(prev[key], next[key])) { keys.push(key) } } return keys } /** Did any consumed context value change? A `memo()` cannot block a re-render * caused by context, so distinguishing this from a parent-driven render is * the difference between "add a memo" and "split the provider". */ function contextChanged(fiber: Fiber): boolean { let dep = fiber.dependencies?.firstContext while (dep) { const context = dep.context as { _currentValue?: unknown } | undefined if (context && 'memoizedValue' in dep && !Object.is(dep.memoizedValue, context._currentValue)) { return true } dep = dep.next } return false } function record(fiber: Fiber) { const name = getDisplayName(fiber) if (!name) { return } const entry = counts.get(name) ?? blank() const props = propsChanged(fiber) const state = stateChanged(fiber) const context = contextChanged(fiber) entry.renders += 1 entry.totalMs += fiber.actualDuration ?? 0 if (props) { entry.propsChanged += 1 } if (state) { entry.stateChanged += 1 } if (context) { entry.contextChanged += 1 } if (!props && !state && !context) { entry.wasted += 1 // explain() support: walk UP from a wasted render to the TOP of the // cascade — the highest ancestor that also rendered this commit. That // fiber is the origin; its own changed props/state is the reason. // Stopping at the first ancestor with changed props is wrong: JSX rebuilt // by a parent makes every intermediate node report "children changed", // which is the symptom cascading down, not the cause. if (explainTarget && name === explainTarget) { let origin: Fiber = fiber let cursor = fiber.return let hops = 0 while (cursor && hops < 80) { if (isCompositeFiber(cursor) && didFiberRender(cursor)) { origin = cursor } cursor = cursor.return hops += 1 } const originName = getDisplayName(origin) ?? '?' const changed = changedPropKeys(origin).filter(k => k !== 'children') const why = origin === fiber ? 'self' : stateChanged(origin) ? `state (hooks #${changedHookIndices(origin).slice(0, 5).join(',#')})` : changed.length ? `props: ${changed.slice(0, 4).join(',')}` : contextChanged(origin) ? 'context' : changedPropKeys(origin).length ? 'children only' : 'no visible change (external store?)' const key = `${originName} (${why})` explainCauses.set(key, (explainCauses.get(key) ?? 0) + 1) } } counts.set(name, entry) } /** Rows sorted by wasted renders, then total renders — the fix list, in order. */ function report(limit = 40) { return [...counts.entries()] .map(([name, r]) => ({ name, ...r, totalMs: Math.round(r.totalMs * 100) / 100 })) .sort((a, b) => b.wasted - a.wasted || b.renders - a.renders) .slice(0, limit) } declare global { interface Window { __RENDER_COUNTS__?: { /** Per-component render attribution since the last `clear()`. */ counts: Map /** Commits observed since the last `clear()`. */ commits: () => number clear: () => void /** Start counting. Cheap no-op until called — zero cost while idle. */ start: () => void stop: () => void recording: () => boolean /** Sorted worst-offenders table; `console.table`-friendly. */ report: (limit?: number) => Array /** Attribution for one component by display name. */ get: (name: string) => RenderRecord | undefined /** * Name a component (its displayName, e.g. 'Block'), interact, then call * with no argument to get the tally of which CHANGED ancestor each of * its wasted renders cascaded from. The origin, walked — not guessed. */ explain: (name?: null | string) => Record | string } } } if (typeof window !== 'undefined' && !window.__RENDER_COUNTS__) { instrument({ onCommitFiberRoot(_id, root) { if (!recording) { return } commits += 1 traverseRenderedFibers(root, fiber => { if (isCompositeFiber(fiber) && didFiberRender(fiber)) { record(fiber) } }) } }) window.__RENDER_COUNTS__ = { clear: () => { counts.clear() commits = 0 }, commits: () => commits, counts, explain: name => { if (name !== undefined) { explainTarget = name explainCauses.clear() if (name && !recording) { recording = true } return name ? `explaining ${name} — interact, then call explain() to read` : 'explain off' } return Object.fromEntries([...explainCauses.entries()].sort((x, y) => y[1] - x[1])) }, get: name => counts.get(name), recording: () => recording, report, start: () => { counts.clear() commits = 0 recording = true }, stop: () => { recording = false } } }