Problem
Three things combine badly during SSE streaming in the chat panel:
upsertMessagePart runs prev.map(...) over the entire message array for every incoming SSE part, and recomputes content: extractTextContent(nextParts) on every delta.
- No component in the chat panel is memoized.
grep -r 'memo(' apps/web/src/components/workspace/chat-panel/ returns zero matches.
- Every message renders a
ReactMarkdown with the full plugin stack (remarkGfm, remarkMath, remarkBracketMath, rehypeKatex).
So each streamed part produces a new array identity, every message component re-renders, and every one of them re-runs the entire unified pipeline.
Confirmed in node_modules/react-markdown/lib/index.js (v10.1.0) that Markdown() has no internal React.memo or useMemo — there is no library-side caching to fall back on.
Location
apps/web/src/hooks/workspace/use-workspace-streaming.ts:425-450 (upsertMessagePart), apps/web/src/components/workspace/chat-panel/messages.tsx:500 (ReactMarkdown)
Impact
Measured with the repo's exact plugin stack, on realistic content:
| message type |
parse time |
| short (57 chars) |
0.537 ms |
| medium (~250 chars) |
1.934 ms |
| rich (code block + table + links) |
2.837 ms |
With a realistic 60/30/10 mix and 30 prior messages in the session, that is ~36 ms of main-thread work per render commit, against a 16.7 ms budget for 60 fps — and there are many commits per second while streaming.
The result is visible jank while the assistant is typing, and it degrades linearly with conversation length.
Suggested fix
Wrap the message component in memo() with a comparator on id plus parts identity. That turns the cost from "every message x every part" into "only the message that actually changed", which is the dominant term here.
Small, well-scoped diff. Two follow-ups worth considering separately: give upsertMessagePart an index-based update instead of a full map, and hoist extractTextContent so it is not recomputed per delta.
Source: independent verification pass (Claude Opus 5), measured locally. Not part of the HN-001..HN-063 batch.
Problem
Three things combine badly during SSE streaming in the chat panel:
upsertMessagePartrunsprev.map(...)over the entire message array for every incoming SSE part, and recomputescontent: extractTextContent(nextParts)on every delta.grep -r 'memo(' apps/web/src/components/workspace/chat-panel/returns zero matches.ReactMarkdownwith the full plugin stack (remarkGfm,remarkMath,remarkBracketMath,rehypeKatex).So each streamed part produces a new array identity, every message component re-renders, and every one of them re-runs the entire unified pipeline.
Confirmed in
node_modules/react-markdown/lib/index.js(v10.1.0) thatMarkdown()has no internalReact.memooruseMemo— there is no library-side caching to fall back on.Location
apps/web/src/hooks/workspace/use-workspace-streaming.ts:425-450(upsertMessagePart),apps/web/src/components/workspace/chat-panel/messages.tsx:500(ReactMarkdown)Impact
Measured with the repo's exact plugin stack, on realistic content:
With a realistic 60/30/10 mix and 30 prior messages in the session, that is ~36 ms of main-thread work per render commit, against a 16.7 ms budget for 60 fps — and there are many commits per second while streaming.
The result is visible jank while the assistant is typing, and it degrades linearly with conversation length.
Suggested fix
Wrap the message component in
memo()with a comparator onidpluspartsidentity. That turns the cost from "every message x every part" into "only the message that actually changed", which is the dominant term here.Small, well-scoped diff. Two follow-ups worth considering separately: give
upsertMessagePartan index-based update instead of a fullmap, and hoistextractTextContentso it is not recomputed per delta.Source: independent verification pass (Claude Opus 5), measured locally. Not part of the HN-001..HN-063 batch.