Skip to content

feat(chat): eliminate streaming jank with append-only markdown and frame-synced pipeline#107

Merged
blove merged 3 commits into
mainfrom
claude/condescending-sanderson
Apr 10, 2026
Merged

feat(chat): eliminate streaming jank with append-only markdown and frame-synced pipeline#107
blove merged 3 commits into
mainfrom
claude/condescending-sanderson

Conversation

@blove
Copy link
Copy Markdown
Contributor

@blove blove commented Apr 10, 2026

Summary

Architectural fix for streaming chat jank. Five changes that address the root causes:

  • Streaming markdown renderer: New append-only DOM renderer that processes text deltas incrementally — never uses innerHTML during streaming. Switches to marked.parse() for a polished final render once streaming completes. 38 new tests.
  • Frame-synced signal pipeline: Default throttle changed from 0 to 16ms (~60fps), batching SSE token updates so change detection fires at most once per frame.
  • Typing indicator: Now shows only during time-to-first-token, not the entire stream duration.
  • Optimistic human message: Human bubble appears instantly on submit instead of waiting for server echo.
  • Auto-scroll: Triggers on message content changes instead of loading state transitions.

Before → After

  • Every SSE token caused: BehaviorSubject.next()toSignal() → change detection → marked.parse(fullContent)sanitize() → full innerHTML replacement → DOM teardown/rebuild
  • Now: tokens batch to ~60fps, streaming renderer only appends new DOM nodes, final render does one marked.parse() call

Test plan

  • nx test agent — 35/35 pass
  • nx test chat — 214/214 pass (38 new streaming markdown tests)
  • Multi-turn streaming conversation in Chrome — bold, lists, code blocks, paragraphs all render correctly
  • Zero console errors (no NG0600, no SafeValue warnings)
  • Optimistic human message appears immediately
  • Typing indicator shows only before first AI token

🤖 Generated with Claude Code

blove and others added 3 commits April 10, 2026 12:09
normalizeMessages() had two code paths: event['messages'] (returned
unfiltered) and event['data'] (filtered by isMessageLike). In
production, FetchStreamTransport's normalizeSdkEvent wraps the raw SDK
data array—which includes metadata objects like { langgraph_node,
langgraph_triggers }—into event.messages. These metadata objects lack
content/type/id fields, causing messageContent() to return undefined
and crashing the content classifier's detectType() on
undefined.length.

The fix applies the existing isMessageLike filter to the
event['messages'] path. Tests now simulate post-normalization event
shapes matching what FetchStreamTransport produces.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…G0600

classifyMessage() is called during Angular template rendering (in the
AI message template via @let). The classifier's update() method writes
to signals (typeSignal.set, markdownSignal.set, etc.), which Angular
21's stricter signal write guards flag as NG0600 — writing signals
during change detection is forbidden.

Wrapping update() in untracked() opts out of the reactive graph for
this imperative push-based API. The template reads the classifier's
signals after the update call returns, so reactivity is preserved.

Verified with multi-turn streaming conversation against production
LangGraph backend — markdown renders correctly, zero console errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…er and frame-synced pipeline

Architecture changes to fix streaming chat jank at the root:

**Streaming markdown renderer** (new)
- Append-only DOM renderer that never uses innerHTML during streaming
- Processes text deltas incrementally via a line-based state machine
- Handles paragraphs, bold/italic, headers, lists, code blocks, links,
  blockquotes, and tables — all rendered by appending DOM nodes
- On stream completion, does a single high-quality marked.parse() render
- 38 new tests covering all markdown features + streaming simulation

**Frame-synced signal pipeline**
- Default throttle changed from 0 (every token) to 16ms (~60fps)
- Batches SSE token updates so at most one signal update fires per frame
- Eliminates change detection storms during high-throughput streaming

**Typing indicator fix**
- Now only shows before the first AI token arrives (time-to-first-token)
- Previously showed the entire duration of streaming, overlapping content

**Optimistic human message**
- Human message bubble appears immediately on submit
- Previously waited for server to echo it back via messages/partial

**Auto-scroll fix**
- Removed isLoading tracking from scroll effect
- Now triggers on message content changes, not loading state transitions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cacheplane Ready Ready Preview, Comment Apr 10, 2026 11:26pm

Request Review

@blove blove merged commit 0bf10de into main Apr 10, 2026
14 checks passed
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