Skip to content

perf(chats): make chat streaming animations cheaper#1046

Draft
ryokun6 wants to merge 3 commits intomainfrom
cursor/improve-chat-streaming-animations-d10a
Draft

perf(chats): make chat streaming animations cheaper#1046
ryokun6 wants to merge 3 commits intomainfrom
cursor/improve-chat-streaming-animations-d10a

Conversation

@ryokun6
Copy link
Copy Markdown
Owner

@ryokun6 ryokun6 commented Apr 18, 2026

Reduce the CPU/render cost of the streaming chat bubble so Chats stays responsive even while Ryo is mid-reply.

Problem

During streaming, useChat fires a text delta every ~50ms. On each delta the currently-streaming assistant message was:

  • Re-rendered as the full motion.div / motion.span tree — every token became a Framer Motion node with a per-token onComplete callback.
  • Re-segmented into markdown tokens and re-decoded for HTML entities from scratch.
  • Causing every sibling ChatMessageItem to re-render too, because hover/copy/highlight IDs were held globally and passed as string IDs.
  • Scanning the full message with link-preview regex on every delta.

For a multi-paragraph reply that's hundreds of React elements rebuilt ~20×/second, plus a DOMParser allocation per delta in decodeHtmlEntities. With a long history or a running stream, this made hovers laggy and input feel unresponsive.

Changes

Cherry-picks two previously-scoped perf commits from cursor/optimize-chat-messages-rendering-1c1a (which had never been merged), then layers on a streaming fast-path and a hot-path micro-optimization:

  1. perf(chats): optimize message list rendering and animations (5af95c87a) — custom memo comparator on ChatMessageItem, local hover state per item, CSS urgent-bg keyframe instead of framer keyframes, memoised tokens/URLs/image URLs, stable callback identities, pre-filtered highlight segments, memoised display-message array at the ChatsAppComponent / useChatRoom level.
  2. fix(chats): use CSS group-hover for toolbar icons (46ea94a35) — pure-CSS group-hover: for the copy/speak/delete toolbar; no re-render per mouse enter/leave. Touch devices still toggle via React state.
  3. perf(chats): streaming fast-path + entity decode short-circuit (new) —
    • New isStreaming flag flows from ChatMessagesContent to ChatMessageItem; only true for the last assistant message while isLoading. While streaming, text parts render as a single plain-text span inside whitespace-pre-wrap. Once the stream completes, the item re-renders with full markdown tokenization (bold/italic/links/citations/speech highlight).
    • Link-preview URL extraction is also skipped while streaming (previews appear after the stream finishes).
    • decodeHtmlEntities fast-paths when the input has no &, avoiding a DOMParser allocation for the ~99% of chat text with no entities.

Per-delta work on the streaming bubble drops from O(N tokens) React element creation + multiple regex scans to O(1) text-content update, which is what the browser should do anyway.

Testing

  • bun run build — builds cleanly.
  • bun run test:unit — 130 pass, 0 fail.
  • bun test tests/test-chat-markdown.test.ts — 4 pass, 0 fail.

Per AGENTS.md / Cursor Cloud instructions, GUI-driven testing is skipped for non-visual perf changes. The visual output while streaming is a plain-text span (no markdown styling mid-stream); once the final delta arrives the bubble re-renders with the usual bold/italic/link rendering. For short replies that fit in a single delta, this transition isn't observable.

Open in Web Open in Cursor 

cursoragent and others added 3 commits April 18, 2026 08:18
Reduce re-renders, framer-motion overhead, and token churn in ChatMessages:

- Custom memo comparator on ChatMessageItem so hover/copy/highlight on
  one message no longer re-renders the whole list.
- Local per-item hover state (was global hoveredMessageId, triggering a
  cascade re-render on every mouse move across messages).
- Replace hover-revealed motion.buttons with plain <button>s using
  CSS opacity transitions driven by local state.
- Drop per-token motion.span stagger animation + onComplete playNote
  hooks. The chat synth note now fires once per assistant streaming
  delta from a centralised effect (throttled by useChatSynth).
- Replace message-enter motion.div with a plain <div> for pre-existing
  messages (only new streaming messages fade in).
- Swap motion.div urgent background animation for a CSS keyframe
  (animate-urgent-bg).
- Memoise displayTokens, imageUrls, and link-preview URL collection
  per item to skip work unless the message or its content changed.
- Pre-filter highlightSegment / localHighlightSegment at the parent so
  only the active target item sees a non-null ref (others skip render).
- Stable useCallback identities for copy/delete/play/stop handlers.
- Memoise currentMessagesToDisplay in ChatsAppComponent and
  currentRoomMessagesLimited in useChatRoom so slice() doesn't return a
  fresh array reference on every parent render.

Co-authored-by: Ryo Lu <me@ryo.lu>
The previous revision used React local state + mouseenter/mouseleave
handlers to toggle the visibility of the per-message copy/speak/delete
toolbar. That triggered a state update + re-render on every mouse move
across messages, and was also gated by an `isTouchDevice()` check that
returns true on hybrid devices (navigator.maxTouchPoints > 0), silently
breaking the hover toolbar there.

Switch to pure CSS hover via Tailwind's `group` / `group-hover:` on the
message wrapper. Desktop hover no longer re-renders the item at all;
touch devices still get a state-driven toggle via onTouchStart.

Co-authored-by: Ryo Lu <me@ryo.lu>
Further reduce per-delta work during assistant message streaming
(useChat throttles to ~50ms, so each full chat render cycle runs
~20x/sec for long replies):

- Add isStreaming flag: only true for the last assistant message while
  useChat isLoading === true. In that case, render text parts as a
  single plain-text span inside .whitespace-pre-wrap — no per-token
  markdown segmentation, no dozens of motion-less <span> nodes per
  bubble. Once streaming completes, the message re-renders with full
  markdown tokenization (bold/italic/links/citations/highlight).
- Skip allUrls extraction while streaming. URL regex scans the whole
  message on every delta otherwise; link previews render after stream.
- decodeHtmlEntities fast-paths text with no '&' marker, avoiding
  DOMParser allocation on the ~99% of chat text with no entities.

Net effect: during streaming, the streaming bubble's work per delta
reduces from O(N tokens) React element creation + N regex scans to
O(1) text node update, which is what the browser should do anyway.

Co-authored-by: Ryo Lu <me@ryo.lu>
@ryos-deploy
Copy link
Copy Markdown

ryos-deploy Bot commented Apr 18, 2026

The preview deployment for ryos-dev is ready. 🟢

Open Preview | Open Build Logs | Open Application Logs

Last updated at: 2026-04-18 08:29:20 CET

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.

2 participants