Skip to content

perf(chat): re-render only the streaming reply per chunk - #1191

Merged
agegr merged 1 commit into
mainfrom
perf/chat-stream-rerenders
Oct 11, 2026
Merged

agegr merged 1 commit into
mainfrom
perf/chat-stream-rerenders

Conversation

@agegr

@agegr agegr commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Problem

The streaming message was useReducer state inside useAgentSession, and that hook runs in ChatWindow. Every SSE chunk therefore re-ran ChatWindow, the hook, the whole composer (ChatInput, ModelSelector) and the minimap, although only the reply's bubble changes.

A props diff in the profiler (old vs new memoizedProps per render) showed ChatInput with identical props in 543 of its 555 renders during one reply. ChatMinimap re-rendered only because its streamingMessage prop changed, and it reads that prop only inside its 150 ms throttled measure. Together these four components took about 18% of commit time while streaming, once the Markdown fix (#1176) was in.

Change

  • Streaming state outside React. lib/streaming-message.ts adds createStreamingStore(): the existing streamReducer, plus subscribe. useAgentSession dispatches to it and returns streamingStore instead of streamState.
  • ChatWindow sees two booleans. It subscribes only to whether a reply streams and whether it has content yet, so it renders when those flip, not per chunk.
  • StreamingReply (memo'd, in ChatWindow.tsx) subscribes to the message and renders its MessageView. After each commit it runs the prompt anchor's update, which the old useLayoutEffect keyed on streamState.streamingMessage did. ChatWindow still runs that update when the bubble appears or goes.
  • ChatMinimap takes getStreamingMessage() instead of the message and reads it when it measures the turns.
  • ChatInput and ChatMinimap are memo'd, for the renders a running tool still causes. An AST scan of their render bodies found no reads of outside mutable values, only ChatInput copying its own state into refs.

Why not useSyncExternalStore

hooks/useStreamingSelector.ts subscribes with a plain state update. useSyncExternalStore renders every store change on its own, while state updates let React batch the SSE events that arrive before it renders. More renders would mean more parses of the reply's last Markdown block.

The store calls its listeners synchronously in dispatch, so message_end appending the reply and ending the stream stays one render, as before.

Measurements

Production build with profiling, 4x CPU throttle. The same prompt (a long Chinese article, no tools) was sent on main and on this branch.

main this PR
ChatWindow renders / self time 555 / 268 ms 33 / 24 ms
ChatInput renders 555 0
ModelSelector renders 555 0
ChatMinimap renders 680 156 (its own 150 ms measure)
StreamingReply renders / self time n/a 616 / 25 ms
Commit time per SSE event 1.59 ms 1.18 ms (−26%)
Script time per SSE event 2.85 ms 2.12 ms (−26%)

The two replies differ in length (1732 vs 1990 SSE events), hence the per-event rows.

Checks

  • npm test: 2625 pass, 0 fail (new test: the store notifies only on a change and stops after unsubscribe). tsc --noEmit and eslint . are clean.
  • node e2e/run.mjs: 27/27 on the first run.
  • Headless Chrome against main and this branch side by side (two prompts in one session, sampled every 150 ms during the second):
    • Prompt anchoring is identical. The new prompt holds 24–25 px from the top, the spacer shrinks the same way as the reply grows, then the view follows the bottom.
    • Minimap preview. Hovering the minimap mid-reply lists the live reply's latest heading.
    • End of the reply. One assistant answer, no leftover bubble, no console errors.

demo/ keeps its own copy of these components and is unchanged.

🤖 Generated with Claude Code

The streaming message was useReducer state inside useAgentSession, so
every SSE chunk re-ran ChatWindow, the hook and the whole composer
(ChatInput, ModelSelector) and the minimap. It now lives in a small
StreamingStore. ChatWindow subscribes only to whether a reply streams
and has content; a memo'd StreamingReply subscribes to the message and
updates the prompt anchor after each commit; ChatMinimap reads the
message through a getter when it measures. ChatInput and ChatMinimap
are memo'd for the renders a running tool still causes.

Subscriptions use a state update, not useSyncExternalStore, so React
keeps batching the chunks that arrive before it renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@agegr
agegr merged commit e8d91ef into main Oct 11, 2026
2 checks passed
Grant-Vranes pushed a commit to Grant-Vranes/pi-web-Desktop-Electron that referenced this pull request Oct 13, 2026
Resolve conflicts keeping the fork's project-rail sidebar, brand/versions
hero, minimap turn anchors and i18n while adopting upstream's streaming
render refactor (agegr#1191), session deep links, notice splitting and other
v0.12.0 changes.
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