Skip to content

perf(ui): memoize the turn derivation chain and TurnView - #472

Merged
jackwener merged 1 commit into
mainfrom
perf/turn-memo
Jul 3, 2026
Merged

perf(ui): memoize the turn derivation chain and TurnView#472
jackwener merged 1 commit into
mainfrom
perf/turn-memo

Conversation

@jackwener

Copy link
Copy Markdown
Member

React 性能队列第 2 项(roadmap §4.1,vercel 70 rules 全读的落地)—— 流式打字不重渲染旧 turn 的核心:

问题

五个派生(visibleMessages/chat/storedTools/tools/turns)每次 render 全部重建 → 纯文本流式(每秒几十次更新的最热路径)中所有 turn 对象身份全变 → 整个 transcript 每 token 重渲染。

修复

  • 派生链五连 useMemo(依赖 messages/tools):文本流式完全不触碰;工具活动更新仍正当失效
  • memo(TurnView) + 两个函数 props 走 ref 背书的稳定 wrapper(advanced-use-latest 模式)—— 上游 handler 重建无法击穿 memo;per-turn 内联闭包移入 TurnView(签名带 turnId)

验证

1696/1696;turn-control-history / streaming-answer fixture 无回归。

vercel react-best-practices rerender rules (round-2 full read), the
core of 'streaming never re-renders old turns':

- visibleMessages/chat/storedTools/tools/turns were derived fresh on
  EVERY render, so during plain-text streaming (dozens of updates/sec,
  the hottest path) every turn object changed identity and the whole
  transcript re-rendered per token. All five derivations are now
  useMemo'd on (messages, tools) — text streaming leaves them
  untouched; tool-activity updates still invalidate legitimately.
- TurnView is memo()'d. Its two function props route through
  identity-stable wrappers backed by refs (advanced-use-latest), so
  upstream handler recreation cannot pierce the memo; the per-turn
  inline closure moved inside TurnView (signature now carries turnId).

Desktop suite 1696/1696; turn-control-history + streaming-answer
fixtures verified.
@jackwener
jackwener merged commit 59e5e7e into main Jul 3, 2026
@jackwener
jackwener deleted the perf/turn-memo branch July 3, 2026 09:57
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Every projection test called project() directly, so the wiring between the
projection and the renderer was untested: a pass added downstream of it — a
spread copy of each turn — restores the original defect with the whole suite
green. That is the shape of #778 breaking #472, which this work exists to
prevent.

Extends the existing render-memo-boundary seam to mount the real ChatView
and count renders per turn through a prop the test owns. The spread-copy
mutation now fails this test.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Every projection test called project() directly, so the wiring between the
projection and the renderer was untested: a pass added downstream of it — a
spread copy of each turn — restores the original defect with the whole suite
green. That is the shape of #778 breaking #472, which this work exists to
prevent.

