Skip to content

perf(code): incremental conversation parsing + memoize UserMessage#2475

Open
adamleithp wants to merge 6 commits into
mainfrom
feat/conversation-stream-perf
Open

perf(code): incremental conversation parsing + memoize UserMessage#2475
adamleithp wants to merge 6 commits into
mainfrom
feat/conversation-stream-perf

Conversation

@adamleithp
Copy link
Copy Markdown
Contributor

@adamleithp adamleithp commented Jun 3, 2026

What this PR does

Two changes to the streaming conversation view:

  1. Incremental conversation parsing. Parse each event once instead of re-parsing the entire event history on every streamed token.
  2. Memoize UserMessage. Stop re-rendering visible user messages on every scroll event.

Why

  • Streaming: buildConversationItems re-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.
  • Scroll: react-virtual flushSync-renders on every scroll event. UserMessage is rendered directly by renderItem with no memo (unlike agent messages, which sit under the memoized SessionUpdateRow), so every visible one re-ran MarkdownRenderer on each scroll-driven render.

How

Incremental parsing. buildConversationItems is split into reusable processEvent / 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. UserMessage is wrapped in memo; 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.

Signal (self time) Before After
executeDispatch — per-scroll re-render of visible rows 7.7 s gone from top
UserMessage render + mount effect 240 ms gone
Secondary long tasks 424 / 369 ms 135 / 129 ms

After 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

  • Equivalence at every prefix (incrementalConversationItems.test.ts): incremental output matches buildConversationItems for every prefix across local / cloud-implicit / multi-turn-with-tools / parent-child tools / progress-group / cross-turn-progress / console+shell (× pending = true/false/null).
  • Reference stability + liveness: completed-turn refs stay identical across tokens; the active streaming row is re-derived and reflects live updates.
  • Regression guard: a sent user message survives the idle→streaming→complete transition.

Next steps (follow-ups, not in this PR)

  • Make the remaining per-token passes incremental. markThoughtCompletion, output assembly, and mergeConversationItems/mcpAppIndices are 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.
  • Drop the forced reflow in UserMessage. The mount effect reads scrollHeight to 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.
  • Disable posthog-js scroll capture for the app. It's now the top identifiable app cost on scroll (~215 ms) and is meaningless on a virtualized inner-container list. Why it matters: it's the current ceiling once our render cost is gone.

🤖 Generated with Claude Code

adamleithp and others added 2 commits June 3, 2026 14:32
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>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 3, 2026

Comments Outside Diff (1)

  1. apps/code/src/renderer/features/sessions/components/incrementalConversationItems.test.ts, line 519-526 (link)

    P2 The nested for loops generate 21 parameterised cases but use a plain for/it pattern rather than Vitest's it.each or describe.each. The team's preference is parameterised tests using the framework's native APIs — it.each(Object.entries(SCENARIOS).flatMap(...)) (or a product of SCENARIOS × pending) would make each case a first-class, individually named test entry and aligns with the project convention.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/code/src/renderer/features/sessions/components/incrementalConversationItems.test.ts
    Line: 519-526
    
    Comment:
    The nested `for` loops generate 21 parameterised cases but use a plain `for`/`it` pattern rather than Vitest's `it.each` or `describe.each`. The team's preference is parameterised tests using the framework's native APIs — `it.each(Object.entries(SCENARIOS).flatMap(...))` (or a product of `SCENARIOS × pending`) would make each case a first-class, individually named test entry and aligns with the project convention.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/code/src/renderer/hooks/useFrameThrottledValue.ts:33-38
The cleanup cancels the `requestAnimationFrame` but leaves `rafRef.current` holding the now-stale handle ID. In React StrictMode (used in development), effects are mounted → cleaned up → remounted. After the cleanup, `rafRef.current` is non-null; when the `[value]` effect re-runs on remount it sees `rafRef.current !== null` and returns early without scheduling a new rAF. `rafRef.current` is only ever reset to `null` inside the rAF callback itself — which was cancelled and will never fire. From that point on, every call to the effect short-circuits, and `throttledEvents` is permanently stuck at the value it held at first mount, so conversation items never update during streaming in a StrictMode dev build.

```suggestion
  useEffect(
    () => () => {
      if (rafRef.current !== null) {
        cancelAnimationFrame(rafRef.current);
        rafRef.current = null;
      }
    },
    [],
  );
```

### Issue 2 of 2
apps/code/src/renderer/features/sessions/components/incrementalConversationItems.test.ts:519-526
The nested `for` loops generate 21 parameterised cases but use a plain `for`/`it` pattern rather than Vitest's `it.each` or `describe.each`. The team's preference is parameterised tests using the framework's native APIs — `it.each(Object.entries(SCENARIOS).flatMap(...))` (or a product of `SCENARIOS × pending`) would make each case a first-class, individually named test entry and aligns with the project convention.

Reviews (1): Last reviewed commit: "perf(code): parse conversation items inc..." | Re-trigger Greptile

Comment thread apps/code/src/renderer/hooks/useFrameThrottledValue.ts Outdated
adamleithp and others added 3 commits June 3, 2026 20:45
`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>
@adamleithp adamleithp changed the title perf(code): fix long-thread streaming lag perf(code): fix long-thread streaming + scroll lag Jun 3, 2026
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>
@adamleithp
Copy link
Copy Markdown
Contributor Author

Addressed Greptile review:

  • P1 — useFrameThrottledValue stuck-rAF in StrictMode: file removed entirely in 13f96dcb0 (the throttle raced with the optimistic→real message swap). Thread resolved.
  • Outside-diff nit — parameterized tests: converted the SCENARIOS × pending matrix to it.each in 4c7210c77.

@adamleithp adamleithp changed the title perf(code): fix long-thread streaming + scroll lag perf(code): incremental conversation parsing + memoize UserMessage Jun 3, 2026
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