// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useStreamingText.ts * @input Uses React useState, useEffect, useRef, useMediaQuery, useTheme * @output Exports useStreamingText hook for smooth text streaming * @position Core hook; smooths bursty streamed text into steady character reveal * * Decouples the arrival rate of streamed chunks from the display rate, * draining characters at a steady pace using requestAnimationFrame. The * reveal cadence advances by a fixed number of UTF-16 code units per tick; * the rendered slice is then snapped back to the nearest grapheme cluster * boundary (see snapToGraphemeBoundary) so a tick landing inside a * surrogate pair, flag sequence, or ZWJ emoji doesn't render a lone * surrogate or a partial glyph for one frame. * * Animation timing derives from Astryx motion tokens (via useTheme) when * an Theme provider is present. Falls back to sensible defaults when * used outside a theme provider. * * SYNC: When modified, update: * - /packages/core/src/hooks/index.ts (exports) * - /packages/core/src/hooks/useStreamingText.test.ts (tests) */ import { useEffect, useMemo, useRef, useState } from 'react'; import { useMediaQuery } from "./useMediaQuery.js"; import { useTheme } from "../theme/useTheme.js"; /** * Speed presets for streaming text reveal. * - `'natural'` — steady character-by-character reveal (~2 chars/frame) * - `'fast'` — faster reveal, scales with backlog (~4 chars/frame) * - `'instant'` — no animation, returns full text immediately */ export type StreamingTextSpeed = 'natural' | 'fast' | 'instant'; export interface UseStreamingTextOptions { /** * Speed of text reveal. * @default 'natural' */ speed?: StreamingTextSpeed; } // Fallback values when no Theme provider is present const FALLBACK_TICK_MS = 50; const FALLBACK_TICK_MS_FAST = 8; /** * Parse a CSS duration string (e.g. "175ms", "0.15s") to milliseconds. * Returns null if unparseable. */ function parseDuration(value: string): number | null { const ms = value.match(/^([\d.]+)ms$/); if (ms) { return parseFloat(ms[1]); } const s = value.match(/^([\d.]+)s$/); if (s) { return parseFloat(s[1]) * 1000; } return null; } const CHARS_PER_TICK = { natural: 10, fast: 4, instant: Infinity } as const; // Reuse a single segmenter when the runtime supports Intl.Segmenter. // Granularity is fixed and locale doesn't affect grapheme-cluster // boundaries, so one instance is safe to share across calls. const graphemeSegmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; function isLowSurrogate(codeUnit: number): boolean { return codeUnit >= 0xdc00 && codeUnit <= 0xdfff; } /** * Snap a UTF-16 code-unit offset down to the start of the grapheme cluster * it falls inside, so slicing `text` at the returned offset never splits a * surrogate pair, flag sequence, or ZWJ emoji mid-glyph. Offsets at the * start/end of the string are returned unchanged (nothing to snap). * * Falls back to snapping off a lone low surrogate when `Intl.Segmenter` is * unavailable — narrower than full grapheme-cluster awareness (a ZWJ * sequence or flag pair can still split in that path), but it's what's * achievable without a full grapheme-aware dependency, and it's strictly * better than the unguarded default of never checking at all. */ export function snapToGraphemeBoundary(text: string, offset: number): number { if (offset <= 0 || offset >= text.length) { return offset; } if (graphemeSegmenter) { // `containing` only returns undefined for an out-of-range index; offset // is already guarded to [1, text.length - 1] above, so this is always // defined at runtime — the `??` is to satisfy the type, which is wider. return graphemeSegmenter.segment(text).containing(offset)?.index ?? offset; } return isLowSurrogate(text.charCodeAt(offset)) ? offset - 1 : offset; } /** * Smooths bursty streamed text into a steady character-by-character reveal. * * Returns a string that grows steadily toward `targetText`. When `isStreaming` * is false, returns the full `targetText` immediately. When the user prefers * reduced motion, the progressive reveal is skipped entirely and the full * `targetText` is returned immediately. * * The reveal cadence itself advances by a fixed number of UTF-16 code units * per tick, not on word or markdown-syntax boundaries — but the rendered * slice is always snapped back to the nearest grapheme cluster boundary, so * a tick landing mid-surrogate-pair or mid-emoji-sequence never renders a * broken glyph. * * @example * ``` * const displayed = useStreamingText(rawText, isStreaming); * return {displayed}; * ``` * * @example * ``` * const displayed = useStreamingText(rawText, isStreaming, { speed: 'fast' }); * ``` */ export function useStreamingText(targetText: string, isStreaming: boolean, options?: UseStreamingTextOptions): string { const speed = options?.speed ?? 'natural'; const charsPerTick = CHARS_PER_TICK[speed]; // Derive tick timing from Astryx motion tokens when available. // natural → --duration-fast-min (frame-level cadence from the theme) // fast → half that, floored at 4ms (roughly 2x speed) const { token } = useTheme(); const tickMs = useMemo(() => { if (speed === 'instant') { return 0; } const base = parseDuration(token('--duration-fast-min')); if (base == null) { return speed === 'fast' ? FALLBACK_TICK_MS_FAST : FALLBACK_TICK_MS; } // Scale: use ~1/10th of the theme's fast-min duration as the per-frame tick. // This maps a 130ms token to ~13ms tick (natural) or ~6.5ms tick (fast). const tick = speed === 'fast' ? base / 20 : base / 10; return Math.max(4, Math.round(tick)); }, [token, speed]); const [displayedLen, setDisplayedLen] = useState(0); const targetRef = useRef(targetText); const displayedLenRef = useRef(0); const lastTickRef = useRef(0); const rafRef = useRef(null); // Keep target ref in sync targetRef.current = targetText; // Reset when target clears (new message) const prevTargetLenRef = useRef(targetText.length); if (targetText.length !== prevTargetLenRef.current) { prevTargetLenRef.current = targetText.length; if (targetText.length === 0 && displayedLen !== 0) { displayedLenRef.current = 0; setDisplayedLen(0); } } // Snap to full text when streaming ends const prevIsStreamingRef = useRef(isStreaming); if (isStreaming !== prevIsStreamingRef.current) { prevIsStreamingRef.current = isStreaming; if (!isStreaming && targetText.length > 0) { displayedLenRef.current = targetText.length; setDisplayedLen(targetText.length); } } // Respect the user's reduced-motion preference — skip the progressive // reveal and snap straight to the full text. Uses the package's SSR-safe // useMediaQuery (useMediaQuery.ts) so the read stays in sync if the // preference changes mid-stream. const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); // Animation loop useEffect(() => { if (!isStreaming || speed === 'instant' || prefersReducedMotion) { return; } function tick(now: number) { const elapsed = now - lastTickRef.current; if (elapsed >= tickMs) { lastTickRef.current = now; const target = targetRef.current; const currentLen = displayedLenRef.current; if (currentLen < target.length) { const nextLen = Math.min(currentLen + charsPerTick, target.length); displayedLenRef.current = nextLen; setDisplayedLen(nextLen); } } rafRef.current = requestAnimationFrame(tick); } rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }; }, [isStreaming, charsPerTick, tickMs, speed, prefersReducedMotion]); if (!isStreaming || speed === 'instant' || prefersReducedMotion) { return targetText; } return targetText.slice(0, snapToGraphemeBoundary(targetText, displayedLen)); }