2c867b05ce
Driving HER real instance (real profile, real transcripts, streams live)
via CDP instead of synthetic tiles finally exposed the remaining stall.
The timeline on a real 60-frame sash drag:
style recalc 2736ms | script 1027ms | layout 89ms
top callsite: pin @ fallback.tsx — 927ms
Two pin-to-bottom ResizeObservers (the bounded tool window's and the
reasoning preview's) pinned on EVERY resize delivery. A sash drag changes
every message's WIDTH once per frame, so each frame ran scrollTop write ->
scrollHeight read across every tool group: a forced write-read reflow
cascade that the render counters could never see (zero React involvement).
Both pins are now height-gated off the RO entry (reflow-free): only
content GROWTH pins. Width-only deliveries return immediately.
Measured on the live app, same drag, before -> after:
fps 11.5 -> 59-60
p95 101ms -> 18ms
slow>33 60/60 -> 1/60
Also in this batch (each was verified live before the next was attempted):
- thread/list: split messageSignature into STRUCTURAL (ids/roles — keys
boundaries + row identity) and WEIGHT (part counts — budget only), and
memoize groups + row JSX. A streamed part-append re-rendered every
turn's boundary via its resetKey prop; explain() measured 540-865
wasted Block renders per drag/stream sample, now {}.
- message-render-boundary: document the structural-only resetKey contract.
- tool/fallback: memoize ToolFallback's part object + ToolEntry/ToolTitle/
ToolGlyph (151 renders each, 100% wasted, on real transcripts).
- use-message-stream: ADAPTIVE flush floor — next flush waits 3x the
measured cost of the last one (33ms floor, 250ms cap), so multi-stream
load degrades text update rate instead of input latency.
- tree-split: preview sash drags with inline flex on the two seam
wrappers, committing the store ONCE on release (fixed-zone sides get
flexBasis only, so a hidden sidebar can't leave a phantom gap).
- debug/: perf-live LoAF long-frame attribution, explain() cascade walker
with changed-hook indices, diag-real-loop/key-latency/switch-trace
probes that drive the real app over CDP.
Typing during 2 live streams: keystroke->paint p50 3.3ms, p95 18.4ms,
zero frames over 33ms. Session switch p50 ~35ms settled; the remaining
~1.3s outlier tail is streaming-session switches (React work-loop, not
style/layout) — next target.
309 lines
9.6 KiB
TypeScript
309 lines
9.6 KiB
TypeScript
// 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
|
|
}
|
|
}
|
|
}
|