Skip to content

Commit 56f391b

Browse files
improvement(mothership): hold render-phase animation latches in useState per updated hook rules
1 parent 95f7c0f commit 56f391b

1 file changed

Lines changed: 18 additions & 16 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)