Skip to content

Commit e4f02aa

Browse files
fix(table): derive typewriter slice from elapsed time (no full-text flash)
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.
1 parent d730015 commit e4f02aa

1 file changed

Lines changed: 28 additions & 30 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -291,50 +291,48 @@ function Wrap({ isEditing, children }: { isEditing: boolean; children: React.Rea
291291
const TYPEWRITER_MS_PER_CHAR = 15
292292

293293
/**
294-
* Reveals `text` character-by-character whenever it changes after the first
295-
* render. Initial render (page hydration or virtualization remount) shows the
296-
* value statically — animation fires only for subsequent updates, which in
297-
* practice means SSE-driven workflow completions arriving via
298-
* `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.
294+
* Reveals `text` character-by-character when it changes after the first render;
295+
* the initial render (mount / scroll-in) shows it statically. The slice is
296+
* derived from elapsed time during render rather than held in state, so it is
297+
* never `null` and never the full string on the frame `text` changes — which is
298+
* what prevents the caller's `?? kind.text` fallback from flashing the whole
299+
* value for a frame. `prevText` is state (not a ref) so a discarded render rolls
300+
* it back and re-detects the change on the committed render.
303301
*/
304302
function useTypewriter(text: string | null): string | null {
305-
const [revealed, setRevealed] = useState<string | null>(text)
306-
const prevTextRef = useRef<string | null>(text)
303+
const [prevText, setPrevText] = useState<string | null>(text)
304+
const [, forceFrame] = useState(0)
307305
const mountedRef = useRef(false)
308-
const animateRef = useRef(false)
309-
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) {
314-
prevTextRef.current = text
315-
const animate = mountedRef.current && text !== null && text.length > 0
316-
animateRef.current = animate
317-
setRevealed(animate ? '' : text)
306+
// Reveal-clock start; 0 = show statically (mount / cleared / empty).
307+
const startRef = useRef(0)
308+
309+
if (prevText !== text) {
310+
setPrevText(text)
311+
startRef.current =
312+
mountedRef.current && text !== null && text.length > 0 ? performance.now() : 0
318313
}
319314

320315
useEffect(() => {
321316
mountedRef.current = true
322317
}, [])
323318

324319
useEffect(() => {
325-
if (!animateRef.current) return
326-
animateRef.current = false
327-
const full = text as string
328-
const start = performance.now()
320+
if (startRef.current === 0 || text === null) return
329321
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)
322+
const tick = () => {
323+
const chars = Math.floor((performance.now() - startRef.current) / TYPEWRITER_MS_PER_CHAR)
324+
forceFrame((f) => f + 1)
325+
if (chars < text.length) raf = requestAnimationFrame(tick)
334326
}
335327
raf = requestAnimationFrame(tick)
336328
return () => cancelAnimationFrame(raf)
337329
}, [text])
338330

339-
return revealed
331+
if (text === null) return null
332+
if (startRef.current === 0) return text
333+
const chars = Math.min(
334+
text.length,
335+
Math.floor((performance.now() - startRef.current) / TYPEWRITER_MS_PER_CHAR)
336+
)
337+
return text.slice(0, chars)
340338
}

0 commit comments

Comments
 (0)