Extends the existing render-memo-boundary seam to mount the real ChatView
and count renders per turn through a prop the test owns. The spread-copy
mutation now fails this test.
Astro-Han added a commit that referenced this pull request Aug 3, 2026
…ing it per token (#2034)

* refactor(ui): make a turn's timeline its only tool authority

turn.tools and turn.timeline were two structures carrying the same tools,
each rewritten separately by projectTurnTools. Derive tools by flattening
the timeline instead, in materializeTurns, overlayLiveTurn and
projectTurnTools alike, so a turn has one tool map and the rewrite pass is
idempotent — projecting an unchanged tool set now returns the same turn
object rather than an equal one.

Also splits the shell-run overlay into its folded-updates and per-tool
halves so a caller can apply one update to one tool without re-folding the
whole list.

* feat(ui): project the transcript incrementally and report affected turns

The transcript simulated incremental updates by re-deriving the whole view
model per token and trusting memo's reference comparison to work out what
had not changed. Two links in that chain were not idempotent, so the guess
was wrong: overlayShellRunUpdates re-folded from its original input every
delta and never wrote its result back, and a background command's durable
revision permanently leads the tool_result snapshot persisted in messages,
so the owning turn was rebuilt on every token; and every message refresh
replaced every turn wholesale.

createTranscriptProjection owns that derived state instead. It remembers
the settled turns and hands the previous object back for any turn a refresh
did not change, remembers the result of applying each shell-run update to
each tool, and reports affectedTurnIds — derived from its own output, since
the turn an event names is not the set of turns it affects (a ShellRun
result folds into the Bash that owns its ref, which can live in an earlier
turn).

Coverage asserts identity, not just rendered output, and covers the
invalidation boundaries in both directions: session switch, deletion,
edit-and-resend, turns missing from the durable snapshot, live-to-settled
handoff, lineage, ownership flips at a fixed revision, and live-only tools.

* perf(ui): keep the prompt rail out of the streaming path

The rail's IntersectionObserver effect depended on the whole turns array,
so every streamed token tore it down and rebuilt it — one querySelector and
one observe per turn across the entire transcript. Key the effect on which
turns exist, which is all the observer set depends on, memoize the rail, and
hand it back the previous entries array when no prompt or answer text moved.

* fix(desktop): intern the per-turn props a memoized TurnView reads

A stable turn only skips a memoized TurnView if every sibling prop is
stable too. deriveAppShellTurnViewModel rebuilt turnFooterActionsByTurn and
turnLineageBadgesByTurn from scratch on every refreshMessages, which fires
at each step and tool boundary, so the memo failed on the footer array and
the whole transcript re-rendered regardless of how stable its turns were —
the transcript projection's refresh half bought nothing at the renderer.

Intern both by value against the previous derivation, the same rule the
projection uses for turns. Value equality is the only option available:
messages arrive freshly deserialized over IPC, so no input carries identity
across a refresh and identity has to be re-established from the output.

* test(desktop): pin the transcript render boundary on the real ChatView

Every projection test called project() directly, so the wiring between the
projection and the renderer was untested: a pass added downstream of it — a
spread copy of each turn — restores the original defect with the whole suite
green. That is the shape of #778 breaking #472, which this work exists to
prevent.

Extends the existing render-memo-boundary seam to mount the real ChatView
and count renders per turn through a prop the test owns. The spread-copy
mutation now fails this test.

* refactor(ui): scope the shell-run overlay and drop its unused entry point

Three follow-ups from review:

- The overlay rebuilt every turn's timeline to discover that only the turns
  holding a background command had moved. Scope the rebuild to those turns.
- affectedTurnIds built a Map and a Set over the whole transcript on every
  streamed token for an answer no render path reads. Compute it on read.
- overlayShellRunUpdates had no caller but its own test, so nothing would
  have gone red when it and the projection diverged. Remove it and drive its
  ShellRun behaviours through the projection that actually ships.

Also stores its own copy of the update list, so the element-wise comparison
is against a snapshot rather than against the caller's array aliased to
itself, and covers two gaps the existing tests left: an ownership flip at an
unchanged revision (the previous fixture's leading revision masked the
ownership comparison entirely) and a lock that a text delta never re-reads
the message log.

* refactor(ui): derive per-turn presentation from the projected turns

The shell derived footer actions, failed-turn labels and lineage badges by
materializing the transcript a second time from the raw message log, so the
turns it keyed them by were not the turns the renderer drew. Those props then
had to be interned by value to line up with a memoized TurnView again.

ChatView now hands its projected turns back to the shell through a
`deriveTurnPresentation` callback, and the shell keys its cache on those turn
objects: a turn the projection did not move costs one WeakMap hit and hands the
same props back. The second authority and the interning both go away.

Also drops `affectedTurnIds` (test-only, and repeating an input reported the
previous step's set), the shell-run per-tool memo (its stated benefit was
already provided by reconcileTurnIdentities, and it could serve a result
derived from a stale tool), and makes `valuesEqual` fail closed outside plain
objects and arrays.

Refs #2030

* test(desktop): pin per-turn presentation reuse on a growing transcript

* fix(ui): fold shell-run children independently of tool order

A turn's tools are a flattening of its timeline, and a live overlay moves
that turn's tools to the end of it. That can order a background command's
child tool ahead of the Bash that owns the run, and the fold scanned only
the tools it had already folded — so it stopped folding there, leaving an
orphan tool row and a parent that never took the child's revision.

Look the parent up by ref over the whole list instead, and merge the
children once their parent's position is known. The invariant test now
carries two tools per turn on both the settled and the live path, so a
reversal between `tools` and `timeline` is observable at all.

* refactor(desktop): repair what the presentation move left behind

Moving the per-turn presentation onto the projected turns superseded the
interning it replaced, but left three traces of it.

A literal NUL byte in the cache key's `join` made git treat the file as
binary, so the diff that carries this PR's core was unreviewable. Two
docblocks still pointed at `deriveAppShellTurnViewModel`, which no longer
exists, and `valuesEqual` still justified its export by an interning rule
this change removed — it now documents the coupling that is real: the
shell keys a WeakMap on the turn objects this comparison decides.

`useAppShellTurnPresentation` claimed its own identity had to stay
constant. Nothing memoizes on it, so the `useCallback` bought nothing
while the render-phase ref write it required would have silently frozen
pending state the day ChatView is memoized. Dropping both leaves the
cache where it belongs, in the ref that holds the derivation.

* refactor(ui): drop the prompt rail's redundant second stabilization

The rail derived a NUL-joined id key and mirrored its turns into a ref so
its observer effect would not re-run per streamed token. ChatView already
hands the rail the same array while no rail-visible field moves, so both
layers guarded the same thing — and each covered for the other well
enough that removing either one on its own kept every test green.

Keying the effect on the turns array leaves one guard instead of two, and
turns the caller's reuse into something the observer count can see.

* test(desktop): cover the per-turn fields the presentation feeds TurnView

The stand-in this suite drives ChatView with returns empty maps for
everything but the footer actions, so failure copy, lineage badges, and
the resume pairing were derived in one test and rendered in another,
never in the same frame. Cutting any of those five wires kept the suite
green. A failed, regenerated transcript now runs the real derivation
through the real ChatView and asserts each value reaches the DOM — with
two failed turns, so offering the resume on every one of them is visible
rather than hidden behind the single turn that could render it.

The observer guard was also counting ResizeObserver and
IntersectionObserver into one tally, so `observe > 0` passed even when
the rail observed nothing. Each kind now counts separately and the guard
pins the exact lifecycle.

* docs(ui): state that the presentation deriver must outlive one render

The prop asked only for purity and idempotence, and a deriver rebuilt in
the render body satisfies both while discarding the cache that makes the
projection worth doing — with nothing turning red. Say so on the prop,
and on the one-shot helper that is exactly the shape someone would copy
out of a story.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant