perf(ui): memoize the turn derivation chain and TurnView - #472
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
React 性能队列第 2 项(roadmap §4.1,vercel 70 rules 全读的落地)—— 流式打字不重渲染旧 turn 的核心:
问题
五个派生(visibleMessages/chat/storedTools/tools/turns)每次 render 全部重建 → 纯文本流式(每秒几十次更新的最热路径)中所有 turn 对象身份全变 → 整个 transcript 每 token 重渲染。
修复
memo(TurnView)+ 两个函数 props 走 ref 背书的稳定 wrapper(advanced-use-latest 模式)—— 上游 handler 重建无法击穿 memo;per-turn 内联闭包移入 TurnView(签名带 turnId)验证
1696/1696;turn-control-history / streaming-answer fixture 无回归。