perf(code): incremental conversation parsing + memoize UserMessage#2475
perf(code): incremental conversation parsing + memoize UserMessage#2475adamleithp wants to merge 6 commits into
Conversation
Long threads lagged badly while streaming. ConversationView re-parsed the entire event history via buildConversationItems on every appended token — O(n) per token, O(n^2) per turn — and reconciled all-new objects each time. Coalesce the events array to at most one rebuild per animation frame with a new useFrameThrottledValue hook. Token bursts collapse into a single rebuild instead of dozens, and the value always settles on the exact latest state within a frame so end-of-stream output is never stale. Build/render semantics are otherwise unchanged — only the rebuild rate is capped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Throttling capped how often the conversation rebuilt, but each rebuild still re-parsed the entire event history — O(n) per frame, scaling with thread length. Long threads stayed heavy while streaming. Process each event exactly once into a persistent builder. Completed turns are reused by reference (so their memoized rows skip re-render) and only the active turn is re-derived per frame, dropping per-frame cost from O(thread) to O(active turn). buildConversationItems is refactored into a reusable processEvent/finalize/readLastTurnInfo split; the public function is unchanged. The incremental builder falls back to a full rebuild when the append-only fast path can't faithfully represent the state (idle, non-append event change, options change, or a progress card in an already-frozen turn being mutated). An equivalence test asserts output matches buildConversationItems at every prefix across local/cloud/tool/progress/shell scenarios, plus reference stability for completed turns and liveness for the active turn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
`replaceOptimisticWithEvent` clears the optimistic user-message placeholder in the same store update that appends the real prompt event. With the conversation parse fed by a purely trailing frame-throttle, that appended event wasn't visible to the parse until the next frame — so for the frames in between the placeholder was gone and the real message not yet derived, and the message flickered out (worse under streaming load, when the trailing frame is delayed). Give the throttle a leading edge via useLayoutEffect: the first change after an idle window applies synchronously before paint, so a structural change like a freshly sent message never drops a frame. Streaming bursts still coalesce on the trailing rAF. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The frame-throttle delayed the parser's view of `events` by up to a frame. `replaceOptimisticWithEvent` clears the optimistic user-message placeholder in the same store commit that appends the real prompt event, so during the lag the placeholder was gone and the real message not yet derived — the sent message flickered out. A leading-edge tweak didn't fully close the window. The parse is now incremental (each event handled once, completed turns reused by reference), so per-token cost already tracks the active turn rather than the whole thread. The throttle's marginal benefit no longer justifies the timing risk, so feed `events` straight through and drop the hook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A scroll-trace of a very long thread showed react-virtual flush-syncing a render on every scroll event, and the dominant cost was re-rendering visible user messages. Unlike agent messages — which sit under the memoized SessionUpdateRow and are themselves memoized — UserMessage is rendered directly by the conversation's renderItem with no memo, so every visible one re-ran MarkdownRenderer on every scroll-driven parent render. Wrap it in memo. Its props are referentially stable for completed turns (the incremental parser reuses their objects), so memo skips them while scrolling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use Vitest's native it.each for the SCENARIOS × pending matrix so each case is a first-class, individually named test entry, per project convention. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed Greptile review:
|
What this PR does
Two changes to the streaming conversation view:
UserMessage. Stop re-rendering visible user messages on every scroll event.Why
buildConversationItemsre-parsed the whole history on every appended token. Events are append-only (one per token), so this was O(n²) per turn — long threads janked while streaming.flushSync-renders on every scroll event.UserMessageis rendered directly byrenderItemwith no memo (unlike agent messages, which sit under the memoizedSessionUpdateRow), so every visible one re-ranMarkdownRendereron each scroll-driven render.How
Incremental parsing.
buildConversationItemsis split into reusableprocessEvent/finalizeBuilder/readLastTurnInfo(public function unchanged). A persistent builder processes only newly-appended events; completed turns are reused by reference (so memoized rows skip re-render) and only the active turn is re-derived. It falls back to a full rebuild when the append-only fast path can't represent the state (idle, non-append change, options change, cross-turn progress mutation).This removes the per-token re-parse of the full history — the expensive cost (object churn, Map ops, message string re-concatenation). The remaining per-token passes (thought-completion scan, output assembly, merge) are cheap O(n) array iteration.
Memoization.
UserMessageis wrapped inmemo; its props are referentially stable for completed turns (a consequence of the incremental parser reusing their objects), so it skips re-render while scrolling.Performance (measured)
DevTools scroll traces of a very long thread, before vs after. Dev build — absolute numbers are inflated ~3–5×, read the relative drop.
executeDispatch— per-scroll re-render of visible rowsUserMessagerender + mount effectAfter the change the React event-dispatch subtree no longer dominates; the bulk of sampled CPU is idle/native, i.e. the main thread sits idle during scroll instead of re-rendering.
Tests
incrementalConversationItems.test.ts): incremental output matchesbuildConversationItemsfor every prefix across local / cloud-implicit / multi-turn-with-tools / parent-child tools / progress-group / cross-turn-progress / console+shell (× pending = true/false/null).Next steps (follow-ups, not in this PR)
markThoughtCompletion, output assembly, andmergeConversationItems/mcpAppIndicesare still O(n) per token (cheap iteration, but it scales with thread length). Why it matters: keeps streaming smooth as threads grow into the thousands of events — this PR removed the expensive O(n), not all of it.UserMessage. The mount effect readsscrollHeightto detect overflow, forcing sync layout on every row mount during scroll. Move overflow detection to CSS. Why it matters: removes the last per-mount layout cost on the scroll path.🤖 Generated with Claude Code