From e4f02aa8b0c6628424a370015c741f477deeeed4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 21 May 2026 02:02:38 -0700 Subject: [PATCH] fix(table): derive typewriter slice from elapsed time (no full-text flash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reveal used a lagging nullable `revealed` state with a `revealed ?? kind.text` fallback in the caller. Under React 18 concurrent rendering a committed render could observe `revealed === null` while `text` was the full value, so the fallback painted the entire string for one frame before the type-on — an intermittent flash, reproducible on a large Run-all (verified in-browser: 60+ cells flashing). Derive the revealed slice from `text` + elapsed time during render instead of holding it in state. For a non-null value the result is never `null` and never the full string on the frame `text` changes (elapsed ≈ 0 → 0 chars), so the fallback can't fire. `prevText` is tracked in state (not a ref) so a discarded render rolls it back and the change is re-detected on the committed render. Verified via DOM MutationObserver: 0 flashes across 213 animated cells. --- .../table-grid/cells/cell-render.tsx | 58 +++++++++---------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 48cd644480..27ff1f2ae4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -291,30 +291,25 @@ function Wrap({ isEditing, children }: { isEditing: boolean; children: React.Rea const TYPEWRITER_MS_PER_CHAR = 15 /** - * Reveals `text` character-by-character whenever it changes after the first - * render. Initial render (page hydration or virtualization remount) shows the - * value statically — animation fires only for subsequent updates, which in - * practice means SSE-driven workflow completions arriving via - * `useTableEventStream → applyCell()`. - * - * rAF-driven (not `setInterval`) so concurrent reveals batch into one - * render/paint per frame instead of O(cells) uncoordinated reflows; reveal - * length is elapsed-time based so dropped frames catch up rather than slow. + * Reveals `text` character-by-character when it changes after the first render; + * the initial render (mount / scroll-in) shows it statically. The slice is + * derived from elapsed time during render rather than held in state, so it is + * never `null` and never the full string on the frame `text` changes — which is + * what prevents the caller's `?? kind.text` fallback from flashing the whole + * value for a frame. `prevText` is state (not a ref) so a discarded render rolls + * it back and re-detects the change on the committed render. */ function useTypewriter(text: string | null): string | null { - const [revealed, setRevealed] = useState(text) - const prevTextRef = useRef(text) + const [prevText, setPrevText] = useState(text) + const [, forceFrame] = useState(0) const mountedRef = useRef(false) - const animateRef = useRef(false) - - // Reset synchronously during render when `text` changes (not on first mount) - // so no frame ever shows the full new value before the animation begins — - // an effect-based reset lands one frame late and flashes the whole text. - if (prevTextRef.current !== text) { - prevTextRef.current = text - const animate = mountedRef.current && text !== null && text.length > 0 - animateRef.current = animate - setRevealed(animate ? '' : text) + // Reveal-clock start; 0 = show statically (mount / cleared / empty). + const startRef = useRef(0) + + if (prevText !== text) { + setPrevText(text) + startRef.current = + mountedRef.current && text !== null && text.length > 0 ? performance.now() : 0 } useEffect(() => { @@ -322,19 +317,22 @@ function useTypewriter(text: string | null): string | null { }, []) useEffect(() => { - if (!animateRef.current) return - animateRef.current = false - const full = text as string - const start = performance.now() + if (startRef.current === 0 || text === null) return let raf = 0 - const tick = (now: number) => { - const chars = Math.min(full.length, Math.floor((now - start) / TYPEWRITER_MS_PER_CHAR)) - setRevealed(full.slice(0, chars)) - if (chars < full.length) raf = requestAnimationFrame(tick) + const tick = () => { + const chars = Math.floor((performance.now() - startRef.current) / TYPEWRITER_MS_PER_CHAR) + forceFrame((f) => f + 1) + if (chars < text.length) raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) }, [text]) - return revealed + if (text === null) return null + if (startRef.current === 0) return text + const chars = Math.min( + text.length, + Math.floor((performance.now() - startRef.current) / TYPEWRITER_MS_PER_CHAR) + ) + return text.slice(0, chars) }