Commit graph

3 commits

Author SHA1 Message Date
Brooklyn Nicholson
2c867b05ce perf(desktop): 60fps sash drag on real sessions — height-gate the RO pins
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.
2026-07-27 01:07:48 -05:00
Brooklyn Nicholson
f7aee9dc8c fix(desktop): make render-churn measure streaming, not boot churn
Two problems, both found by distrusting the harness's own numbers.

1. The scenario slept a fixed 1s after mounting tabs, then recorded. Boot
   and session hydration are not reliably done by then, so a variable
   amount of unrelated work landed inside the measurement window. Three
   back-to-back runs on identical code spread 2.2x on total_renders and
   3.8x on wasted_renders — wide enough that a single-run before/after
   delta could be mostly noise. Replaced with a quiesce gate that waits
   for commits to hold still before recording, and reports 'quiet:N' or
   'timeout:...' so a contaminated run is visible instead of silent.

2. The counter attributed a context-driven re-render as 'wasted', which
   pointed at memo() as the fix when memo cannot block context at all.
   Adds contextChanged via the fiber's context dependency list, and
   excludes it from wasted.

The gate also turned up a finding worth more than the fix: with five busy
tiles and NO driver running, the renderer still commits ~18x/sec. The
report now names the cascade roots (own state changed, props did not)
rather than leaving them to be guessed at — Streamdown re-renders itself
105 times while idle, which is what drives Block/Ct.
2026-07-26 15:54:18 -05:00
Brooklyn Nicholson
44211f3608 feat(desktop): dev-only render + store churn counters
Adds two dev-only counters that attribute re-renders and store
notifications during an interaction, so a perf claim can be answered with
a number instead of a hunch:

  window.__RENDER_COUNTS__  what re-rendered, and why (props/state/parent)
  window.__ATOM_CHURN__     which store published it, and whether it mattered

The `wasted` column in each is the fix list — components that re-rendered
with no changed input, and stores that published a value equal to the
last one. Both are inert until start(), so idle cost is one branch per
commit and per notify.

React 19.2 removed injectProfilingHooks from react-dom, so the mark*
profiling family is unavailable and onCommitFiberRoot is the only channel
left. <Profiler> can't answer the question either: React invokes onRender
for every Profiler in a committed tree including subtrees that bailed
out, and a bailed-out subtree still reports nonzero actualDuration. This
uses bippy's didFiberRender instead. bippy over react-scan because
react-scan/lite is a thin wrapper over it while the package pulls ~217
transitive deps and floats two on latest.

main.tsx imports the entry statically above react-dom because react-dom
captures the devtools hook at module init — a late install reports
renderers=0 and observes zero commits. Production exclusion is handled by
a build-time alias to a no-op module rather than tree-shaking, since a
static side-effect import can't be eliminated.
2026-07-26 06:10:54 -05:00