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
    }
  }
}