File size: 9,793 Bytes
28ad89a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | // 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 `<Profiler>`: 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<string, RenderRecord>()
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<string, number>()
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<string, unknown> | null | undefined
const next = fiber.memoizedProps as Record<string, unknown> | 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<string, unknown> | null | undefined
const next = fiber.memoizedProps as Record<string, unknown> | 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<string, RenderRecord>
/** 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<RenderRecord & { name: string }>
/** 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, number> | 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
}
}
}
|