diff --git a/.changeset/animation-effects.md b/.changeset/animation-effects.md new file mode 100644 index 00000000..8f0ed0b2 --- /dev/null +++ b/.changeset/animation-effects.md @@ -0,0 +1,18 @@ +--- +"streamdown": patch +--- + +feat: accept animation effect objects in `animated.animation` + +An `AnimationEffect` bundles a keyframes name with the per-word behavior those keyframes need: + +```ts +interface AnimationEffect { + name: string; // @keyframes sd- + duration?: number; // default; animated.duration overrides it + scatter?: number; // max extra delay per word, hashed from its position + decorate?: (text: string, seed: number) => Record<`data-${string}`, string>; +} +``` + +`scatter` reveals words in a stable random order instead of left to right. `decorate` adds `data-*` attributes to each word while it animates, for effect styles to use. Other attributes are dropped. String animation names work as before. diff --git a/.changeset/smooth-streaming.md b/.changeset/smooth-streaming.md new file mode 100644 index 00000000..701dd479 --- /dev/null +++ b/.changeset/smooth-streaming.md @@ -0,0 +1,20 @@ +--- +"streamdown": patch +--- + +feat: add `smooth` prop to pace streams that arrive in large chunks + +Some providers and relays deliver text in bursts, such as ~150 characters every ~500 ms, which renders as a jump followed by a pause. With `smooth`, Streamdown measures the time between arrivals and reveals each chunk a word at a time, so it finishes about when the next chunk is expected. + +```tsx + + {markdown} + +``` + +- Off by default. When off, rendering is unchanged. +- Only appends are paced. Resets, edits and full-text replacements render immediately, as does content present on mount. +- A word split across chunks is held until it's complete. +- With `animated`, `stagger` defaults to `0`, since pacing already spaces words out. An explicit `stagger` still applies. +- When `isAnimating` becomes `false`, the remaining text appears within 250 ms. Until then, caret, animation and incomplete-Markdown handling stay active, even if `mode` switches to `"static"` in the same update. +- Hidden tabs render immediately, since browsers pause animation frames there. diff --git a/.changeset/streamdown-effects.md b/.changeset/streamdown-effects.md new file mode 100644 index 00000000..6aff80fd --- /dev/null +++ b/.changeset/streamdown-effects.md @@ -0,0 +1,16 @@ +--- +"@streamdown/effects": patch +--- + +feat: add `@streamdown/effects` with `diffuse` and `scramble` + +Diffusion-style streaming effects for `animated.animation`. `diffuse` sharpens words out of a blur in scattered order. `scramble` shows noise glyphs that resolve to the real text, keeping the real words in the DOM for copy and assistive technology. + +```tsx +import { scramble } from "@streamdown/effects"; +import "@streamdown/effects/styles.css"; + + + {markdown} + +``` diff --git a/.changeset/tidy-words-fade.md b/.changeset/tidy-words-fade.md new file mode 100644 index 00000000..d1b304f1 --- /dev/null +++ b/.changeset/tidy-words-fade.md @@ -0,0 +1,15 @@ +--- +"streamdown": patch +--- + +Preserve pending and active word animations across streaming updates instead of setting their duration to zero before they finish. Apply the same timing to list markers, task checkboxes, images, and horizontal rules. + +Allow finite text animations to finish after streaming stops before removing their wrappers. Cleanup follows browser animation completion, including cancellation, rather than using a fixed timeout. + +Discard animation history for removed blocks and reset the timeline when content is cleared, so the first words of a replay animate again instead of appearing instantly. + +Exclude parser-generated layout whitespace from animation offsets so new words inside lists receive their fade and tight-to-loose list transitions preserve existing text history. Source whitespace remains counted. + +Animate fenced code as one unit on the shared timeline, preserving its fade while code and syntax highlighting update. + +Use one timestamp per render pass when scheduling blocks, so parsing time cannot let later blocks overtake earlier text. diff --git a/apps/website/content/docs/animation.mdx b/apps/website/content/docs/animation.mdx index 20a08a2e..2ac305ff 100644 --- a/apps/website/content/docs/animation.mdx +++ b/apps/website/content/docs/animation.mdx @@ -103,7 +103,7 @@ export default function Page() { | Option | Type | Default | Description | |--------|------|---------|-------------| -| `animation` | `string` | `"fadeIn"` | Animation name. Built-in: `fadeIn`, `blurIn`, `slideUp`. Custom strings are prefixed with `sd-`. | +| `animation` | `string \| AnimationEffect` | `"fadeIn"` | Animation name. Built-in: `fadeIn`, `blurIn`, `slideUp`. Custom strings are prefixed with `sd-`. Also accepts an [effect](#animation-effects). | | `duration` | `number` | `150` | Animation duration in milliseconds. | | `easing` | `string` | `"ease"` | CSS timing function. | | `sep` | `"word" \| "char"` | `"word"` | Split text by word or character. | @@ -145,6 +145,27 @@ Define your own `@keyframes` and reference them by name: The animation name is automatically prefixed with `sd-`, so define your keyframes as `sd-yourName`. +## Animation effects + +`animation` also accepts an effect object. An effect bundles a keyframes name with the per-word behavior those keyframes need, such as a scattered reveal order or attributes for your CSS to draw. [`@streamdown/effects`](/docs/plugins/effects) provides ready-made effects. + +```tsx +import type { AnimationEffect } from "streamdown"; + +const shimmer: AnimationEffect = { + name: "shimmer", // uses @keyframes sd-shimmer + duration: 300, // default; animated.duration overrides it + scatter: 120, // up to 120 ms extra delay per word, in a stable random order + decorate: (text, seed) => ({ "data-shimmer": text }), // extra data-* attributes +}; + + + {markdown} +; +``` + +`decorate` runs for each word while it animates. Return the same attributes for the same `text` and `seed`, because words re-render while they animate. Only `data-*` attributes are applied. + ## Callbacks Use `onAnimationStart` and `onAnimationEnd` to react to animation state changes. These fire when `isAnimating` transitions from `false` to `true` and vice versa. Both callbacks are suppressed in `mode="static"`. @@ -253,6 +274,7 @@ You can override these in your own CSS for more control. ## Related features +- [Smooth streaming](/docs/smooth-streaming) — Pace streams that arrive in large chunks - [Carets](/docs/carets) — Blinking cursor indicator during streaming - [Plugins](/docs/plugins) — Overview of the plugin system - [Configuration](/docs/configuration) — All Streamdown props including `isAnimating` diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx index 07d60663..b4e1d9e9 100644 --- a/apps/website/content/docs/configuration.mdx +++ b/apps/website/content/docs/configuration.mdx @@ -189,6 +189,11 @@ Math rendering and CJK support require installing separate plugins. See [Mathema description: "Enable character-by-character animation for streaming content. See [Animation](/docs/animation).", type: "boolean | AnimateOptions", }, + smooth: { + description: "Reveal text that arrives in large chunks a word at a time, at the rate it has been arriving. See [Smooth streaming](/docs/smooth-streaming).", + type: "boolean", + default: "false", + }, linkSafety: { description: "Configure link safety modals for external URLs. See [Link Safety](/docs/link-safety).", type: "LinkSafetyConfig", diff --git a/apps/website/content/docs/plugins/effects.mdx b/apps/website/content/docs/plugins/effects.mdx new file mode 100644 index 00000000..1b823193 --- /dev/null +++ b/apps/website/content/docs/plugins/effects.mdx @@ -0,0 +1,59 @@ +--- +title: "@streamdown/effects" +description: Streaming text effects for the animated prop. +type: reference +summary: Diffusion-style effects that resolve words in scattered order, from a blur or from noise glyphs. +prerequisites: + - /docs/animation +related: + - /docs/smooth-streaming +--- + +The `@streamdown/effects` package provides [animation effects](/docs/animation#animation-effects) that reveal streaming text in scattered order, like a diffusion model resolving its output. + +- `diffuse`: words sharpen out of a blur. +- `scramble`: words start as noise glyphs that change once, then resolve to the real text. + +## Install + +```package-install +npm install @streamdown/effects +``` + +## Usage + +Import the effect and its styles, and pass the effect as `animated.animation`: + +```tsx title="app/page.tsx" +import { Streamdown } from "streamdown"; +import { scramble } from "@streamdown/effects"; +import "streamdown/styles.css"; +import "@streamdown/effects/styles.css"; + +export default function Page() { + return ( + + {markdown} + + ); +} +``` + +Effects look best with [`smooth`](/docs/smooth-streaming), which reveals text a few words at a time. Without it, a large chunk resolves all at once. + +## Options + +`animated.duration`, `animated.easing`, and `animated.sep` work as they do for built-in animations. `sep: "char"` scrambles each character on its own. + +| Effect | Default duration | Scatter | +|--------|------------------|---------| +| `diffuse` | 420 ms | up to 140 ms | +| `scramble` | 320 ms | up to 160 ms | + +## Accessibility + +`scramble` keeps the real text in the DOM. It paints the text transparent and draws the noise with CSS pseudo-elements that have empty alternative text, so selection, copy, and assistive technology get the real words. With `prefers-reduced-motion: reduce`, `scramble` falls back to a fade without glyphs. diff --git a/apps/website/content/docs/plugins/meta.json b/apps/website/content/docs/plugins/meta.json index 1561e34b..f8e42a63 100644 --- a/apps/website/content/docs/plugins/meta.json +++ b/apps/website/content/docs/plugins/meta.json @@ -1,4 +1,4 @@ { "title": "Plugins", - "pages": ["index", "code", "mermaid", "math", "cjk"] + "pages": ["index", "code", "mermaid", "math", "cjk", "effects"] } diff --git a/apps/website/content/docs/smooth-streaming.mdx b/apps/website/content/docs/smooth-streaming.mdx new file mode 100644 index 00000000..2cb7e1ba --- /dev/null +++ b/apps/website/content/docs/smooth-streaming.mdx @@ -0,0 +1,45 @@ +--- +title: Smooth streaming +description: Reveal streams that arrive in large chunks a word at a time. +type: reference +summary: Paces text that arrives in large, infrequent chunks so it appears at a steady rate. +related: + - /docs/animation + - /docs/carets +--- + +Some providers and relays deliver text in large chunks, such as 150 characters every half second. Rendered as it arrives, the text jumps forward and then pauses, and the caret jumps with it. Set `smooth` to reveal each chunk a word at a time, at the rate text has been arriving. + +## Enable smooth streaming + +```tsx title="app/page.tsx" +import { Streamdown } from "streamdown"; + +export default function Page() { + return ( + + {markdown} + + ); +} +``` + +`smooth` works with or without [`animated`](/docs/animation). With `animated`, each word animates as soon as it's revealed: `smooth` sets the default `stagger` to `0`, because pacing already spaces the words out. A `stagger` you set still applies. + +## How it works + +- Streamdown measures the time between chunks and reveals the text it has not shown yet so that it finishes about when the next chunk is expected. +- Text appears in whole words. While streaming, a word split across two chunks is held until it's complete. +- Only appended text is paced. Any other change renders immediately, such as a new message, an edit, or a model that resends the full text on every update. +- Content that exists when the component mounts renders immediately. +- Browsers pause animation frames in background tabs, so text that arrives there renders immediately. + +## Latency + +Pacing delays text by about one chunk interval: each chunk finishes appearing around when the next one arrives. For a stream that sends a chunk every 500 ms, that's about 500 ms. For a stream that sends tokens every few milliseconds, it's a frame or two. + +## When the stream ends + +Streamdown uses `isAnimating` to know when the stream ends. When it becomes `false`, the remaining text appears within 250 ms. Until then, Streamdown keeps its streaming behavior, including the caret, animations, and incomplete Markdown handling, even if you set `mode="static"` in the same update. + +`onAnimationEnd` fires after the remaining text appears. As with other animation callbacks, it's suppressed in `mode="static"`. diff --git a/package.json b/package.json index 3f16fd0b..238cb6f3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "bench": "turbo run bench", "bench:ui": "turbo run bench:ui", "build": "turbo run build", - "build:packages": "pnpm build --filter streamdown --filter @streamdown/math --filter @streamdown/code --filter @streamdown/cjk --filter @streamdown/mermaid", + "build:packages": "pnpm build --filter streamdown --filter @streamdown/math --filter @streamdown/code --filter @streamdown/cjk --filter @streamdown/mermaid --filter @streamdown/effects", "bump-deps": "npx npm-check-updates -u --deep -x \"recharts\" && pnpm i", "changeset": "changeset", "changeset:publish": "pnpm build && changeset publish", diff --git a/packages/streamdown-effects/__tests__/index.test.ts b/packages/streamdown-effects/__tests__/index.test.ts new file mode 100644 index 00000000..5a788c1c --- /dev/null +++ b/packages/streamdown-effects/__tests__/index.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { diffuse, noise, scramble } from "../index"; + +const UPPER_RE = /\p{Lu}/u; +const LOWER_RE = /\p{Ll}/u; +const DIGIT_RE = /\p{N}/u; + +describe("noise", () => { + it("keeps the shape of the text", () => { + const text = "Rivers, 42 times—über!"; + const out = noise(text, 7); + expect([...out]).toHaveLength([...text].length); + [...text].forEach((real, i) => { + const fake = [...out][i]; + if (UPPER_RE.test(real)) { + expect(fake).toMatch(UPPER_RE); + } else if (LOWER_RE.test(real)) { + expect(fake).toMatch(LOWER_RE); + } else if (DIGIT_RE.test(real)) { + expect(fake).toMatch(DIGIT_RE); + } else { + expect(fake).toBe(real); + } + }); + }); + + it("is deterministic per seed", () => { + expect(noise("streaming", 3)).toBe(noise("streaming", 3)); + expect(noise("streaming", 3)).not.toBe(noise("streaming", 4)); + }); +}); + +describe("effects", () => { + it("diffuse scatters a blur without decorating", () => { + expect(diffuse).toEqual({ duration: 420, name: "diffuse", scatter: 140 }); + }); + + it("scramble stamps two different noise sets", () => { + const attributes = scramble.decorate?.("Streaming", 11) ?? {}; + expect(Object.keys(attributes)).toEqual([ + "data-sd-glyphs", + "data-sd-glyphs-alt", + ]); + expect(attributes["data-sd-glyphs"]).toHaveLength(9); + expect(attributes["data-sd-glyphs"]).not.toBe( + attributes["data-sd-glyphs-alt"] + ); + expect(scramble.decorate?.("Streaming", 11)).toEqual(attributes); + }); +}); diff --git a/packages/streamdown-effects/index.ts b/packages/streamdown-effects/index.ts new file mode 100644 index 00000000..08be0972 --- /dev/null +++ b/packages/streamdown-effects/index.ts @@ -0,0 +1,70 @@ +/** + * Matches `AnimationEffect` in streamdown. Declared here so this package has + * no runtime or type dependency on it. + */ +export interface AnimationEffect { + decorate?: (text: string, seed: number) => Record<`data-${string}`, string>; + duration?: number; + name: string; + scatter?: number; +} + +const UPPERCASE_RE = /\p{Lu}/u; +const LETTER_RE = /\p{L}/u; +const NUMBER_RE = /\p{N}/u; + +const LOWER = "abcdefghijklmnopqrstuvwxyz"; +const UPPER = LOWER.toUpperCase(); +const DIGITS = "0123456789"; + +const hash01 = (a: number, b: number): number => { + const x = Math.sin(a * 12.9898 + b * 78.233) * 43_758.5453; + return x - Math.floor(x); +}; + +const pick = (set: string, seed: number, index: number): string => + set.charAt(Math.floor(hash01(seed, index) * set.length)); + +/** + * Same-shape noise for a word: letters and digits become random characters + * of the same class; punctuation and spacing are kept. Deterministic for a + * given seed. + */ +export const noise = (text: string, seed: number): string => { + let out = ""; + let index = 0; + for (const char of text) { + if (UPPERCASE_RE.test(char)) { + out += pick(UPPER, seed, index); + } else if (LETTER_RE.test(char)) { + out += pick(LOWER, seed, index); + } else if (NUMBER_RE.test(char)) { + out += pick(DIGITS, seed, index); + } else { + out += char; + } + index += 1; + } + return out; +}; + +/** Words sharpen out of a blur, in scattered order. */ +export const diffuse: AnimationEffect = { + duration: 420, + name: "diffuse", + scatter: 140, +}; + +/** + * Words start as noise glyphs that change once, then resolve to the real + * text, in scattered order. The real text stays in the DOM throughout. + */ +export const scramble: AnimationEffect = { + decorate: (text, seed) => ({ + "data-sd-glyphs": noise(text, seed), + "data-sd-glyphs-alt": noise(text, seed + 1), + }), + duration: 320, + name: "scramble", + scatter: 160, +}; diff --git a/packages/streamdown-effects/package.json b/packages/streamdown-effects/package.json new file mode 100644 index 00000000..a1e39993 --- /dev/null +++ b/packages/streamdown-effects/package.json @@ -0,0 +1,40 @@ +{ + "name": "@streamdown/effects", + "version": "0.0.0", + "description": "Streaming text effects for Streamdown's animated prop", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/streamdown.git", + "directory": "packages/streamdown-effects" + }, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./styles.css": "./styles.css" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "styles.css" + ], + "sideEffects": [ + "*.css" + ], + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:coverage": "vitest --coverage run" + }, + "devDependencies": { + "@vitest/coverage-v8": "^4.1.10", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^5.0.0" + } +} diff --git a/packages/streamdown-effects/styles.css b/packages/streamdown-effects/styles.css new file mode 100644 index 00000000..7c128c8d --- /dev/null +++ b/packages/streamdown-effects/styles.css @@ -0,0 +1,89 @@ +@keyframes sd-diffuse { + from { + opacity: 0; + filter: blur(8px); + } + 55% { + opacity: 0.75; + filter: blur(2px); + } + to { + opacity: 1; + filter: blur(0); + } +} + +/* + * scramble: the real word is laid out but unpainted while noise glyphs, + * drawn over it from data attributes, change once and then resolve. The text + * stays in the DOM, so selection, copy and assistive tech get real words; the + * glyphs use empty alt text. + */ +@keyframes sd-scramble { + from { + -webkit-text-fill-color: transparent; + animation-timing-function: step-end; + } + to { + -webkit-text-fill-color: currentcolor; + } +} + +@keyframes sd-glyphs { + from { + opacity: 1; + } + 50%, + to { + opacity: 0; + } +} + +@keyframes sd-glyphs-alt { + from { + opacity: 0; + } + 50% { + opacity: 1; + } + to { + opacity: 0; + } +} + +[data-sd-glyphs] { + position: relative; +} + +[data-sd-glyphs]::before, +[data-sd-glyphs]::after { + /* Block offset stays auto: the static position starts at the top of the + line box, so the glyphs share the text's baseline. */ + position: absolute; + inset-inline-start: 0; + -webkit-text-fill-color: currentcolor; + white-space: pre; + pointer-events: none; + animation: sd-glyphs var(--sd-duration, 320ms) step-end var(--sd-delay, 0ms) + both; +} + +[data-sd-glyphs]::before { + content: attr(data-sd-glyphs) / ""; +} + +[data-sd-glyphs]::after { + content: attr(data-sd-glyphs-alt) / ""; + animation-name: sd-glyphs-alt; +} + +@media (prefers-reduced-motion: reduce) { + [data-sd-glyphs] { + animation-name: sd-fadeIn; + } + + [data-sd-glyphs]::before, + [data-sd-glyphs]::after { + content: none; + } +} diff --git a/packages/streamdown-effects/tsconfig.json b/packages/streamdown-effects/tsconfig.json new file mode 100644 index 00000000..17da37c9 --- /dev/null +++ b/packages/streamdown-effects/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "noEmit": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "build", "dist"] +} diff --git a/packages/streamdown-effects/tsup.config.ts b/packages/streamdown-effects/tsup.config.ts new file mode 100644 index 00000000..0e67e3c5 --- /dev/null +++ b/packages/streamdown-effects/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + dts: true, + entry: ["index.ts"], + format: ["esm"], + minify: true, + outDir: "dist", + sourcemap: false, + treeshake: true, + platform: "browser", +}); diff --git a/packages/streamdown-effects/vitest.config.ts b/packages/streamdown-effects/vitest.config.ts new file mode 100644 index 00000000..c55c5cc9 --- /dev/null +++ b/packages/streamdown-effects/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + }, +}); diff --git a/packages/streamdown/__tests__/animate-issues.test.tsx b/packages/streamdown/__tests__/animate-issues.test.tsx index 2cb56a87..a2cca25f 100644 --- a/packages/streamdown/__tests__/animate-issues.test.tsx +++ b/packages/streamdown/__tests__/animate-issues.test.tsx @@ -8,13 +8,15 @@ */ import { act, render } from "@testing-library/react"; import { StrictMode, useEffect } from "react"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Streamdown } from "../index"; import { MAX_ANIMATION_BACKLOG_MS } from "../lib/animate"; const NEW_WORD_RE = /^(Next|section|arrives)$/; const SPAN_GAP_RE = /<\/span> vi.restoreAllMocks()); + const parseDelay = (el: Element): number => { const raw = (el as HTMLElement).style.getPropertyValue("--sd-delay").trim(); if (!raw) { @@ -257,7 +259,8 @@ describe("issue #570 — spans come off when isAnimating goes false", () => { expect(mounts).toBe(1); }); - it("suppresses already-seen words under StrictMode double-invoke", async () => { + it("suppresses settled words under StrictMode double-invoke", async () => { + const clock = vi.spyOn(performance, "now").mockReturnValue(1000); const config = { animation: "fadeIn" as const, duration: 200, @@ -276,6 +279,7 @@ describe("issue #570 — spans come off when isAnimating goes false", () => { await act(() => Promise.resolve()); await act(() => Promise.resolve()); + clock.mockReturnValue(1250); await act(() => { rerender( @@ -292,7 +296,7 @@ describe("issue #570 — spans come off when isAnimating goes false", () => { container.querySelectorAll("[data-sd-animate]") ) as HTMLElement[]; - // "one"/"two"/"three" should have duration 0 (already seen); "four"/"five" + // "one"/"two"/"three" should have duration 0 (finished); "four"/"five" // keep the configured duration. Under the old get-and-reset path, StrictMode // would wipe prevContentLength and every span would be 200ms. const durations = spans.map((el) => diff --git a/packages/streamdown/__tests__/animate.test.ts b/packages/streamdown/__tests__/animate.test.ts index ff35d275..0119c647 100644 --- a/packages/streamdown/__tests__/animate.test.ts +++ b/packages/streamdown/__tests__/animate.test.ts @@ -1,5 +1,7 @@ import rehypeParse from "rehype-parse"; import rehypeStringify from "rehype-stringify"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; import { unified } from "unified"; import { describe, expect, it } from "vitest"; import { @@ -17,7 +19,7 @@ const WORLD_SPAN_RE = />world<\/span>/; const I_SPACE_SPAN_RE = />i <\/span>/; const INLINE_CODE_ANIMATE_RE = /]*>[\s\S]*data-sd-animate[\s\S]*world[\s\S]*<\/code>/; -const FENCED_PRE_BARE_RE = /
block<\/code><\/pre>/;
+const FENCED_PRE_BARE_RE = /]*>block<\/code><\/pre>/;
 const PRE_ANIMATE_RE = /
