11'use client'
22
3- import { type ComponentPropsWithoutRef , memo , useEffect , useMemo , useRef } from 'react'
3+ import { type ComponentPropsWithoutRef , memo , useEffect , useMemo , useRef , useState } from 'react'
44import { Streamdown } from 'streamdown'
55import 'streamdown/styles.css'
66// prismjs core must load before its language components — they register on the
@@ -53,17 +53,42 @@ const PROSE_CLASSES = cn(
5353
5454/**
5555 * Soft fade for newly revealed text. Paired with {@link useSmoothText}, which
56- * paces the reveal: `sep: 'char'` fades each character as the pacer exposes it
57- * (so a growing trailing word never re-animates), and `stagger: 0` keeps the
58- * cadence driven by the pacer rather than an overlapping per-token delay ramp.
56+ * paces the reveal; `stagger: 0` keeps the cadence driven by the pacer rather
57+ * than an overlapping per-token delay ramp — every span revealed in one tick
58+ * fades as a unit, so `sep: 'word'` looks identical to `sep: 'char'` while
59+ * creating ~5x fewer spans. That span count is the dominant mid-stream cost:
60+ * the animate plugin rebuilds a span per token for the WHOLE trailing block on
61+ * every reveal tick, so per-char wrapping of a long paragraph meant thousands
62+ * of hast nodes + React elements reconciled ~40x/sec. Streamdown's
63+ * prev-content tracking keeps a word that grows across two ticks from
64+ * re-fading (its continuation renders unfaded), and the pacer's word-boundary
65+ * snapping makes such splits rare to begin with.
5966 */
6067const STREAM_ANIMATION = {
6168 animation : 'fadeIn' ,
6269 duration : 220 ,
6370 stagger : 0 ,
64- sep : 'char ' ,
71+ sep : 'word ' ,
6572} as const
6673
74+ /**
75+ * How long after the reveal fully settles before the animated tree is dropped.
76+ * Must exceed {@link STREAM_ANIMATION}'s 220ms duration so the last characters
77+ * finish fading at full opacity before their spans are swapped for plain text.
78+ */
79+ const ANIMATION_DRAIN_MS = 300
80+
81+ /**
82+ * Once a segment has revealed this many characters, new text stops fading in;
83+ * the word-paced reveal itself is unchanged. Fade cost scales with segment
84+ * length — every reveal tick rebuilds a span per word for the WHOLE trailing
85+ * markdown block — so on an unbroken wall of text it eventually swamps the
86+ * frame budget (measured: ~9k-char single paragraphs spent ~30% of main-thread
87+ * time in long tasks) while the fade itself is imperceptible detail that deep
88+ * into a reply.
89+ */
90+ const FADE_MAX_REVEALED_CHARS = 6000
91+
6792function startsInlineWord ( value : string ) : boolean {
6893 return / ^ [ A - Z a - z 0 - 9 _ ( ] / . test ( value )
6994}
@@ -306,19 +331,51 @@ function ChatContentInner({
306331 } , [ isRevealing ] )
307332
308333 /**
309- * One-way latch: once a message has streamed in this mount, keep rendering it
310- * through Streamdown's streaming/animation pipeline for the rest of its life.
311- * Drives `mode`, `animated`, AND `isAnimating` together — all three must stay
312- * constant across the completion boundary. Streamdown removes the per-word
313- * `<span>` wrappers (and re-parses the whole message) the instant `isAnimating`
314- * goes false, so wiring `isAnimating` to `isRevealing` (which flips at
315- * completion) reintroduces the streaming→static flash this latch exists to
316- * prevent. Content is stable once revealed, so a permanently-true
317- * `isAnimating` never re-fades anything.
334+ * Streaming-tree lifecycle. While a message streams (and until its reveal
335+ * drains), it renders through Streamdown's streaming/animated pipeline, whose
336+ * animate plugin wraps every character in its own `<span data-sd-animate>` —
337+ * thousands of DOM nodes per streamed message. Holding that tree forever made
338+ * long sessions progressively laggier until a refresh (which renders the same
339+ * transcript static). `animationDrained` flips one-way
340+ * {@link ANIMATION_DRAIN_MS} after the reveal settles and swaps to the static
341+ * pipeline; the drain window lets the last 220ms fades finish so the swap
342+ * trades identical pixels, unlike flipping at `isRevealing`'s edge, which cut
343+ * running fades short (the old completion flash).
344+ *
345+ * The swap must REMOUNT Streamdown (via `key`), not just flip its props:
346+ * Streamdown's default element components are memoized on className + source
347+ * position (`E`/`qe` in streamdown 2.5), so a re-parse of unchanged content
348+ * without the animate plugin bails at every unoverridden element (`p`,
349+ * `strong`, `tr`, headings, …) and leaves the stale per-char span DOM in
350+ * place. Remounting also converges the settled DOM byte-for-byte with what a
351+ * reloaded transcript renders.
352+ *
353+ * The drain is deliberately one-way: a stream that resumes afterwards
354+ * (reconnect/continuation) reveals paced but unfaded, because re-arming
355+ * mounts a fresh animate plugin with no prev-content tracking, which would
356+ * re-fade the entire already-visible message.
318357 */
319358 const streamedThisSession = useRef ( false )
320359 if ( isStreaming ) streamedThisSession . current = true
321- const keepStreamingTree = isRevealing || streamedThisSession . current
360+
361+ const [ animationDrained , setAnimationDrained ] = useState ( false )
362+ useEffect ( ( ) => {
363+ if ( isRevealing || animationDrained || ! streamedThisSession . current ) return
364+ const timeout = setTimeout ( ( ) => setAnimationDrained ( true ) , ANIMATION_DRAIN_MS )
365+ return ( ) => clearTimeout ( timeout )
366+ } , [ isRevealing , animationDrained ] )
367+
368+ const streamingTree = ( isRevealing || streamedThisSession . current ) && ! animationDrained
369+
370+ /**
371+ * One-way fade cutoff (see {@link FADE_MAX_REVEALED_CHARS}). Latched so a
372+ * sanitize-induced content shrink back across the boundary cannot re-arm
373+ * `animated` — a fresh animate plugin has no prev-content tracking and would
374+ * re-fade the entire visible segment.
375+ */
376+ const fadeCutoffRef = useRef ( false )
377+ if ( streamedContent . length > FADE_MAX_REVEALED_CHARS ) fadeCutoffRef . current = true
378+ const fadeActive = streamingTree && ! fadeCutoffRef . current
322379
323380 useEffect ( ( ) => {
324381 const handler = ( e : Event ) => {
@@ -392,9 +449,10 @@ function ChatContentInner({
392449 className = { cn ( PROSE_CLASSES , '[&>:first-child]:mt-0 [&>:last-child]:mb-0' ) }
393450 >
394451 < Streamdown
395- mode = { keepStreamingTree ? undefined : 'static' }
396- animated = { keepStreamingTree ? STREAM_ANIMATION : false }
397- isAnimating = { keepStreamingTree }
452+ key = { streamingTree ? 'stream' : 'static' }
453+ mode = { streamingTree ? undefined : 'static' }
454+ animated = { fadeActive ? STREAM_ANIMATION : false }
455+ isAnimating = { streamingTree }
398456 components = { MARKDOWN_COMPONENTS }
399457 >
400458 { group . markdown }
@@ -418,9 +476,10 @@ function ChatContentInner({
418476 return (
419477 < div className = { cn ( PROSE_CLASSES , '[&>:first-child]:mt-0 [&>:last-child]:mb-0' ) } >
420478 < Streamdown
421- mode = { keepStreamingTree ? undefined : 'static' }
422- animated = { keepStreamingTree ? STREAM_ANIMATION : false }
423- isAnimating = { keepStreamingTree }
479+ key = { streamingTree ? 'stream' : 'static' }
480+ mode = { streamingTree ? undefined : 'static' }
481+ animated = { fadeActive ? STREAM_ANIMATION : false }
482+ isAnimating = { streamingTree }
424483 components = { MARKDOWN_COMPONENTS }
425484 >
426485 { streamedContent }
0 commit comments