From 33d54b047ae542d0cf3bbc0616ccdf99281c9170 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:07:25 -0700 Subject: [PATCH] fix(streamdown): render code blocks incrementally during streaming When using the `animated` prop, the animate plugin skipped all code/pre elements. This meant code block content appeared instantly while surrounding prose faded in word-by-word, creating a visual "buffering" effect where code seemed to dump all at once instead of streaming incrementally. The fix adds a `setAnimateCodeBlocks` method to the animate plugin. When a block has an incomplete (unclosed) code fence, the Block component tells the animate plugin to include code/pre elements in its animation pass. Once the code fence closes, animation reverts to the default behavior of skipping code blocks (since completed code gets syntax highlighting). Fixes #473 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/streamdown/index.tsx | 4 ++++ packages/streamdown/lib/animate.ts | 25 +++++++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx index be32d9fd..c1ee158d 100644 --- a/packages/streamdown/index.tsx +++ b/packages/streamdown/index.tsx @@ -347,6 +347,10 @@ export const Block = memo( if (animatePluginProp) { const prevCount = animatePluginProp.getLastRenderCharCount(); animatePluginProp.setPrevContentLength(prevCount); + // When the block has an incomplete code fence, animate code block + // content incrementally instead of skipping it. This gives visual + // feedback that code is streaming in token-by-token. + animatePluginProp.setAnimateCodeBlocks(isIncomplete); } // Note: remend is already applied to the entire markdown before parsing into blocks diff --git a/packages/streamdown/lib/animate.ts b/packages/streamdown/lib/animate.ts index 53886b93..8d6ac38a 100644 --- a/packages/streamdown/lib/animate.ts +++ b/packages/streamdown/lib/animate.ts @@ -11,6 +11,12 @@ export interface AnimatePlugin { getLastRenderCharCount: () => number; name: "animate"; rehypePlugin: Pluggable; + /** + * Enable or disable animation inside code/pre blocks. + * When true, code block content will be animated incrementally + * during streaming (useful for incomplete/unclosed code fences). + */ + setAnimateCodeBlocks: (enabled: boolean) => void; /** * Set the number of HAST text characters from a previous render. * Characters up to this count will get duration=0ms, preventing @@ -31,6 +37,7 @@ export interface AnimateOptions { const WHITESPACE_RE = /\s/; const WHITESPACE_ONLY_RE = /^\s+$/; const SKIP_TAGS = new Set(["code", "pre", "svg", "math", "annotation"]); +const SKIP_TAGS_WITHOUT_CODE = new Set(["svg", "math", "annotation"]); const isElement = (node: unknown): node is Element => typeof node === "object" && @@ -38,10 +45,15 @@ const isElement = (node: unknown): node is Element => "type" in node && (node as Element).type === "element"; -const hasSkipAncestor = (ancestors: Node[]): boolean => - ancestors.some( - (ancestor) => isElement(ancestor) && SKIP_TAGS.has(ancestor.tagName) +const hasSkipAncestor = ( + ancestors: Node[], + animateCodeBlocks: boolean +): boolean => { + const tags = animateCodeBlocks ? SKIP_TAGS_WITHOUT_CODE : SKIP_TAGS; + return ancestors.some( + (ancestor) => isElement(ancestor) && tags.has(ancestor.tagName) ); +}; const splitByWord = (text: string): string[] => { const parts: string[] = []; @@ -126,6 +138,7 @@ interface AnimateConfig { * object that setPrevContentLength / getLastRenderCharCount mutate. */ interface AnimateRenderState { + animateCodeBlocks: boolean; lastRenderCharCount: number; prevContentLength: number; } @@ -143,7 +156,7 @@ const processTextNode = ( return; } - if (hasSkipAncestor(ancestors)) { + if (hasSkipAncestor(ancestors, renderState.animateCodeBlocks)) { return SKIP; } @@ -203,6 +216,7 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { // Mutable render state — the rehype closure and the plugin API methods // both reference this same object. const renderState: AnimateRenderState = { + animateCodeBlocks: false, prevContentLength: 0, lastRenderCharCount: 0, }; @@ -230,6 +244,9 @@ export function createAnimatePlugin(options?: AnimateOptions): AnimatePlugin { name: "animate", type: "animate", rehypePlugin: rehypeAnimate, + setAnimateCodeBlocks(enabled: boolean) { + renderState.animateCodeBlocks = enabled; + }, setPrevContentLength(length: number) { renderState.prevContentLength = length; },