[\s\S]*data-sd-animate/;
 
 const INPUT_TAG_RE = /]*>/;
@@ -130,12 +132,14 @@ describe("animate plugin", () => {
 
     it("should not animate text inside pre elements", async () => {
       const result = await processHtml("
some code
"); - expect(result).not.toContain("data-sd-animate"); + expect(result).toContain("data-sd-animate"); + expect(result).not.toContain(" code (fenced blocks)", async () => { const result = await processHtml("
const x = 1
"); - expect(result).not.toContain("data-sd-animate"); + expect(result).toContain("data-sd-animate"); + expect(result).not.toContain(" { }); describe("getLastRenderCharCount", () => { + it("counts source spaces without counting generated list padding", async () => { + const plugin = createAnimatePlugin(); + const processor = unified() + .use(remarkParse) + .use(remarkRehype) + .use(plugin.rehypePlugin) + .use(rehypeStringify); + await processor.process("1. *One* **two**"); + expect(plugin.getLastRenderCharCount()).toBe("One two".length); + }); + it("should return 0 before any render", () => { const plugin = createAnimatePlugin(); expect(plugin.getLastRenderCharCount()).toBe(0); @@ -261,7 +276,7 @@ describe("animate plugin", () => { const plugin = createAnimatePlugin(); // First render: "Hello" await processHtml("

Hello

", plugin); - plugin.commit(); + plugin.setPrevContentLength(plugin.getLastRenderCharCount()); // Second render: "Hello world" — committed count drives the skip window const result = await processHtml("

Hello world

", plugin); @@ -269,12 +284,52 @@ describe("animate plugin", () => { // "Hello" (chars 0-4) should have duration:0ms — already visible // " world" should have normal duration const spans = result.match(/--sd-duration:[^;"]*/g) ?? []; - expect(spans.some((s) => s.includes("0ms"))).toBe(true); - expect(spans.some((s) => s.includes("150ms"))).toBe(true); + expect(spans).toEqual(["--sd-duration:0ms", "--sd-duration:150ms"]); }); }); describe("stagger delay", () => { + it.each([ + ["list marker", "
  • Alpha beta", "
", "li"], + [ + "task checkbox", + '
  • Alpha beta', + "
", + "input", + ], + ["image", '

Alpha beta', "

", "img"], + ["rule", "

Alpha beta", "

", "hr"], + ])("preserves a pending %s animation across updates", async (_name, prefix, suffix, selector) => { + let now = 1000; + const timeline = createAnimateTimeline({ now: () => now }); + const plugin = createAnimatePlugin({ duration: 250, timeline }); + timeline.beginPass(now); + const before = document.createElement("div"); + before.innerHTML = await processHtml(prefix + suffix, plugin); + const style = before.querySelector(selector)?.getAttribute("style"); + expect(style).toContain("250ms"); + plugin.commit(); + timeline.commitPass(); + + now += 50; + timeline.beginPass(now); + const after = document.createElement("div"); + after.innerHTML = await processHtml(`${prefix} gamma${suffix}`, plugin); + expect(after.querySelector(selector)?.getAttribute("style")).toBe(style); + plugin.commit(); + timeline.commitPass(); + + now += 500; + timeline.beginPass(now); + after.innerHTML = await processHtml( + `${prefix} gamma delta${suffix}`, + plugin + ); + expect(after.querySelector(selector)?.getAttribute("style")).toContain( + "duration:0ms" + ); + }); + it("should apply incremental delay to each word", async () => { const plugin = createAnimatePlugin({ stagger: 50 }); const result = await processHtml("

Hello world foo

", plugin); @@ -413,7 +468,7 @@ describe("animate plugin", () => { const result = await processHtml("

Hello world foo

", plugin); timeline.commitPass(); - expect(delaysOf(result)).toEqual([70]); + expect(delaysOf(result)).toEqual([50, 70]); }); it("per-plugin mark/rewind makes StrictMode double-rehype idempotent", async () => { @@ -736,3 +791,64 @@ describe("animate plugin", () => { }); }); }); + +describe("code fence animation", () => { + it("reserves one timeline slot without changing highlighted code", async () => { + const plugin = createAnimatePlugin({ duration: 250, stagger: 10 }); + const code = 'const x = 1\n'; + const result = await processHtml( + `

Code example

${code}

Following

`, + plugin + ); + expect(delaysOf(result)).toEqual([10, 20, 30]); + expect(result).toContain(code); + expect(result.match(/data-sd-animate(?: |>|=)/g)).toHaveLength(4); + }); + + it("keeps code and following text timings when the fence grows", async () => { + let now = 1000; + const timeline = createAnimateTimeline({ now: () => now }); + const plugin = createAnimatePlugin({ + duration: 250, + stagger: 10, + timeline, + }); + timeline.beginPass(now); + const initial = await processHtml( + "
one

Following

", + plugin + ); + plugin.commit(); + timeline.commitPass(); + now += 50; + timeline.beginPass(now); + const growing = await processHtml( + "
one two three four

Following new

", + plugin + ); + expect(delaysOf(initial)).toEqual([0, 10]); + expect(delaysOf(growing)).toEqual([0, 10]); + expect(growing.match(/--sd-duration:250ms/g)).toHaveLength(3); + plugin.commit(); + timeline.commitPass(); + now += 500; + timeline.beginPass(now); + const settled = await processHtml( + "
one two three four five

Following new

", + plugin + ); + expect(settled.match(/--sd-duration:0ms/g)).toHaveLength(3); + }); +}); + +it("schedules blocks against one clock even when rendering takes time", async () => { + let now = 1000; + const timeline = createAnimateTimeline({ now: () => now }); + const heading = createAnimatePlugin({ stagger: 10, timeline }); + const code = createAnimatePlugin({ stagger: 10, timeline }); + timeline.beginPass(now); + await processHtml("

Code example

", heading); + now += 15; + const result = await processHtml("
const x = 1
", code); + expect(delaysOf(result)).toEqual([20]); +}); diff --git a/packages/streamdown/__tests__/animation-drain.test.tsx b/packages/streamdown/__tests__/animation-drain.test.tsx new file mode 100644 index 00000000..649024b8 --- /dev/null +++ b/packages/streamdown/__tests__/animation-drain.test.tsx @@ -0,0 +1,101 @@ +import { act, render } from "@testing-library/react"; +import { StrictMode } from "react"; +import { describe, expect, it } from "vitest"; +import { Streamdown } from "../index"; + +function pendingAnimation() { + let finish: () => void = () => undefined; + const finished = new Promise((resolve) => { + finish = resolve; + }); + return { + animation: { + animationName: "sd-fadeIn", + playState: "running", + effect: { getComputedTiming: () => ({ endTime: 500 }) }, + finished, + }, + finish, + }; +} + +function message(text: string, streaming: boolean) { + return ( + + + {text} + + + ); +} + +describe("animation drain", () => { + it("retains pending words after streaming ends and cleans up after every animation finishes", async () => { + const first = pendingAnimation(); + const last = pendingAnimation(); + const { container, rerender } = render(message("Alpha beta", true)); + Object.defineProperty(container.firstElementChild, "getAnimations", { + value: () => [first.animation, last.animation], + }); + const word = container.querySelector("[data-sd-animate]"); + await act(() => rerender(message("Alpha beta gamma", false))); + expect(container.querySelector("[data-sd-animate]")).toBe(word); + expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(3); + + await act(async () => first.finish()); + expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(3); + await act(async () => last.finish()); + expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(0); + expect(container.textContent).toBe("Alpha beta gamma"); + }); + + it("does not let completion of an earlier drain tear down a resumed stream", async () => { + const previous = pendingAnimation(); + const current = pendingAnimation(); + let animations = [previous.animation]; + const { container, rerender } = render(message("Alpha", true)); + Object.defineProperty(container.firstElementChild, "getAnimations", { + value: () => animations, + }); + await act(() => rerender(message("Alpha", false))); + await act(() => rerender(message("Alpha beta", true))); + await act(async () => previous.finish()); + animations = [current.animation]; + await act(() => rerender(message("Alpha beta gamma", false))); + expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(3); + await act(async () => current.finish()); + expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(0); + }); + + it("resamples animations when the final content changes during a drain", async () => { + const previous = pendingAnimation(); + const current = pendingAnimation(); + let animations = [previous.animation]; + const { container, rerender } = render(message("Alpha", true)); + Object.defineProperty(container.firstElementChild, "getAnimations", { + value: () => animations, + }); + await act(() => rerender(message("Alpha", false))); + animations = [previous.animation, current.animation]; + await act(() => rerender(message("Alpha beta", false))); + await act(async () => previous.finish()); + expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(2); + await act(async () => current.finish()); + expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(0); + }); + + it("does not wait for unrelated or infinite animations", async () => { + const unrelated = pendingAnimation(); + unrelated.animation.animationName = "spinner"; + const infinite = pendingAnimation(); + infinite.animation.effect.getComputedTiming = () => ({ + endTime: Number.POSITIVE_INFINITY, + }); + const { container, rerender } = render(message("Alpha", true)); + Object.defineProperty(container.firstElementChild, "getAnimations", { + value: () => [unrelated.animation, infinite.animation], + }); + await act(() => rerender(message("Alpha", false))); + expect(container.querySelectorAll("[data-sd-animate]")).toHaveLength(0); + }); +}); diff --git a/packages/streamdown/__tests__/animation-effect.test.ts b/packages/streamdown/__tests__/animation-effect.test.ts new file mode 100644 index 00000000..b870123f --- /dev/null +++ b/packages/streamdown/__tests__/animation-effect.test.ts @@ -0,0 +1,111 @@ +import rehypeParse from "rehype-parse"; +import rehypeStringify from "rehype-stringify"; +import { unified } from "unified"; +import { describe, expect, it, vi } from "vitest"; +import { + type AnimatePlugin, + type AnimationEffect, + createAnimatePlugin, +} from "../lib/animate"; + +const DELAY_RE = /--sd-delay:(\d+)ms/g; +const DURATION_RE = /--sd-duration:(\d+)ms/; +const MARK_RE = /data-mark="([^"]*)"/g; + +const prose = + "

The quick brown fox jumps over the lazy dog near the quiet river.

"; + +const run = async (plugin: AnimatePlugin, html = prose) => + String( + await unified() + .use(rehypeParse, { fragment: true }) + .use(plugin.rehypePlugin) + .use(rehypeStringify) + .process(html) + ); + +const delaysOf = (html: string) => + Array.from(html.matchAll(DELAY_RE), (m) => Number.parseInt(m[1], 10)); + +const isSorted = (values: number[]) => + values.every((value, i) => i === 0 || value >= values[i - 1]); + +describe("animation effects", () => { + it("uses the effect's keyframes and default duration", async () => { + const effect: AnimationEffect = { name: "shimmer", duration: 400 }; + const html = await run(createAnimatePlugin({ animation: effect })); + expect(html).toContain("--sd-animation:sd-shimmer"); + expect(html.match(DURATION_RE)?.[1]).toBe("400"); + }); + + it("lets animated.duration override the effect's default", async () => { + const html = await run( + createAnimatePlugin({ + animation: { name: "shimmer", duration: 400 }, + duration: 120, + }) + ); + expect(html.match(DURATION_RE)?.[1]).toBe("120"); + }); + + it("scatters delays when the effect asks for it", async () => { + const scattered = await run( + createAnimatePlugin({ animation: { name: "shimmer", scatter: 150 } }) + ); + const ordered = await run(createAnimatePlugin({ animation: "fadeIn" })); + expect(isSorted(delaysOf(scattered))).toBe(false); + expect(isSorted(delaysOf(ordered))).toBe(true); + }); + + it("gives each word the same timing and attributes on every run", async () => { + const plugin = createAnimatePlugin({ + animation: { + decorate: (text, seed) => ({ "data-mark": `${text}:${seed}` }), + name: "shimmer", + scatter: 150, + }, + }); + expect(await run(plugin)).toBe(await run(plugin)); + }); + + it("decorates animating words with the trimmed word and a stable seed", async () => { + const decorate = vi.fn((text: string) => ({ "data-mark": text })); + const html = await run( + createAnimatePlugin({ animation: { decorate, name: "shimmer" } }) + ); + const marks = Array.from(html.matchAll(MARK_RE), (m) => m[1]); + expect(marks[0]).toBe("The"); + expect(marks).toHaveLength(13); + const seeds = decorate.mock.calls.map(([, seed]) => seed); + expect(new Set(seeds).size).toBe(seeds.length); + }); + + it("only passes data attributes through", async () => { + const html = await run( + createAnimatePlugin({ + animation: { + // Simulate an effect returning something it shouldn't. + decorate: () => + ({ "data-ok": "yes", onclick: "x" }) as Record< + `data-${string}`, + string + >, + name: "shimmer", + }, + }) + ); + expect(html).toContain('data-ok="yes"'); + expect(html).not.toContain("onclick"); + }); + + it("stops decorating words whose animation has finished", async () => { + const decorate = vi.fn(() => ({ "data-mark": "x" })); + const plugin = createAnimatePlugin({ + animation: { decorate, name: "shimmer" }, + }); + plugin.setPrevContentLength(prose.length); + const html = await run(plugin); + expect(decorate).not.toHaveBeenCalled(); + expect(html).not.toContain("data-mark"); + }); +}); diff --git a/packages/streamdown/__tests__/animation-layout-whitespace.test.tsx b/packages/streamdown/__tests__/animation-layout-whitespace.test.tsx new file mode 100644 index 00000000..78d1960c --- /dev/null +++ b/packages/streamdown/__tests__/animation-layout-whitespace.test.tsx @@ -0,0 +1,79 @@ +import { act, render } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Streamdown } from "../index"; + +afterEach(() => vi.restoreAllMocks()); + +const prefixes = [ + ["paragraph", "But in ourselves, that we"], + ["tight list", "1. First\n2. But in ourselves, that we"], + ["loose ordered list", "1. First\n\n2. But in ourselves, that we"], + ["loose unordered list", "- First\n\n- But in ourselves, that we"], + ["italic list item", "1. First\n\n2. *But in ourselves, that we"], + ["nested list", "- First\n\n - But in ourselves, that we"], + ["quoted list", "> 1. First\n>\n> 2. But in ourselves, that we"], +]; + +describe.each(["word", "char"] as const)("layout whitespace (%s)", (sep) => { + it.each(prefixes)("fades every arriving unit in a %s", async (_, prefix) => { + const clock = vi.spyOn(performance, "now").mockReturnValue(1000); + const config = { duration: 250, stagger: 10, sep }; + const message = (text: string) => ( + + + {text} + + + ); + const { container, rerender } = render(message(prefix)); + const read = () => [ + ...container.querySelectorAll("[data-sd-animate]"), + ]; + const previous = read(); + const styles = previous.map((word) => word.getAttribute("style")); + clock.mockReturnValue(1050); + await act(() => rerender(message(`${prefix} are underlings.`))); + const updated = read(); + for (const [index, word] of previous.entries()) { + expect(updated[index]).toBe(word); + expect(word.getAttribute("style")).toBe(styles[index]); + } + const arrived = updated.slice(previous.length); + expect(arrived.map((word) => word.textContent).join("")).toBe( + "are underlings." + ); + for (const word of arrived) { + expect(word.style.getPropertyValue("--sd-duration")).toBe("250ms"); + } + const delays = arrived.map((word) => + Number.parseFloat(word.style.getPropertyValue("--sd-delay") || "0") + ); + for (let index = 1; index < delays.length; index++) { + expect(delays[index]).toBeGreaterThan(delays[index - 1]); + } + }); +}); + +it("preserves source whitespace between inline elements in the animation offsets", async () => { + const clock = vi.spyOn(performance, "now").mockReturnValue(1000); + const config = { duration: 250, stagger: 10 }; + const message = (text: string) => ( + + {text} + + ); + const { container, rerender } = render(message("1. First\n\n2. *One* ")); + clock.mockReturnValue(1050); + await act(() => rerender(message("1. First\n\n2. *One* **two** three"))); + const words = [ + ...container.querySelectorAll("[data-sd-animate]"), + ]; + expect(words.slice(-2).map((word) => word.textContent)).toEqual([ + "two", + "three", + ]); + for (const word of words.slice(-2)) { + expect(word.style.getPropertyValue("--sd-duration")).toBe("250ms"); + } +}); diff --git a/packages/streamdown/__tests__/animation-lifetime.test.tsx b/packages/streamdown/__tests__/animation-lifetime.test.tsx new file mode 100644 index 00000000..74a52178 --- /dev/null +++ b/packages/streamdown/__tests__/animation-lifetime.test.tsx @@ -0,0 +1,110 @@ +import { act, render } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Streamdown } from "../index"; + +afterEach(() => vi.restoreAllMocks()); + +describe("animation history after clearing content", () => { + it.each([ + 50, 1000, + ])("animates the first batch again after clearing at %i ms", async (elapsed) => { + let now = 1000; + vi.spyOn(performance, "now").mockImplementation(() => now); + const message = (text: string) => ( + + + {text} + + + ); + const batch = "Alpha beta gamma delta epsilon zeta eta theta"; + const { container, rerender } = render(message(batch)); + now += elapsed; + await act(() => rerender(message(""))); + await act(() => rerender(message(batch))); + const words = [ + ...container.querySelectorAll("[data-sd-animate]"), + ]; + expect(words).toHaveLength(8); + for (const [index, word] of words.entries()) { + expect(word.style.getPropertyValue("--sd-duration")).toBe("250ms"); + expect(word.style.getPropertyValue("--sd-delay") || "0ms").toBe( + `${index * 40}ms` + ); + } + }); +}); + +describe.each([ + false, + true, +])("animation lifetime (StrictMode: %s)", (strict) => { + it("preserves pending and active fades across fast updates, then settles them", async () => { + let now = 1000; + vi.spyOn(performance, "now").mockImplementation(() => now); + const config = { duration: 250, stagger: 100 }; + const message = (text: string) => { + const content = ( + + {text} + + ); + return strict ? {content} : content; + }; + const { container, rerender } = render(message("One two three")); + const words = [ + ...container.querySelectorAll("[data-sd-animate]"), + ]; + const styles = words.map((word) => word.getAttribute("style")); + expect(words).toHaveLength(3); + + now += 50; + await act(() => rerender(message("One two three four"))); + const updated = [ + ...container.querySelectorAll("[data-sd-animate]"), + ]; + for (const [index, word] of words.entries()) { + expect(updated[index]).toBe(word); + expect(word.getAttribute("style")).toBe(styles[index]); + } + + now += 50; + await act(() => rerender(message("One two three four five"))); + for (const [index, word] of words.entries()) { + expect(word.getAttribute("style")).toBe(styles[index]); + } + + now = 1600; + await act(() => rerender(message("One two three four five six"))); + for (const word of words) { + expect(word.style.getPropertyValue("--sd-duration")).toBe("0ms"); + } + const last = container.querySelector( + "[data-sd-animate]:last-child" + ); + expect(last?.textContent?.trim()).toBe("six"); + expect(last?.style.getPropertyValue("--sd-duration")).toBe("250ms"); + }); + + it("keeps a partial word's fade while tokens extend that word", async () => { + let now = 1000; + vi.spyOn(performance, "now").mockImplementation(() => now); + const message = (text: string) => { + const content = ( + + {text} + + ); + return strict ? {content} : content; + }; + const { container, rerender } = render(message("Hel")); + const word = container.querySelector("[data-sd-animate]"); + const style = word?.getAttribute("style"); + now += 50; + await act(() => rerender(message("Hello"))); + expect(container.querySelector("[data-sd-animate]")).toBe(word); + expect(word?.textContent).toBe("Hello"); + expect(word?.getAttribute("style")).toBe(style); + }); +}); diff --git a/packages/streamdown/__tests__/list-animation-retrigger.test.tsx b/packages/streamdown/__tests__/list-animation-retrigger.test.tsx index 46a7455e..61fda5fc 100644 --- a/packages/streamdown/__tests__/list-animation-retrigger.test.tsx +++ b/packages/streamdown/__tests__/list-animation-retrigger.test.tsx @@ -8,7 +8,7 @@ * re-run their CSS entry animation. * * Fix: the animate plugin tracks prevContentLength and sets --sd-duration:0ms for - * text-node positions that were already rendered in the previous pass, so + * text-node positions whose previous animation has finished, so * already-visible characters do not re-run their entry animation even when the * spans around them are rebuilt. * @@ -20,7 +20,7 @@ */ import { act, render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Streamdown } from "../index"; const animatedConfig = { @@ -30,8 +30,11 @@ const animatedConfig = { sep: "char" as const, }; +afterEach(() => vi.restoreAllMocks()); + describe("list animation retrigger fix (#410)", () => { - it("does not remount spans for existing list items when a new item appears", async () => { + it("does not re-animate settled list items when a new item appears", async () => { + const clock = vi.spyOn(performance, "now").mockReturnValue(1000); const { rerender, container } = render( {"1. Item 1\n2. Item 2\n"} @@ -50,6 +53,7 @@ describe("list animation retrigger fix (#410)", () => { }); // Simulate a new list group appearing (triggers tight→loose transition) + clock.mockReturnValue(3000); await act(() => { rerender( @@ -72,13 +76,7 @@ describe("list animation retrigger fix (#410)", () => { // characters carry --sd-duration:0ms. expect(container.querySelector("li > p")).toBeNull(); - // One character at the transition boundary is not suppressed: - // prevContentLength counts inter-element whitespace, and the tight -> loose - // change alters how much of it the block contains, so the boundary lands one - // character early. That accounting lives in lib/animate.ts and is unchanged - // here; before this test was updated the transition was never rendered at - // all, so it could not be observed. - const previouslyVisible = afterSpans.slice(0, initialSpans.length - 1); + const previouslyVisible = afterSpans.slice(0, initialSpans.length); for (const span of previouslyVisible) { expect( (span as HTMLElement).style.getPropertyValue("--sd-duration") @@ -95,7 +93,8 @@ describe("list animation retrigger fix (#410)", () => { } }); - it("sets --sd-duration:0ms on already-rendered content when item text grows", async () => { + it("sets --sd-duration:0ms on settled content when item text grows", async () => { + const clock = vi.spyOn(performance, "now").mockReturnValue(1000); // When a list item's text grows during streaming, its node position // changes (end column extends). This causes the memo'd MemoLi to // re-render, and the animate plugin applies 0ms to already-visible chars. @@ -118,6 +117,7 @@ describe("list animation retrigger fix (#410)", () => { // Streaming update: item text grows from "AB" to "AB CD" // This changes the li node position → MemoLi re-renders + clock.mockReturnValue(3000); await act(() => { rerender( diff --git a/packages/streamdown/__tests__/smooth.test.tsx b/packages/streamdown/__tests__/smooth.test.tsx new file mode 100644 index 00000000..28f88344 --- /dev/null +++ b/packages/streamdown/__tests__/smooth.test.tsx @@ -0,0 +1,209 @@ +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type BlockProps, Streamdown } from "../index"; + +const first = "Hello there"; +const words = (count: number) => + Array.from({ length: count }, (_, i) => `word${i}`).join(" "); +const full = `${first} ${words(40)}`; + +const advance = (ms: number) => { + act(() => { + vi.advanceTimersByTime(ms); + }); +}; + +const textOf = (container: HTMLElement) => container.textContent ?? ""; + +describe("smooth", () => { + beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + "setTimeout", + "clearTimeout", + "requestAnimationFrame", + "cancelAnimationFrame", + "performance", + "Date", + ], + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders appends immediately when off", () => { + const { container, rerender } = render( + {first} + ); + rerender({full}); + expect(textOf(container)).toBe(full); + }); + + it("shows existing content immediately on mount", () => { + const { container } = render({full}); + expect(textOf(container)).toBe(full); + }); + + it("reveals a large append over time, a word at a time", () => { + const { container, rerender } = render( + + {first} + + ); + advance(500); + rerender( + + {`${full} `} + + ); + expect(textOf(container)).toBe(first); + + advance(48); + const partial = textOf(container); + expect(partial.length).toBeGreaterThan(first.length); + expect(partial.length).toBeLessThan(full.length); + expect(full.startsWith(partial)).toBe(true); + expect(full.charAt(partial.length)).toBe(" "); + + advance(1000); + expect(textOf(container)).toBe(full); + }); + + it("keeps streaming behavior until caught up after switching to static", () => { + const { container, rerender } = render( + + {first} + + ); + advance(500); + // The stream ends in the same update that delivers a large final chunk, + // and the app switches to static mode. + rerender( + + {full} + + ); + const caret = () => + (container.firstElementChild as HTMLElement).style.getPropertyValue( + "--streamdown-caret" + ); + expect(textOf(container).length).toBeLessThan(full.length); + expect(caret()).toBe('" ▋"'); + + advance(300); + expect(textOf(container)).toBe(full); + expect(caret()).toBe(""); + }); + + it("fires onAnimationEnd once the held-back text is shown", () => { + const onAnimationEnd = vi.fn(); + const { container, rerender } = render( + + {first} + + ); + advance(500); + rerender( + + {full} + + ); + expect(textOf(container).length).toBeLessThan(full.length); + expect(onAnimationEnd).not.toHaveBeenCalled(); + + advance(300); + expect(textOf(container)).toBe(full); + expect(onAnimationEnd).toHaveBeenCalledTimes(1); + }); + + it("reveals words without a stagger queue when animated", () => { + const maxDelay = (animated: boolean | { stagger: number }) => { + const { container, rerender, unmount } = render( + + {first} + + ); + advance(500); + rerender( + + {`${full} `} + + ); + advance(60); + const delays = Array.from( + container.querySelectorAll("[data-sd-animate]"), + (span) => + Number.parseInt(span.style.getPropertyValue("--sd-delay") || "0", 10) + ); + unmount(); + return Math.max(0, ...delays); + }; + expect(maxDelay(true)).toBe(0); + // An explicit stagger is still honored. + expect(maxDelay({ stagger: 40 })).toBeGreaterThan(0); + }); + + it("shows a change that is not an append immediately", () => { + const { container, rerender } = render( + + {full} + + ); + rerender( + + {"A different message entirely"} + + ); + expect(textOf(container)).toBe("A different message entirely"); + }); + + it("shows everything at once in a hidden tab", () => { + const visibility = vi + .spyOn(document, "visibilityState", "get") + .mockReturnValue("hidden"); + try { + const { container, rerender } = render( + + {first} + + ); + advance(500); + rerender( + + {`${full} `} + + ); + expect(textOf(container)).toBe(full); + } finally { + visibility.mockRestore(); + } + }); + + it("does not rewind when turned on mid-stream", () => { + const rendered: string[] = []; + const RecordingBlock = ({ content }: BlockProps) => { + rendered.push(content); + return

{content}

; + }; + const { rerender } = render( + + {first} + + ); + rerender( + + {full} + + ); + rendered.length = 0; + rerender( + + {full} + + ); + expect(rendered).not.toContain(first); + expect(rendered.at(-1)).toBe(full); + }); +}); diff --git a/packages/streamdown/__tests__/text-pacer.test.ts b/packages/streamdown/__tests__/text-pacer.test.ts new file mode 100644 index 00000000..ed0da9ac --- /dev/null +++ b/packages/streamdown/__tests__/text-pacer.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { createTextPacer, snapToWord } from "../lib/use-smooth-stream"; + +const WHITESPACE_RE = /\s/; + +describe("snapToWord", () => { + it("extends to the end of the word the head is in", () => { + expect(snapToWord("hello world", 2, true)).toBe(5); + }); + + it("holds back a trailing partial word while streaming", () => { + expect(snapToWord("hello wor", 9, false)).toBe(6); + }); + + it("shows the trailing word once the stream is final", () => { + expect(snapToWord("hello wor", 9, true)).toBe(9); + }); + + it("treats each CJK character as its own unit", () => { + expect(snapToWord("你好世界", 2, false)).toBe(2); + expect(snapToWord("你好世界", 4, false)).toBe(4); + }); + + it("caps runs with no boundary", () => { + expect(snapToWord("a".repeat(100), 0, true)).toBe(32); + expect(snapToWord("a".repeat(100), 100, false)).toBe(68); + }); + + it("does not split a surrogate pair at a cap", () => { + const text = `${"a".repeat(31)}😀${"a".repeat(20)}`; + expect(snapToWord(text, 0, true)).toBe(31); + }); +}); + +describe("createTextPacer", () => { + it("treats the initial text as already shown", () => { + const pacer = createTextPacer("already here"); + expect(pacer.visible).toBe("already here"); + expect(pacer.isRevealing).toBe(false); + }); + + it("reveals an append gradually over the measured gap", () => { + const pacer = createTextPacer(""); + const first = "one two "; + const second = `${first}three four five six seven eight nine ten `; + + pacer.update(first, true, 0); + pacer.advance(200); + expect(pacer.visible).toBe(first); + + pacer.update(second, true, 500); + expect(pacer.visible).toBe(first); + expect(pacer.isRevealing).toBe(true); + + pacer.advance(750); + expect(pacer.visible.length).toBeGreaterThan(first.length); + expect(pacer.visible.length).toBeLessThan(second.length); + expect(second.startsWith(pacer.visible)).toBe(true); + expect(second.charAt(pacer.visible.length)).toMatch(WHITESPACE_RE); + + pacer.advance(1000); + expect(pacer.visible).toBe(second); + expect(pacer.isRevealing).toBe(false); + }); + + it("never shows a partial trailing word while streaming", () => { + const pacer = createTextPacer(""); + pacer.update("playing gu", true, 0); + pacer.advance(10_000); + expect(pacer.visible).toBe("playing "); + expect(pacer.isRevealing).toBe(false); + + pacer.update("playing guitar ", true, 10_500); + pacer.advance(20_000); + expect(pacer.visible).toBe("playing guitar "); + }); + + it("shows everything shortly after the stream ends", () => { + const pacer = createTextPacer(""); + pacer.update("a b ", true, 0); + const text = `a b ${"word ".repeat(60)}tail`; + pacer.update(text, true, 500); + pacer.advance(520); + expect(pacer.visible.length).toBeLessThan(text.length); + + pacer.update(text, false, 520); + pacer.advance(520 + 250); + expect(pacer.visible).toBe(text); + expect(pacer.isRevealing).toBe(false); + }); + + it("shows a change that is not an append immediately", () => { + const pacer = createTextPacer(""); + pacer.update("first draft of the text ", true, 0); + pacer.update("a replacement frame", true, 100); + expect(pacer.visible).toBe("a replacement frame"); + expect(pacer.isRevealing).toBe(false); + }); + + it("shows a same-length replacement immediately", () => { + const pacer = createTextPacer("abc"); + pacer.update("xyz", true, 0); + expect(pacer.visible).toBe("xyz"); + }); + + it("flush shows everything", () => { + const pacer = createTextPacer(""); + pacer.update("x ", true, 0); + pacer.update(`x ${"long chunk ".repeat(20)}`, true, 500); + pacer.flush(); + expect(pacer.visible).toBe(`x ${"long chunk ".repeat(20)}`); + }); + + it("keeps up with a steady cadence instead of building a backlog", () => { + const pacer = createTextPacer(""); + const chunk = "alpha beta gamma delta epsilon zeta eta theta iota kappa "; + let text = ""; + for (let i = 0; i < 20; i += 1) { + const at = i * 500; + text += chunk; + pacer.update(text, true, at); + // Just before the next chunk, at most the tail of this one is left. + pacer.advance(at + 499); + if (i >= 3) { + expect(text.length - pacer.visible.length).toBeLessThan( + chunk.length / 2 + ); + } + // Whole words only: fully shown, or cut right before whitespace. + const next = text.charAt(pacer.visible.length); + expect(next === "" || WHITESPACE_RE.test(next)).toBe(true); + } + }); +}); diff --git a/packages/streamdown/__tests__/void-element-animation.test.tsx b/packages/streamdown/__tests__/void-element-animation.test.tsx index f7220010..5dea68a1 100644 --- a/packages/streamdown/__tests__/void-element-animation.test.tsx +++ b/packages/streamdown/__tests__/void-element-animation.test.tsx @@ -65,3 +65,25 @@ describe("void element animation (React render)", () => { expect(hr?.getAttribute("style") ?? "").toContain("--sd-animation"); }); }); + +it("keeps the animated code wrapper when a streamed fence grows", async () => { + const prefix = "### Code example\n\n```js\nconst x = 1"; + const { container, rerender } = await renderAnimated(prefix); + const code = container.querySelector('[data-streamdown="code-block"]'); + const wrapper = code?.closest("[data-sd-animate]"); + expect(wrapper).not.toBeNull(); + const style = wrapper?.getAttribute("style"); + expect(style).toContain("--sd-duration: 500ms"); + expect(code?.querySelector("[data-sd-animate]")).toBeNull(); + + rerender( + + {`${prefix}\nconst y = 2\n\`\`\``} + + ); + await act(() => Promise.resolve()); + expect(container.querySelector('[data-streamdown="code-block"]')).toBe(code); + expect(code?.closest("[data-sd-animate]")).toBe(wrapper); + expect(wrapper?.getAttribute("style")).toBe(style); + expect(code?.textContent).toContain("const y = 2"); +}); diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index dce573c4..a824d769 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -8,6 +8,7 @@ import { createElement, type JSX, memo, + useCallback, useEffect, useId, useLayoutEffect, @@ -58,10 +59,12 @@ import { type StreamdownTranslations, TranslationsContext, } from "./lib/translations-context"; +import { useAnimationDrain } from "./lib/use-animation-drain"; import { useCaretHost } from "./lib/use-caret-host"; +import { useSmoothStream } from "./lib/use-smooth-stream"; import { createCn } from "./lib/utils"; -export type { AnimateOptions } from "./lib/animate"; +export type { AnimateOptions, AnimationEffect } from "./lib/animate"; // biome-ignore lint/performance/noBarrelFile: "required" export { createAnimatePlugin } from "./lib/animate"; export { useIsCodeFenceIncomplete } from "./lib/block-incomplete-context"; @@ -292,6 +295,16 @@ export type StreamdownProps = Options & { */ tableMaxHeight?: number | string; animated?: boolean | AnimateOptions; + /** + * Pace bursty streams. Text that arrives in large chunks is revealed a word + * at a time at the rate it has been arriving, instead of all at once. + * Adds roughly one chunk interval of display latency. Uses `isAnimating` + * to know when the stream ends. Until the held-back text is shown, + * Streamdown stays in streaming mode (caret, animation, incomplete-Markdown + * handling) even if `mode` is `"static"`, and `onAnimationEnd` waits for + * it. @default false + */ + smooth?: boolean; caret?: keyof typeof carets; plugins?: PluginConfig; remend?: RemendOptions; @@ -467,6 +480,30 @@ export const StreamdownContext = createContext( defaultStreamdownContext ); +const getAnimatedKey = ( + animated: StreamdownProps["animated"], + smooth: boolean +): string => { + if (!animated) { + return ""; + } + const options = animated === true ? "true" : JSON.stringify(animated); + // Block plugins bake in a stagger default that depends on smooth. + return smooth ? `${options}:smooth` : options; +}; + +/** Options for one block's animate plugin; maxBacklogMs goes to the timeline. */ +const getBlockAnimateOptions = ( + animated: StreamdownProps["animated"], + smooth: boolean +): AnimateOptions => { + const { maxBacklogMs: _, ...options }: AnimateOptions = + typeof animated === "object" ? animated : {}; + // Smooth already spaces words out; a stagger would queue them a second + // time behind the reveal. + return { ...options, stagger: options.stagger ?? (smooth ? 0 : undefined) }; +}; + export type BlockProps = Options & { content: string; shouldParseIncompleteMarkdown: boolean; @@ -492,8 +529,8 @@ export const Block = memo( animatePlugin: animatePluginProp, ...props }: BlockProps) => { - // After rehype paints, commit the new char count so the *next* render - // treats already-visible text as settled. Commit lives outside the render + // After rehype paints, commit the char count and pending animation timings + // so the next render preserves their schedule. Commit lives outside the render // body so StrictMode double-invoke cannot wipe and re-seed prevContentLength // (#570 secondary). The plugin seeds prevContentLength from its own // committedCharCount at the start of every rehype run. @@ -592,7 +629,7 @@ Block.displayName = "Block"; export const Streamdown = memo( ({ children, - mode = "streaming", + mode: modeProp = "streaming", dir, parseIncompleteMarkdown: shouldParseIncompleteMarkdown = true, normalizeHtmlIndentation: shouldNormalizeHtmlIndentation = false, @@ -604,9 +641,10 @@ export const Streamdown = memo( mermaid, codeBlockMaxHeight = 400, controls = true, - isAnimating = false, + isAnimating: isAnimatingProp = false, tableMaxHeight = 300, animated, + smooth = false, BlockComponent = Block, parseMarkdownIntoBlocksFn = parseMarkdownIntoBlocks, caret, @@ -629,6 +667,17 @@ export const Streamdown = memo( // All hooks must be called before any conditional returns const generatedId = useId(); + const { + text: smoothed, + isAnimating, + mode, + } = useSmoothStream({ + children, + isAnimating: isAnimatingProp, + mode: modeProp, + smooth, + }); + const prefixedCn = useMemo(() => createCn(prefix), [prefix]); // null means "first render" — distinguishes from false so we can fire @@ -678,8 +727,8 @@ export const Streamdown = memo( } let result = mode === "streaming" && shouldParseIncompleteMarkdown - ? remend(children, remendOptions) - : children; + ? remend(smoothed, remendOptions) + : smoothed; // Escape markdown metacharacters inside literal-tag-content tags so that // children are rendered as plain text rather than parsed as markdown. @@ -699,6 +748,7 @@ export const Streamdown = memo( return result; }, [ children, + smoothed, mode, shouldParseIncompleteMarkdown, remendOptions, @@ -737,15 +787,13 @@ export const Streamdown = memo( // plugin from being recreated when the user passes an inline object // literal (e.g. animated={{ animation: 'fadeIn' }}) whose reference // changes on every parent render. - const animatedKey = useMemo(() => { - if (animated === true) { - return "true"; - } - if (animated) { - return JSON.stringify(animated); - } - return ""; - }, [animated]); + const animatedKey = useMemo( + () => getAnimatedKey(animated, smooth), + [animated, smooth] + ); + + const { containerRef: animationContainerRef, animateText } = + useAnimationDrain(isAnimating, animatedKey, mode, children); // Shared wall-clock timeline: serializes stagger delays across sibling // blocks AND across streaming ticks (memoized earlier blocks don't @@ -763,9 +811,7 @@ export const Streamdown = memo( if (prevAnimatedKeyRef.current !== animatedKey) { prevAnimatedKeyRef.current = animatedKey; const backlog = - animatedKey !== "true" - ? (animated as AnimateOptions).maxBacklogMs - : undefined; + typeof animated === "object" ? animated.maxBacklogMs : undefined; animateTimelineRef.current = createAnimateTimeline({ maxBacklogMs: backlog, }); @@ -777,7 +823,7 @@ export const Streamdown = memo( // Reset the per-pass cursor from the last *committed* horizon so a // StrictMode double-render recomputes the same delays instead of // stacking (#482 + StrictMode). - if (isAnimating && animateTimelineRef.current) { + if (animateText && animateTimelineRef.current) { animateTimelineRef.current.beginPass(animateTimelineRef.current.now()); } } else { @@ -791,7 +837,22 @@ export const Streamdown = memo( // renders that called beginPass never reach this effect, so they can't // poison nextStartAt. useLayoutEffect(() => { - if (isAnimating) { + // Removed blocks have no DOM animations to preserve. Keeping their + // history makes a replay's first words look already settled. + blockAnimatePluginsRef.current.length = Math.min( + blockAnimatePluginsRef.current.length, + blocksToRender.length + ); + blockRehypePluginsRef.current.length = Math.min( + blockRehypePluginsRef.current.length, + blocksToRender.length + ); + if (blocksToRender.length === 0) { + animateTimelineRef.current = null; + prevAnimatedKeyRef.current = ""; + return; + } + if (animateText) { animateTimelineRef.current?.commitPass(); } }); @@ -1015,6 +1076,14 @@ export const Streamdown = memo( }, [isAnimating, blocksToRender]); const { containerRef, showCaret } = useCaretHost(shouldHideCaret); + // The animation drain and the caret host both watch the container. + const setContainer = useCallback( + (element: HTMLDivElement | null) => { + animationContainerRef.current = element; + containerRef.current = element; + }, + [animationContainerRef, containerRef] + ); const getBlockPlugins = ( index: number @@ -1023,16 +1092,11 @@ export const Streamdown = memo( blockRehypePlugins: Pluggable[]; } => { let blockAnimatePlugin: AnimatePlugin | null = null; - if (animateTimelineRef.current && isAnimating) { + if (animateTimelineRef.current && animateText) { if (!blockAnimatePluginsRef.current[index]) { // maxBacklogMs is consumed by the timeline factory, not the plugin. - const rawOpts = - animatedKey && animatedKey !== "true" - ? (animated as AnimateOptions) - : ({} as AnimateOptions); - const { maxBacklogMs: _, ...pluginOpts } = rawOpts; blockAnimatePluginsRef.current[index] = createRenderAnimatePlugin({ - ...pluginOpts, + ...getBlockAnimateOptions(animated, smooth), timeline: animateTimelineRef.current, }); } @@ -1108,7 +1172,7 @@ export const Streamdown = memo( : null, className )} - ref={containerRef} + ref={setContainer} style={style} > {blocksToRender.length === 0 && caret && isAnimating && ( @@ -1159,6 +1223,7 @@ export const Streamdown = memo( prevProps.shikiTheme === nextProps.shikiTheme && prevProps.isAnimating === nextProps.isAnimating && prevProps.animated === nextProps.animated && + prevProps.smooth === nextProps.smooth && prevProps.mode === nextProps.mode && prevProps.plugins === nextProps.plugins && prevProps.className === nextProps.className && diff --git a/packages/streamdown/lib/animate.ts b/packages/streamdown/lib/animate.ts index a0212608..8053da7e 100644 --- a/packages/streamdown/lib/animate.ts +++ b/packages/streamdown/lib/animate.ts @@ -46,6 +46,7 @@ export interface AnimateTimeline { /** Snapshot of the working cursor (passNextStartAt). */ mark: () => number; now: () => number; + passNow: () => number; /** Restore the working cursor to a prior mark. */ rewind: (mark: number) => void; take: (wordCount: number, stagger: number, now: number) => ScheduleSlot; @@ -69,10 +70,13 @@ export function createAnimateTimeline( let committedNextStartAt = 0; /** Working absolute time for the in-flight pass. */ let passNextStartAt = 0; + let passStartedAt = nowFn(); return { now: nowFn, + passNow: () => passStartedAt, beginPass(now: number) { + passStartedAt = now; // Resume from the last commit, but never more than maxBacklog ahead of // wall-clock — drops debt from a previous pass that overshot via the // min-step floor so a fast stream stays caught up on the next tick. @@ -129,7 +133,7 @@ export function createAnimateTimeline( export interface AnimatePlugin { /** * Commit the last rehype char count so the *next* rehype run treats that - * many characters as already-visible. Also clears the StrictMode rewind + * many characters as already-scheduled. Also clears the StrictMode rewind * mark so the next commit starts clean. Called from Block's useLayoutEffect. */ commit: () => void; @@ -145,8 +149,30 @@ export interface AnimatePlugin { type: "animate"; } +/** + * An animation packaged with the per-word behavior its keyframes need, such + * as the ones in `@streamdown/effects`. Pass it as `animated.animation`. + */ +export interface AnimationEffect { + /** + * Extra attributes for each word while it animates, such as text for + * styles to draw. Must return the same attributes for the same arguments: + * words are re-rendered while they animate. + */ + decorate?: (text: string, seed: number) => Record<`data-${string}`, string>; + /** Default duration in ms. `animated.duration` overrides it. */ + duration?: number; + /** `@keyframes` name without the `sd-` prefix. */ + name: string; + /** + * Maximum extra delay in ms per word, hashed from its position, so words + * resolve in scattered order instead of left to right. + */ + scatter?: number; +} + export interface AnimateOptions { - animation?: "fadeIn" | "blurIn" | "slideUp" | (string & {}); + animation?: "fadeIn" | "blurIn" | "slideUp" | (string & {}) | AnimationEffect; duration?: number; easing?: string; /** @@ -165,10 +191,9 @@ const WHITESPACE_ONLY_RE = /^\s+$/; // `pre` (CommonMark always emits `pre > code`); raw inline `code` is safe to // animate — word spans inherit styles the same way surrounding prose does (#594). const SKIP_TAGS = new Set(["pre", "svg", "math", "annotation"]); -// Elements with no text node of their own that should still animate in. They -// honor opacity/filter/transform, so they reuse the standard [data-sd-animate] -// rule and work with every animation type. -const VOID_ANIMATE_TAGS = new Set(["img", "hr"]); +// Code fences reserve one slot without touching syntax-highlighting spans. +// Like images and rules, the whole element follows the shared timeline. +const ATOMIC_ANIMATE_TAGS = new Set(["img", "hr", "pre"]); const isElement = (node: unknown): node is Element => typeof node === "object" && @@ -267,12 +292,9 @@ const stampCheckbox = ( } }; -// Images and rules have no text node, so they're tagged directly. Their -// "already shown" state is judged by document position (charCounter.count) -// rather than character length, since they contribute no characters. -// Advance count by 1 so a trailing void at the prevLen boundary is treated as -// already-shown on the next tick (avoids one-frame re-animate). -const processVoidElement = ( +// Atomic elements occupy one position regardless of their contents, so growing +// a code fence cannot restart its fade or move the following text's offset. +const processAtomicElement = ( element: Element, ancestors: Node[], config: AnimateConfig, @@ -283,14 +305,16 @@ const processVoidElement = ( if (hasSkipAncestor(ancestors)) { return; } - const prevLen = renderState.prevContentLength; const partStart = charCounter.count; charCounter.count += 1; - const skipAnimation = prevLen > 0 && partStart < prevLen; - const delay = skipAnimation - ? 0 - : schedule.baseDelay + charCounter.newIndex++ * schedule.step; - stampAnimation(element, config, skipAnimation ? 0 : config.duration, delay); + const timing = animationTiming( + partStart, + config, + renderState, + charCounter, + schedule + ); + stampAnimation(element, config, timing.duration, timing.delay); }; /** @@ -389,44 +413,55 @@ const plainStyleValue = /^[^\s;:'"\\/](?:[^;:'"\\/\r\n]*[^\s;:'"\\/])?$/; const makeSpan = ( word: string, config: AnimateConfig, - skipAnimation?: boolean, - delay?: number + duration: number, + delay?: number, + attributes?: Record ): Element => { const animation = `sd-${config.animation}`; - const duration = `${skipAnimation ? 0 : config.duration}ms`; let style: string | Record; if (config.styleObjects) { style = { "--sd-animation": animation, - "--sd-duration": duration, + "--sd-duration": `${duration}ms`, "--sd-easing": config.easing, }; if (delay) { style["--sd-delay"] = `${Math.round(delay)}ms`; } } else { - style = `--sd-animation:${animation};--sd-duration:${duration};--sd-easing:${config.easing}`; + style = `--sd-animation:${animation};--sd-duration:${duration}ms;--sd-easing:${config.easing}`; if (delay) { style += `;--sd-delay:${Math.round(delay)}ms`; } } + const properties: Element["properties"] = { + "data-sd-animate": true, + // hast types properties as primitives, but hast-util-to-jsx-runtime + // hands an object style to React as is instead of parsing a string. + style: style as string, + }; + for (const [key, value] of Object.entries(attributes ?? {})) { + // Effects run after sanitization, so only data attributes pass through. + if (key.startsWith("data-")) { + properties[key] = value; + } + } return { type: "element", tagName: "span", - properties: { - "data-sd-animate": true, - // hast types properties as primitives, but hast-util-to-jsx-runtime - // hands an object style to React as is instead of parsing a string. - style: style as string, - }, + properties, children: [{ type: "text", value: word }], }; }; interface AnimateConfig { animation: string; + decorate?: AnimationEffect["decorate"]; duration: number; easing: string; + scatter: number; + /** Per-block salt for position hashes. */ + seed: number; sep: "word" | "char"; stagger: number; /** Give word spans a React style object instead of a CSS string */ @@ -434,9 +469,17 @@ interface AnimateConfig { timeline?: AnimateTimeline; } +interface AnimationTiming { + delay: number; + endsAt: number; +} + interface AnimateRenderState { + committedAnimations: Map; committedCharCount: number; lastRenderCharCount: number; + now: number; + pendingAnimations: Map; /** * Timeline cursor snapshot from the first rehype run of the current commit. * null → not yet run this commit; number → rewind here on re-entry @@ -451,14 +494,52 @@ interface Schedule { step: number; } +/** Deterministic [0, 1) hash, so StrictMode re-runs and re-renders agree. */ +const hash01 = (a: number, b: number): number => { + const x = Math.sin(a * 12.9898 + b * 78.233) * 43_758.5453; + return x - Math.floor(x); +}; + const isNewAnimateUnit = (prevLen: number, partStart: number): boolean => !(prevLen > 0 && partStart < prevLen); -const isVoidAnimateElement = (node: Node): node is Element => - isElement(node) && VOID_ANIMATE_TAGS.has(node.tagName); +const animationTiming = ( + partStart: number, + config: AnimateConfig, + state: AnimateRenderState, + counter: { newIndex: number }, + schedule: Schedule +): { duration: number; delay: number } => { + let timing = state.committedAnimations.get(partStart); + if (isNewAnimateUnit(state.prevContentLength, partStart)) { + const scatter = + config.scatter > 0 ? config.scatter * hash01(config.seed, partStart) : 0; + const delay = Math.round( + schedule.baseDelay + counter.newIndex++ * schedule.step + scatter + ); + timing = { delay, endsAt: state.now + delay + config.duration }; + } + if (!timing || timing.endsAt <= state.now) { + return { duration: 0, delay: 0 }; + } + // Keep the original CSS timing on retained DOM nodes. Replacing it with + // duration:0 before the fade ends jumps to its final keyframe; subtracting + // elapsed time from the delay would advance an already-running animation. + state.pendingAnimations.set(partStart, timing); + return { duration: config.duration, delay: timing.delay }; +}; + +// remark-rehype pads block elements with positionless newlines. They are not +// streamed content: trailing padding must not make the next word look old, and +// adding

wrappers to a loose list must not shift existing animation offsets. +const isLayoutWhitespace = (node: Text): boolean => + !node.position && WHITESPACE_ONLY_RE.test(node.value); + +const isAtomicAnimateElement = (node: Node): node is Element => + isElement(node) && ATOMIC_ANIMATE_TAGS.has(node.tagName); /** - * Count newly-animated units (mirrors processTextNode / processVoidElement + * Count newly-animated units (mirrors processTextNode / processAtomicElement * skip logic) so timeline.take reserves the right number of slots. */ const countNewWords = ( @@ -470,18 +551,21 @@ const countNewWords = ( let charPos = 0; visitParents( tree, - (node: Node) => node.type === "text" || isVoidAnimateElement(node), + (node: Node) => node.type === "text" || isAtomicAnimateElement(node), (node: Node, ancestors) => { if (hasSkipAncestor(ancestors)) { return SKIP; } - if (isVoidAnimateElement(node)) { + if (isAtomicAnimateElement(node)) { if (isNewAnimateUnit(prevLen, charPos)) { newWords += 1; } charPos += 1; return; } + if (isLayoutWhitespace(node as Text)) { + return; + } const text = (node as Text).value; if (!text.trim()) { charPos += text.length; @@ -529,6 +613,9 @@ const processTextNode = ( return; } + if (isLayoutWhitespace(node)) { + return; + } const text = node.value; if (!text.trim()) { charCounter.count += text.length; @@ -536,7 +623,6 @@ const processTextNode = ( } const parts = config.sep === "char" ? splitByChar(text) : splitByWord(text); - const prevLen = renderState.prevContentLength; let didAnimate = false; // Fade the list marker in with this item's first animated word. Only the @@ -553,18 +639,28 @@ const processTextNode = ( if (WHITESPACE_ONLY_RE.test(part)) { return { type: "text", value: part } as Text; } - const skipAnimation = prevLen > 0 && partStart < prevLen; - const delay = skipAnimation - ? 0 - : schedule.baseDelay + charCounter.newIndex++ * schedule.step; + const timing = animationTiming( + partStart, + config, + renderState, + charCounter, + schedule + ); didAnimate = true; if (liAncestor && needsMarker && !markerStamped) { - const itemDuration = skipAnimation ? 0 : config.duration; - stampMarker(liAncestor, itemDuration, delay, config.easing); - stampCheckbox(liAncestor, config, itemDuration, delay); + stampMarker(liAncestor, timing.duration, timing.delay, config.easing); + stampCheckbox(liAncestor, config, timing.duration, timing.delay); markerStamped = true; } - return makeSpan(part, config, skipAnimation, delay); + return makeSpan( + part, + config, + timing.duration, + timing.delay, + config.decorate && timing.duration > 0 + ? config.decorate(part.trimEnd(), config.seed * 100_003 + partStart) + : undefined + ); }); if (didAnimate) { @@ -599,12 +695,18 @@ function buildAnimatePlugin( options: (AnimateOptions & { timeline?: AnimateTimeline }) | undefined, styleObjects: boolean ): AnimatePlugin { - const animation = options?.animation ?? "fadeIn"; + const id = instanceId++; + const option = options?.animation ?? "fadeIn"; + const effect = typeof option === "string" ? undefined : option; + const animation = typeof option === "string" ? option : option.name; const easing = options?.easing ?? "ease"; const config: AnimateConfig = { animation, - duration: options?.duration ?? 150, + decorate: effect?.decorate, + duration: options?.duration ?? effect?.duration ?? 150, easing, + scatter: effect?.scatter ?? 0, + seed: id, sep: options?.sep ?? "word", stagger: options?.stagger ?? 40, styleObjects: @@ -615,21 +717,26 @@ function buildAnimatePlugin( }; const renderState: AnimateRenderState = { + committedAnimations: new Map(), committedCharCount: 0, prevContentLength: 0, lastRenderCharCount: 0, pendingMark: null, + now: 0, + pendingAnimations: new Map(), }; - const id = instanceId++; const rehypeAnimate = () => (tree: Root) => { const charCounter = { count: 0, newIndex: 0 }; - // Seed skip-window from the last committed paint (#570 secondary). + // Seed the scheduled prefix from the last committed paint (#570 secondary). renderState.prevContentLength = renderState.committedCharCount; + renderState.pendingAnimations = new Map(); const timeline = config.timeline; - const now = timeline?.now() ?? defaultNow(); + // All blocks paint in the same commit, so their CSS delays share an origin. + const now = timeline?.passNow() ?? defaultNow(); + renderState.now = now; // StrictMode / discarded render: first run marks the cursor; a re-run // rewinds so take() doesn't stack on itself. commit() clears the mark. @@ -651,7 +758,7 @@ function buildAnimatePlugin( visitParents( tree, - (node: Node) => node.type === "text" || isVoidAnimateElement(node), + (node: Node) => node.type === "text" || isAtomicAnimateElement(node), (node: Node, ancestors) => { if (node.type === "text") { return processTextNode( @@ -663,7 +770,7 @@ function buildAnimatePlugin( schedule ); } - processVoidElement( + processAtomicElement( node as Element, ancestors, config, @@ -688,12 +795,14 @@ function buildAnimatePlugin( setPrevContentLength(length: number) { renderState.committedCharCount = length; renderState.prevContentLength = length; + renderState.committedAnimations.clear(); }, getLastRenderCharCount() { return renderState.lastRenderCharCount; }, commit() { renderState.committedCharCount = renderState.lastRenderCharCount; + renderState.committedAnimations = renderState.pendingAnimations; renderState.pendingMark = null; }, }; diff --git a/packages/streamdown/lib/components.tsx b/packages/streamdown/lib/components.tsx index fb0470a2..1ddc3c23 100644 --- a/packages/streamdown/lib/components.tsx +++ b/packages/streamdown/lib/components.tsx @@ -1127,12 +1127,13 @@ export const components: Options["components"] = { blockquote: MemoBlockquote, code: MemoCode, img: MemoImg, - pre: ({ children }) => { - if (isValidElement(children)) { - return cloneElement(children, { "data-block": "true" }); - } - return children; - }, + pre: ({ children, node: _node, ...props }) => ( +

+ {isValidElement(children) + ? cloneElement(children, { "data-block": "true" }) + : children} +
+ ), sup: MemoSup, sub: MemoSub, p: MemoParagraph, diff --git a/packages/streamdown/lib/use-animation-drain.ts b/packages/streamdown/lib/use-animation-drain.ts new file mode 100644 index 00000000..6d281d22 --- /dev/null +++ b/packages/streamdown/lib/use-animation-drain.ts @@ -0,0 +1,51 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +export function useAnimationDrain( + isAnimating: boolean, + animatedKey: string, + mode: "streaming" | "static", + content: unknown +) { + const containerRef = useRef(null); + const [retained, setRetained] = useState(false); + const enabled = Boolean(animatedKey) && mode !== "static"; + + // biome-ignore lint/correctness/useExhaustiveDependencies: New content or animation settings can replace the DOM animations being drained. + useLayoutEffect(() => { + if (!enabled || isAnimating) { + setRetained(enabled && isAnimating); + return; + } + + // Read the browser's actual animations after the final chunk commits: + // a fixed grace period can truncate staggered words or delay reduced motion. + const pending = ( + containerRef.current?.getAnimations?.({ subtree: true }) ?? [] + ).filter( + (animation) => + "animationName" in animation && + String(animation.animationName).startsWith("sd-") && + animation.playState !== "finished" && + animation.playState !== "idle" && + Number.isFinite(animation.effect?.getComputedTiming().endTime) + ); + if (pending.length === 0) { + setRetained(false); + return; + } + + let cancelled = false; + Promise.allSettled(pending.map((animation) => animation.finished)).then( + () => { + if (!cancelled) { + setRetained(false); + } + } + ); + return () => { + cancelled = true; + }; + }, [isAnimating, enabled, animatedKey, content]); + + return { containerRef, animateText: enabled && (isAnimating || retained) }; +} diff --git a/packages/streamdown/lib/use-smooth-stream.ts b/packages/streamdown/lib/use-smooth-stream.ts new file mode 100644 index 00000000..dde1d26c --- /dev/null +++ b/packages/streamdown/lib/use-smooth-stream.ts @@ -0,0 +1,249 @@ +import { useEffect, useRef, useState } from "react"; + +/** Reveal horizon before a second chunk gives us a real gap to measure. */ +const FIRST_GAP_MS = 120; +/** Clamp for the gap estimate, so a long stall doesn't make the next reveal crawl. */ +const MIN_GAP_MS = 16; +const MAX_GAP_MS = 1000; +/** + * Gap estimate smoothing. It rises fast when chunks slow down and decays + * slowly when they speed up, so one quick chunk can't drain the buffer early + * before the next slow gap. + */ +const GAP_RISE = 0.5; +const GAP_DECAY = 0.15; +/** Once the stream ends, show whatever is still held back within this time. */ +const DRAIN_MS = 250; +/** Longest run revealed or held back as one "word" when no boundary is found. */ +const MAX_WORD_CHARS = 32; + +// Whitespace ends a word. Han, kana and Hangul characters are each their own +// unit, so CJK text still reveals smoothly without spaces. +const BOUNDARY_RE = + /[\s\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; + +const isBoundary = (char: string): boolean => BOUNDARY_RE.test(char); + +const isHighSurrogate = (code: number): boolean => + code >= 0xd8_00 && code <= 0xdb_ff; + +/** + * Visible length for a reveal head at `head`. Extends forward to the end of + * the word the head is in, so words appear whole. While streaming, holds + * back a trailing word the producer may still be extending. + */ +export const snapToWord = ( + text: string, + head: number, + final: boolean +): number => { + let end = Math.min(Math.floor(head), text.length); + const limit = Math.min(text.length, end + MAX_WORD_CHARS); + while (end < limit && !isBoundary(text.charAt(end))) { + end += 1; + } + if (!final && end === text.length) { + const floor = Math.max(0, end - MAX_WORD_CHARS); + while (end > floor && !isBoundary(text.charAt(end - 1))) { + end -= 1; + } + } + // Never split a surrogate pair at a MAX_WORD_CHARS cut. + if ( + end > 0 && + end < text.length && + isHighSurrogate(text.charCodeAt(end - 1)) + ) { + end -= 1; + } + return end; +}; + +export interface TextPacer { + /** Move the reveal head forward to `now`. */ + advance: (now: number) => void; + /** Show everything immediately. */ + flush: () => void; + /** True while there is text the pacer can still reveal. */ + readonly isRevealing: boolean; + /** + * Feed the latest full text. Appends are paced; any other change (reset, + * edit, a diffusion model's full-text frame) is shown immediately. + */ + update: (text: string, streaming: boolean, now: number) => void; + readonly visible: string; +} + +/** + * Jitter buffer for bursty streams. Each arrival re-targets the reveal rate + * so the current backlog finishes around when the next chunk is expected. + * `initial` is treated as already shown. + */ +export const createTextPacer = (initial: string): TextPacer => { + let target = initial; + /** Fractional reveal position. */ + let head = initial.length; + /** Visible length, snapped to words. Never decreases for a given target. */ + let end = initial.length; + /** Characters per millisecond. */ + let rate = 0; + /** Estimated milliseconds between arrivals; 0 until measured. */ + let gap = 0; + let lastArrival: number | null = null; + let lastAdvance: number | null = null; + let streaming = false; + /** `target.slice(0, end)`, cached so frames that reveal nothing don't copy. */ + let visible = initial; + + const growTo = (next: number) => { + if (next > end) { + end = next; + visible = target.slice(0, end); + } + }; + + const advance = (now: number) => { + const elapsed = lastAdvance === null ? 0 : Math.max(0, now - lastAdvance); + lastAdvance = now; + head = Math.min(target.length, head + rate * elapsed); + growTo(snapToWord(target, head, !streaming)); + }; + + const recordArrival = (now: number) => { + if (lastArrival !== null) { + const observed = Math.min( + MAX_GAP_MS, + Math.max(MIN_GAP_MS, now - lastArrival) + ); + const weight = observed > gap ? GAP_RISE : GAP_DECAY; + gap = gap === 0 ? observed : gap + weight * (observed - gap); + } + lastArrival = now; + }; + + return { + advance, + flush() { + head = target.length; + rate = 0; + growTo(target.length); + }, + get isRevealing() { + return end < snapToWord(target, target.length, !streaming); + }, + update(text, isStreaming, now) { + // Settle progress made under the previous rate first. + advance(now); + streaming = isStreaming; + + if (text !== target) { + if (text.startsWith(target)) { + recordArrival(now); + target = text; + } else { + target = text; + head = text.length; + end = text.length; + visible = text; + gap = 0; + lastArrival = null; + } + } + + const backlog = target.length - head; + rate = backlog / (gap || FIRST_GAP_MS); + if (!streaming) { + rate = Math.max(rate, backlog / DRAIN_MS); + } + }, + get visible() { + return visible; + }, + }; +}; + +const now = (): number => + typeof performance === "undefined" ? Date.now() : performance.now(); + +const isDocumentHidden = (): boolean => + typeof document !== "undefined" && document.visibilityState === "hidden"; + +export interface SmoothStreamOptions { + children: unknown; + /** Whether more text may still arrive. */ + isAnimating: boolean; + mode: "static" | "streaming"; + smooth: boolean; +} + +export interface SmoothStream { + isAnimating: boolean; + mode: "static" | "streaming"; + text: string; +} + +/** + * Paces string `children` for display when `smooth` is on. Appends are + * revealed over time; any other change renders as-is. While held-back text + * is still being shown, reports streaming state even if the stream has + * ended, so caret, animation and `onAnimationEnd` wait for it. + */ +export const useSmoothStream = ({ + children, + isAnimating, + mode, + smooth, +}: SmoothStreamOptions): SmoothStream => { + const text = typeof children === "string" ? children : ""; + const pacerRef = useRef(null); + const [visible, setVisible] = useState(text); + const [wasSmooth, setWasSmooth] = useState(smooth); + + // Turning pacing on mid-stream starts from the full current text, not from + // whatever was last paced. + if (smooth !== wasSmooth) { + setWasSmooth(smooth); + setVisible(text); + } + + // Constant while disabled, so the effect doesn't re-run on every token. + const source = smooth ? text : ""; + const streaming = smooth && isAnimating; + + useEffect(() => { + if (!smooth) { + pacerRef.current = null; + return; + } + pacerRef.current ??= createTextPacer(source); + const pacer = pacerRef.current; + pacer.update(source, streaming, now()); + // Animation frames don't run in hidden tabs. Arrivals and the end of the + // stream still land here, so show everything instead of stalling. If the + // tab hides between arrivals, the first frame back catches up at once. + if (isDocumentHidden()) { + pacer.flush(); + } + + let frame = 0; + const tick = () => { + pacer.advance(now()); + setVisible(pacer.visible); + if (pacer.isRevealing) { + frame = requestAnimationFrame(tick); + } + }; + tick(); + return () => cancelAnimationFrame(frame); + }, [source, streaming, smooth]); + + // Until the effect catches up, a non-append change renders as-is rather + // than flashing the previous text. + const shown = smooth && text.startsWith(visible) ? visible : text; + const isCatchingUp = shown.length < text.length; + return { + isAnimating: isAnimating || isCatchingUp, + mode: isCatchingUp ? "streaming" : mode, + text: shown, + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cf04b91..64b75889 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -470,6 +470,21 @@ importers: specifier: ^5.0.0 version: 5.0.0(@opentelemetry/api@1.9.0)(@types/node@25.0.9)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + packages/streamdown-effects: + devDependencies: + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@5.0.0) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^5.0.0 + version: 5.0.0(@opentelemetry/api@1.9.0)(@types/node@25.0.9)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) + packages/streamdown-math: dependencies: katex: