mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(table): derive typewriter slice from elapsed time (no full-text flash) (#4694)
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.
This commit is contained in:
+27
-29
@@ -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<string | null>(text)
|
||||
const prevTextRef = useRef<string | null>(text)
|
||||
const [prevText, setPrevText] = useState<string | null>(text)
|
||||
const [, forceFrame] = useState(0)
|
||||
const mountedRef = useRef(false)
|
||||
const animateRef = useRef(false)
|
||||
// Reveal-clock start; 0 = show statically (mount / cleared / empty).
|
||||
const startRef = useRef(0)
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user