@@ -296,35 +296,44 @@ const TYPEWRITER_MS_PER_CHAR = 15
296296 * value statically — animation fires only for subsequent updates, which in
297297 * practice means SSE-driven workflow completions arriving via
298298 * `useTableEventStream → applyCell()`.
299+ *
300+ * rAF-driven (not `setInterval`) so concurrent reveals batch into one
301+ * render/paint per frame instead of O(cells) uncoordinated reflows; reveal
302+ * length is elapsed-time based so dropped frames catch up rather than slow.
299303 */
300304function useTypewriter ( text : string | null ) : string | null {
301305 const [ revealed , setRevealed ] = useState < string | null > ( text )
302- const isFirstRunRef = useRef ( true )
303306 const prevTextRef = useRef < string | null > ( text )
307+ const mountedRef = useRef ( false )
308+ const animateRef = useRef ( false )
304309
305- useEffect ( ( ) => {
306- if ( isFirstRunRef . current ) {
307- isFirstRunRef . current = false
308- prevTextRef . current = text
309- setRevealed ( text )
310- return
311- }
312- if ( prevTextRef . current === text ) return
310+ // Reset synchronously during render when `text` changes (not on first mount)
311+ // so no frame ever shows the full new value before the animation begins —
312+ // an effect-based reset lands one frame late and flashes the whole text.
313+ if ( prevTextRef . current !== text ) {
313314 prevTextRef . current = text
315+ const animate = mountedRef . current && text !== null && text . length > 0
316+ animateRef . current = animate
317+ setRevealed ( animate ? '' : text )
318+ }
314319
315- if ( text === null || text . length === 0 ) {
316- setRevealed ( text )
317- return
318- }
320+ useEffect ( ( ) => {
321+ mountedRef . current = true
322+ } , [ ] )
319323
320- setRevealed ( '' )
321- let i = 0
322- const id = window . setInterval ( ( ) => {
323- i ++
324- setRevealed ( text . slice ( 0 , i ) )
325- if ( i >= text . length ) window . clearInterval ( id )
326- } , TYPEWRITER_MS_PER_CHAR )
327- return ( ) => window . clearInterval ( id )
324+ useEffect ( ( ) => {
325+ if ( ! animateRef . current ) return
326+ animateRef . current = false
327+ const full = text as string
328+ const start = performance . now ( )
329+ let raf = 0
330+ const tick = ( now : number ) => {
331+ const chars = Math . min ( full . length , Math . floor ( ( now - start ) / TYPEWRITER_MS_PER_CHAR ) )
332+ setRevealed ( full . slice ( 0 , chars ) )
333+ if ( chars < full . length ) raf = requestAnimationFrame ( tick )
334+ }
335+ raf = requestAnimationFrame ( tick )
336+ return ( ) => cancelAnimationFrame ( raf )
328337 } , [ text ] )
329338
330339 return revealed
0 commit comments