perf(frontend): memoize chat items to stop re-parsing markdown on every token - #150
perf(frontend): memoize chat items to stop re-parsing markdown on every token#150lucastononro wants to merge 5 commits into
Conversation
…wn on every token renderChatItem was a plain function, so every agent_token/agent_message setChatItems re-ran the whole list and re-parsed markdown for every prior message. Extract a memo-wrapped ChatItemView keyed by item.id (only the streaming bubble's item reference changes, so React bails out on the rest) and hoist the assistant bubble's remark-plugins array to a module constant so it doesn't defeat the memoization with a fresh array each render. Closes #98 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
- Pass a per-item `isStreaming` boolean to ChatItemView instead of the shared `streamingItemId` string, so stream start/end only re-renders the affected bubble instead of breaking memo bailout for all items (both P2 findings). - Add a manual test tutorial for the memoization/streaming behavior (docs/manual-tests/chat-memoization.md) — no frontend unit-test infrastructure exists to host a vitest test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wp66uSq9saSpRq9Bbmjxj4
Greptile review findings addressed (b37907e)Fixed (both P2 findings — they are two halves of the same valid issue):
Dismissed: none — both findings were valid. Tests: the frontend has no unit-test infrastructure (no vitest/jest, zero test files), and Validation: |
|
Merged into integration branch |
Summary
renderGroupedChatItems/renderChatItemwere plain functions, and every assistant bubble ranReactMarkdown. Eachagent_tokenSSE event callssetChatItems, which re-ran the whole list and re-parsed markdown for every prior message — O(messages) parses per streamed chunk, causing visible jank in long sessions.Changes
src/app/page.tsx— extractedrenderChatIteminto amemo-wrappedChatItemViewcomponent, rendered withkey={item.id}fromrenderGroupedChatItems. Since only the currently-streaming assistant item's object reference changes between renders (all otherChatItemobjects inchatItemsare untouched),memo's shallow-prop comparison now lets React bail out and skip re-render (and re-parse) for every bubble except the one actually changing.remarkPlugins={[remarkGfm]}array to a module-levelCHAT_MARKDOWN_PLUGINSconstant — a fresh array literal on every render would otherwise look like a "new" plugin list toReactMarkdownand undermine the memoization, mirroring the existingReportMarkdown/componentsmemoization pattern (page.tsxReportMarkdown).ChatItem, the SSE handler, or any card component's own logic (CollapsibleToolCard,SubAgentCard,ClarificationCard,AgentToolCardare called with identical props, just without the now-redundant inlinekey).Test plan
Existing (must still work)
New
ChatItemViewinstance re-renders peragent_tokenevent, not the full list.Closes #98
🤖 Generated with Claude Code
Greptile Summary
This PR memoizes the chat message list to eliminate O(messages)
ReactMarkdownre-parses per streamed token, by convertingrenderChatIteminto amemo-wrappedChatItemViewcomponent with a module-levelCHAT_MARKDOWN_PLUGINSconstant. ThestreamingItemIdstring prop is replaced with a per-itemisStreamingboolean, so only the actively-streaming bubble receives a prop change at stream start/end boundaries.ChatItemView(formerlyrenderChatItem) is wrapped inReact.memoand keyed byitem.id; the SSE handler's[...prev]spread preserves object references for all non-streaming items, so memo's shallow comparison correctly bails out for every prior bubble during a stream.CHAT_MARKDOWN_PLUGINS = [remarkGfm]is hoisted to module scope, preventing a fresh array reference on each render from defeating memo forReactMarkdown.streamingItemId-string prop and the type signature) are resolved in this revision.Confidence Score: 5/5
Safe to merge; the change is a pure render-path refactor with no logic, state, or SSE handler changes.
The SSE handler already preserves object references for non-streaming items ([...prev] with only the streaming slot replaced), so memo's shallow prop comparison works as intended. Both issues from the prior review round are addressed: the shared streamingItemId string is replaced with a per-item isStreaming boolean, and the ChatItemView type signature matches. CHAT_MARKDOWN_PLUGINS is correctly hoisted. The only outstanding item is ToolGroupCard re-rendering on every token (pre-existing, no markdown parsing cost).
Files Needing Attention: No files require special attention; the core change in page.tsx is straightforward and the SSE handler's reference-stability assumption is confirmed in the same file.
Important Files Changed
Sequence Diagram
sequenceDiagram participant SSE as SSE Handler participant State as chatItems state participant RGC as renderGroupedChatItems participant Memo as React.memo (ChatItemView) participant RM as ReactMarkdown SSE->>State: agent_token → setChatItems([...prev, updatedStreamingItem]) Note over State: Non-streaming items keep same object refs State->>RGC: re-runs on parent render RGC->>Memo: "ChatItemView key=id item=stableRef isStreaming=false" Memo-->>Memo: shallow compare: item ref unchanged → bail out Note over RM: ReactMarkdown NOT called for prior bubbles RGC->>Memo: "ChatItemView key=streamId item=newRef isStreaming=true" Memo->>RM: props changed → render RM-->>Memo: parse + render markdown for streaming bubble onlyReviews (3): Last reviewed commit: "Merge branch 'staging-v0.0.5' into fix/9..." | Re-trigger Greptile