@@ -356,9 +356,9 @@ function ChatContentInner({
356356 * mounts a fresh animate plugin with no prev-content tracking, which would
357357 * re-fade the entire already-visible message.
358358 */
359- const streamedThisSession = useRef ( false )
359+ const [ streamedThisSession , setStreamedThisSession ] = useState ( false )
360360 const [ animationDrained , setAnimationDrained ] = useState ( false )
361- const fadeCutoffRef = useRef ( false )
361+ const [ fadeCutoff , setFadeCutoff ] = useState ( false )
362362
363363 /**
364364 * The per-session latches above outlive the content when React reuses this
@@ -370,26 +370,28 @@ function ChatContentInner({
370370 * is REPLACED (not an append of the previous string) after the instance has
371371 * settled. A resumed turn only ever appends, so this never undoes the
372372 * one-way drain; mid-stream sanitize rewrites are excluded by the
373- * `animationDrained` gate (the drain only fires after settle).
373+ * `animationDrained` gate (the drain only fires after settle). All latches
374+ * are render-phase `useState` adjustments (prev-tracker idiom), not refs —
375+ * they are read during render, and state is concurrent-safe where a
376+ * render-phase ref mutation is not.
374377 */
375- const prevDisplayContentRef = useRef ( displayContent )
376- if ( prevDisplayContentRef . current !== displayContent ) {
377- const replaced = ! displayContent . startsWith ( prevDisplayContentRef . current )
378- prevDisplayContentRef . current = displayContent
379- if ( replaced && animationDrained ) {
380- streamedThisSession . current = false
381- fadeCutoffRef . current = false
378+ const [ prevDisplayContent , setPrevDisplayContent ] = useState ( displayContent )
379+ if ( prevDisplayContent !== displayContent ) {
380+ setPrevDisplayContent ( displayContent )
381+ if ( ! displayContent . startsWith ( prevDisplayContent ) && animationDrained ) {
382+ setStreamedThisSession ( false )
383+ setFadeCutoff ( false )
382384 setAnimationDrained ( false )
383385 }
384386 }
385387
386- if ( isStreaming ) streamedThisSession . current = true
388+ if ( isStreaming && ! streamedThisSession ) setStreamedThisSession ( true )
387389
388390 useEffect ( ( ) => {
389- if ( isRevealing || animationDrained || ! streamedThisSession . current ) return
391+ if ( isRevealing || animationDrained || ! streamedThisSession ) return
390392 const timeout = setTimeout ( ( ) => setAnimationDrained ( true ) , ANIMATION_DRAIN_MS )
391393 return ( ) => clearTimeout ( timeout )
392- } , [ isRevealing , animationDrained ] )
394+ } , [ isRevealing , animationDrained , streamedThisSession ] )
393395
394396 /**
395397 * `parserTree` (drives `mode`) stays latched for the mount's life: streaming
@@ -403,7 +405,7 @@ function ChatContentInner({
403405 * byte-identical pixels. Only never-streamed mounts (reloaded history)
404406 * render static.
405407 */
406- const parserTree = isRevealing || streamedThisSession . current
408+ const parserTree = isRevealing || streamedThisSession
407409 const streamingTree = parserTree && ! animationDrained
408410
409411 /**
@@ -412,8 +414,8 @@ function ChatContentInner({
412414 * `animated` — a fresh animate plugin has no prev-content tracking and would
413415 * re-fade the entire visible segment.
414416 */
415- if ( streamedContent . length > FADE_MAX_REVEALED_CHARS ) fadeCutoffRef . current = true
416- const fadeActive = streamingTree && ! fadeCutoffRef . current
417+ if ( ! fadeCutoff && streamedContent . length > FADE_MAX_REVEALED_CHARS ) setFadeCutoff ( true )
418+ const fadeActive = streamingTree && ! fadeCutoff
417419
418420 useEffect ( ( ) => {
419421 const handler = ( e : Event ) => {
0 commit comments