Skip to content

[HN-103] Chat panel re-renders every message on every SSE part with no memoization #466

Description

@ixjosemi

Problem

Three things combine badly during SSE streaming in the chat panel:

  1. upsertMessagePart runs prev.map(...) over the entire message array for every incoming SSE part, and recomputes content: extractTextContent(nextParts) on every delta.
  2. No component in the chat panel is memoized. grep -r 'memo(' apps/web/src/components/workspace/chat-panel/ returns zero matches.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions