` tag: `html.replace(PRE_TAG_REGEX, ']*)(style="[^"]*background[^";]*;?[^"]*")([^>]*>)/g, '$1$3')`
+
+### 2.2 Design Context & Historical PRs
+
+**PR #69**: Major UX redesign that introduced:
+- `preClassName` parameter for custom styling injection
+- `removePreBackground` function to strip theme backgrounds
+- Enhanced header structure with language labels and action buttons
+
+### 2.3 Current react-shiki Implementation
+
+Created two transformers to replicate Streamdown's original HTML transformations using Shiki's transformer API:
+
+**1. preClassTransformer**: Injects custom classes into the `` tag via the `pre(node)` hook
+**2. removeBackgroundTransformer**: Strips background styles from the `` element, handling both string and object style properties
+
+**Improvement over original**: Both transformers operate on the HAST (Hypertext Abstract Syntax Tree) rather than regex-based string manipulation.
+
+### 2.4 Testing Requirements
+
+- Verify custom classes applied correctly
+- Verify backgrounds removed
+- **Testing**: Passed all tests in packages/streamdown/__tests__/
+
+---
+
+## 3. Language Resolution & Dynamic Loading
+
+### 3.1 Streamdown's Original Implementation
+
+**Issue #78 → PR #80: Multi-Language Rendering Bug**
+
+Version 1.1.7 failed when rendering multiple code blocks with different languages simultaneously. Only the first language loaded; subsequent blocks threw Shiki errors and failed to render.
+
+**Root cause**: Global cache had race conditions and didn't properly track loaded languages across concurrent component instances.
+
+**Solution**: `HighlighterManager` singleton with centralized language tracking (`Set`), synchronized initialization (`initializationPromise`), and language preservation during theme changes (pre-loads all previously loaded languages when recreating highlighters).
+
+---
+
+The `HighlighterManager` class (~100 lines) handled:
+- **Fallback handling**: Defaulted to "text" language for unsupported languages
+- **Language loading**: Tracked loaded languages in a `Set` to avoid redundant loading
+- **Synchronized language loading**: Synchronized language loading across both highlighters
+- **Language pre-loading on theme change**: When themes changed and highlighters were recreated, all previously loaded languages were pre-loaded into the new highlighters
+- **JavaScript engine for CSP compliance**: Used `createJavaScriptRegexEngine({ forgiving: true })` to avoid CSP violations from WASM (PR #77: "fix: rendering code blocks throws csp errors using vite build"). This avoids WASM while providing best-effort results for unsupported grammars.
+
+**Data attributes**: Rich data attributes on elements (`data-code-block-container`, `data-code-block-header`, `data-language`) for testing and styling.
+
+### 3.2 Current react-shiki Implementation
+
+Retained Streamdown's explicit language validation with fallback to "text" for unsupported languages. While react-shiki handles language resolution internally, this preserves exact compatibility during migration and can potentially be removed if react-shiki's built-in handling proves sufficient.
+
+**Highlighter creation**:
+
+```typescript
+import { createHighlighter } from "shiki";
+import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
+
+async function createStreamdownHighlighter(themes: [BundledTheme, BundledTheme]) {
+ return await createHighlighter({
+ themes: themes,
+ langs: [], // Languages loaded dynamically as needed
+ engine: createJavaScriptRegexEngine({ forgiving: true })
+ });
+}
+```
+
+**Key architectural insight**: Using `createHighlighter` from the main "shiki" bundle (not `react-shiki/core`) provides access to all bundled languages while still allowing engine customization. This custom highlighter is then passed to react-shiki's `useShikiHighlighter` hook via the `highlighter` option.
+
+**New behavior**:
+- Single highlighter instance persists across theme changes
+- Shiki's default dynamic language loading should handle loading languages as needed
+- No explicit language tracking required
+
+### 3.3 Open Questions & Research Needed
+
+
+**Potential blindspots to investigate**:
+
+1. **Can we remove explicit fallback?** Does react-shiki's built-in language handling sufficiently cover Streamdown's explicit fallback behavior?
+
+2. **Theme changes**: When `lightTheme` or `darkTheme` context values change, we recreate the highlighter. Does this lose loaded languages?
+ - **Current implementation**: Yes, we recreate the highlighter in `useEffect([lightTheme, darkTheme])`
+ - **Question**: Does this cause languages to be re-downloaded/re-parsed?
+ - **Mitigation needed?**: Consider caching the highlighter at a higher level or implementing theme loading without recreation
+
+3. **Multiple code blocks**: Do languages get shared across different `CodeBlock` component instances?
+ - **Current implementation**: Each component creates its own highlighter instance
+ - **Question**: Is this inefficient? Should we use a singleton pattern?
+ - **Note**: Original Streamdown used a singleton `HighlighterManager`
+
+4. **Language loading errors**: How does the new setup handle languages that fail to load?
+ - **Original**: Explicit fallback to "text"
+ - **New**: Relies on Shiki's internal error handling
+ - **Verification needed**: Test with invalid language identifiers
+
+### 3.4 Action Items
+
+1. Review how react-shiki handles language caching across re-renders
+2. Consider implementing a module-level highlighter singleton (like original Streamdown)
+3. Add comprehensive logging to track language loading behavior during development
+4. Performance testing with multiple code blocks and frequent theme changes
+5. Test with various language identifiers to ensure compatibility
+
+---
+
+## 4. Code Reduction & Simplification
+
+- **Removed**: ~100 lines of `HighlighterManager` class
+- **Added**: ~30 lines of transformer and highlighter creation logic
+- **Net result**: Simpler, more maintainable code
+- **Testing**: Passed all tests in packages/streamdown/__tests__/
+
+---
+
+## 5. Testing in apps/website
+
+**Priority**: High
+**Status**: Not yet tested
+
+**Required actions**:
+1. Build and run the `apps/website` project with the migrated code
+2. Verify syntax highlighting works correctly
+3. Test theme switching behavior (light/dark mode transitions)
+4. Confirm no visual regressions compared to dual highlighter approach
+5. Verify no CSP violations in apps/website build
+
+---
+
+## 6. Summary
+
+The migration from Streamdown's dual highlighter approach to react-shiki simplifies the codebase. The key remaining work involves:
+
+1. **Verification**: Testing in the actual website application
+2. **Theme switching**: Ensuring the CSS variable approach works with Streamdown's existing theme management
+3. **Performance**: Confirming that Shiki's dynamic language loading handles all edge cases previously covered by manual language tracking
+4. **Optimization**: Evaluating whether a highlighter singleton would be more efficient than per-component instances
+
+The migration reduces complexity while leveraging react-shiki's well-tested multi-theme support.
From 94e6450266ff9530b65619a908a12fb4ab5ff825 Mon Sep 17 00:00:00 2001
From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com>
Date: Wed, 1 Oct 2025 22:16:51 -0700
Subject: [PATCH 3/8] ensure tests check for highlighted output
---
.../streamdown/__tests__/code-block.test.tsx | 36 +++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/packages/streamdown/__tests__/code-block.test.tsx b/packages/streamdown/__tests__/code-block.test.tsx
index 504324e4..82be3826 100644
--- a/packages/streamdown/__tests__/code-block.test.tsx
+++ b/packages/streamdown/__tests__/code-block.test.tsx
@@ -313,4 +313,40 @@ describe("CodeBlock with multiple languages", () => {
{ timeout: 5000 }
);
});
+
+ it("should actually highlight code with syntax colors", async () => {
+ const jsCode = "const x = 1;";
+ const { container } = render(
+
+
+
+ );
+
+ await waitFor(
+ () => {
+ // Check for shiki-themed pre element with CSS variables
+ const pre = container.querySelector("pre.shiki");
+ expect(pre).toBeTruthy();
+ expect(pre).toHaveClass("shiki-themes");
+
+ // Verify CSS variables for multi-theme support
+ const style = pre?.getAttribute("style");
+ expect(style).toBeTruthy();
+ expect(style).toContain("--shiki-light");
+ expect(style).toContain("--shiki-dark");
+
+ // Verify actual syntax highlighting tokens exist
+ // Code should be wrapped in span elements (tokens)
+ const tokens = container.querySelectorAll("pre.shiki span.line span");
+ expect(tokens.length).toBeGreaterThan(0);
+
+ // At least one token should have style attribute with color
+ const tokensWithStyle = Array.from(tokens).filter(
+ (token) => token.getAttribute("style")
+ );
+ expect(tokensWithStyle.length).toBeGreaterThan(0);
+ },
+ { timeout: 5000 }
+ );
+ });
});
From 7c6a2dfeefd29e933e63629b22a73b82b698a625 Mon Sep 17 00:00:00 2001
From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com>
Date: Wed, 1 Oct 2025 22:17:09 -0700
Subject: [PATCH 4/8] use react-shiki in website highlighter
---
apps/website/components/code-block.tsx | 93 ++++++++++++++++----------
apps/website/package.json | 1 +
pnpm-lock.yaml | 18 +++++
3 files changed, 78 insertions(+), 34 deletions(-)
diff --git a/apps/website/components/code-block.tsx b/apps/website/components/code-block.tsx
index 7a49007a..8970c3ed 100644
--- a/apps/website/components/code-block.tsx
+++ b/apps/website/components/code-block.tsx
@@ -10,7 +10,15 @@ import {
useRef,
useState,
} from "react";
-import { type BundledLanguage, codeToHtml } from "shiki";
+import {
+ bundledLanguages,
+ getSingletonHighlighter,
+ type BundledLanguage,
+ type Highlighter,
+ type SpecialLanguage,
+} from "shiki";
+import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
+import { useShikiHighlighter } from "react-shiki";
import { cn } from "@/lib/utils";
type CodeBlockProps = HTMLAttributes & {
@@ -26,17 +34,25 @@ const CodeBlockContext = createContext({
code: "",
});
-export async function highlightCode(code: string, language: BundledLanguage) {
- return Promise.all([
- await codeToHtml(code, {
- lang: language,
- theme: "github-light",
- }),
- await codeToHtml(code, {
- lang: language,
- theme: "github-dark",
- }),
- ]);
+// Get singleton highlighter with JavaScript engine (PR #77)
+async function getWebsiteHighlighter(
+ lang: BundledLanguage | SpecialLanguage
+): Promise {
+ const highlighter = await getSingletonHighlighter({
+ themes: ["github-light", "github-dark"],
+ langs: [],
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
+ });
+
+ // Load the language if it's a bundled language
+ if (lang !== "text" && Object.hasOwn(bundledLanguages, lang)) {
+ const loadedLanguages = highlighter.getLoadedLanguages();
+ if (!loadedLanguages.includes(lang)) {
+ await highlighter.loadLanguage(lang as BundledLanguage);
+ }
+ }
+
+ return highlighter;
}
export const CodeBlock = ({
@@ -46,43 +62,52 @@ export const CodeBlock = ({
children,
...props
}: CodeBlockProps) => {
- const [html, setHtml] = useState("");
- const [darkHtml, setDarkHtml] = useState("");
+ const [highlighter, setHighlighter] = useState(
+ undefined
+ );
const mounted = useRef(false);
+ // Check if language is supported
+ const isLanguageSupported = (lang: string): lang is BundledLanguage => {
+ return Object.hasOwn(bundledLanguages, lang);
+ };
+
+ const langToUse = isLanguageSupported(language)
+ ? language
+ : ("text" as SpecialLanguage);
+
useEffect(() => {
- highlightCode(code, language).then(([light, dark]) => {
- if (!mounted.current) {
- setHtml(light);
- setDarkHtml(dark);
- mounted.current = true;
+ mounted.current = true;
+ getWebsiteHighlighter(langToUse).then((h) => {
+ if (mounted.current) {
+ setHighlighter(h);
}
});
return () => {
mounted.current = false;
};
- }, [code, language]);
+ }, [langToUse]);
+
+ // Use react-shiki with singleton highlighter
+ const html = useShikiHighlighter(
+ code,
+ langToUse,
+ { light: "github-light", dark: "github-dark" },
+ {
+ highlighter,
+ defaultColor: "light-dark()",
+ outputFormat: "html",
+ }
+ );
return (
pre]:bg-transparent!",
- className
- )}
- // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
- dangerouslySetInnerHTML={{ __html: html }}
- {...props}
- />
-
pre]:bg-transparent!",
- className
- )}
+ className={cn("overflow-x-auto [&>pre]:bg-transparent!", className)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
- dangerouslySetInnerHTML={{ __html: darkHtml }}
+ dangerouslySetInnerHTML={{ __html: (html as string) || "" }}
{...props}
/>
{children}
diff --git a/apps/website/package.json b/apps/website/package.json
index 503ba054..b2dddd37 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -21,6 +21,7 @@
"react": "19.1.1",
"react-dom": "19.1.1",
"react-markdown": "^10.1.0",
+ "react-shiki": "^0.8.0",
"rehype-harden": "^1.1.5",
"shiki": "^3.12.2",
"streamdown": "workspace:*",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bdda8df0..d6848765 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -168,6 +168,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.1.10)(react@19.1.1)
+ react-shiki:
+ specifier: ^0.8.0
+ version: 0.8.0(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
rehype-harden:
specifier: ^1.1.5
version: 1.1.5
@@ -8241,6 +8244,21 @@ snapshots:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
+ react-shiki@0.8.0(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
+ dependencies:
+ clsx: 2.1.1
+ dequal: 2.0.3
+ hast-util-to-jsx-runtime: 2.3.6
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+ shiki: 3.13.0
+ unist-util-visit: 5.0.0
+ optionalDependencies:
+ '@types/react': 19.1.10
+ '@types/react-dom': 19.1.7(@types/react@19.1.10)
+ transitivePeerDependencies:
+ - supports-color
+
react-shiki@0.8.0(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
clsx: 2.1.1
From 4a3cb126b225871cafc9be0993253853c4768bb5 Mon Sep 17 00:00:00 2001
From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com>
Date: Sun, 5 Oct 2025 15:01:07 -0700
Subject: [PATCH 5/8] feat: use react-shiki engine param to avoid creating
custom highlighter
---
apps/website/components/code-block.tsx | 52 +-----
packages/streamdown/lib/code-block.tsx | 50 +----
packages/streamdown/package.json | 2 +-
pnpm-lock.yaml | 19 +-
react-shiki-migration-context.md | 248 ++++---------------------
5 files changed, 63 insertions(+), 308 deletions(-)
diff --git a/apps/website/components/code-block.tsx b/apps/website/components/code-block.tsx
index 8970c3ed..c1fec29a 100644
--- a/apps/website/components/code-block.tsx
+++ b/apps/website/components/code-block.tsx
@@ -6,19 +6,14 @@ import {
createContext,
type HTMLAttributes,
useContext,
- useEffect,
- useRef,
useState,
} from "react";
import {
bundledLanguages,
- getSingletonHighlighter,
type BundledLanguage,
- type Highlighter,
type SpecialLanguage,
} from "shiki";
-import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
-import { useShikiHighlighter } from "react-shiki";
+import { createJavaScriptRegexEngine, useShikiHighlighter } from "react-shiki";
import { cn } from "@/lib/utils";
type CodeBlockProps = HTMLAttributes
& {
@@ -34,27 +29,6 @@ const CodeBlockContext = createContext({
code: "",
});
-// Get singleton highlighter with JavaScript engine (PR #77)
-async function getWebsiteHighlighter(
- lang: BundledLanguage | SpecialLanguage
-): Promise {
- const highlighter = await getSingletonHighlighter({
- themes: ["github-light", "github-dark"],
- langs: [],
- engine: createJavaScriptRegexEngine({ forgiving: true }),
- });
-
- // Load the language if it's a bundled language
- if (lang !== "text" && Object.hasOwn(bundledLanguages, lang)) {
- const loadedLanguages = highlighter.getLoadedLanguages();
- if (!loadedLanguages.includes(lang)) {
- await highlighter.loadLanguage(lang as BundledLanguage);
- }
- }
-
- return highlighter;
-}
-
export const CodeBlock = ({
code,
language,
@@ -62,12 +36,6 @@ export const CodeBlock = ({
children,
...props
}: CodeBlockProps) => {
- const [highlighter, setHighlighter] = useState(
- undefined
- );
- const mounted = useRef(false);
-
- // Check if language is supported
const isLanguageSupported = (lang: string): lang is BundledLanguage => {
return Object.hasOwn(bundledLanguages, lang);
};
@@ -76,28 +44,14 @@ export const CodeBlock = ({
? language
: ("text" as SpecialLanguage);
- useEffect(() => {
- mounted.current = true;
- getWebsiteHighlighter(langToUse).then((h) => {
- if (mounted.current) {
- setHighlighter(h);
- }
- });
-
- return () => {
- mounted.current = false;
- };
- }, [langToUse]);
-
- // Use react-shiki with singleton highlighter
const html = useShikiHighlighter(
code,
langToUse,
{ light: "github-light", dark: "github-dark" },
{
- highlighter,
- defaultColor: "light-dark()",
outputFormat: "html",
+ defaultColor: "light-dark()",
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
}
);
diff --git a/packages/streamdown/lib/code-block.tsx b/packages/streamdown/lib/code-block.tsx
index 92359249..dab87aad 100644
--- a/packages/streamdown/lib/code-block.tsx
+++ b/packages/streamdown/lib/code-block.tsx
@@ -12,10 +12,7 @@ import {
} from "react";
import {
bundledLanguages,
- getSingletonHighlighter,
type BundledLanguage,
- type BundledTheme,
- type Highlighter,
type SpecialLanguage,
} from "shiki";
import type { ShikiTransformer } from "shiki/core";
@@ -38,7 +35,7 @@ const CodeBlockContext = createContext({
code: "",
});
-// Custom transformers for Streamdown requirements
+// shiki transformers for background removal and pre class injection
const createPreClassTransformer = (className?: string): ShikiTransformer => ({
name: "streamdown:pre-class",
pre(node) {
@@ -88,28 +85,6 @@ const removeBackgroundTransformer: ShikiTransformer = {
},
};
-// Get singleton highlighter with themes
-async function getStreamdownHighlighter(
- themes: [BundledTheme, BundledTheme],
- lang: BundledLanguage | SpecialLanguage
-): Promise {
- const highlighter = await getSingletonHighlighter({
- themes, // Load both themes
- langs: [], // Languages will be loaded dynamically
- engine: createJavaScriptRegexEngine({ forgiving: true }),
- });
-
- // Load the language if it's a bundled language
- if (lang !== "text" && Object.hasOwn(bundledLanguages, lang)) {
- const loadedLanguages = highlighter.getLoadedLanguages();
- if (!loadedLanguages.includes(lang)) {
- await highlighter.loadLanguage(lang as BundledLanguage);
- }
- }
-
- return highlighter;
-}
-
export const CodeBlock = ({
code,
language,
@@ -119,10 +94,6 @@ export const CodeBlock = ({
...rest
}: CodeBlockProps) => {
const [lightTheme, darkTheme] = useContext(ShikiThemeContext);
- const [highlighter, setHighlighter] = useState(
- undefined
- );
- const mounted = useRef(false);
// Check if language is supported
const isLanguageSupported = (lang: string): lang is BundledLanguage => {
@@ -133,29 +104,14 @@ export const CodeBlock = ({
? language
: ("text" as SpecialLanguage);
- // Get singleton highlighter with current themes and load language
- useEffect(() => {
- mounted.current = true;
- getStreamdownHighlighter([lightTheme, darkTheme], langToUse).then((h) => {
- if (mounted.current) {
- setHighlighter(h);
- }
- });
-
- return () => {
- mounted.current = false;
- };
- }, [lightTheme, darkTheme, langToUse]);
-
- // Use react-shiki hook with our singleton highlighter
const html = useShikiHighlighter(
code,
langToUse,
{ light: lightTheme, dark: darkTheme },
{
- highlighter,
- defaultColor: "light-dark()",
outputFormat: "html",
+ defaultColor: "light-dark()",
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
transformers: [
createPreClassTransformer(preClassName),
removeBackgroundTransformer,
diff --git a/packages/streamdown/package.json b/packages/streamdown/package.json
index fa092365..c9cf4128 100644
--- a/packages/streamdown/package.json
+++ b/packages/streamdown/package.json
@@ -55,7 +55,7 @@
"marked": "^16.2.1",
"mermaid": "^11.11.0",
"react-markdown": "^10.1.0",
- "react-shiki": "^0.8.0",
+ "react-shiki": "^0.9.0",
"rehype-harden": "^1.1.5",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d6848765..edf01b75 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -227,8 +227,8 @@ importers:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.1.12)(react@19.1.1)
react-shiki:
- specifier: ^0.8.0
- version: 0.8.0(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ specifier: ^0.9.0
+ version: 0.9.0(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
rehype-harden:
specifier: ^1.1.5
version: 1.1.5
@@ -3723,6 +3723,19 @@ packages:
'@types/react-dom':
optional: true
+ react-shiki@0.9.0:
+ resolution: {integrity: sha512-5t+vHGglJioG3LU6uTKFaiOC+KNW7haL8e22ZHSP7m174ZD/X2KgCVJcxvcUOM3FiqjPQD09AyS9/+RcOh3PmA==}
+ peerDependencies:
+ '@types/react': '>=16.8.0'
+ '@types/react-dom': '>=16.8.0'
+ react: '>= 16.8.0'
+ react-dom: '>= 16.8.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
@@ -8259,7 +8272,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- react-shiki@0.8.0(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
+ react-shiki@0.9.0(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
clsx: 2.1.1
dequal: 2.0.3
diff --git a/react-shiki-migration-context.md b/react-shiki-migration-context.md
index 7ec2e22a..11f8e898 100644
--- a/react-shiki-migration-context.md
+++ b/react-shiki-migration-context.md
@@ -1,243 +1,75 @@
-# Migrating Streamdown to react-shiki
+# React-Shiki Migration
-This document tracks the migration of Streamdown's syntax highlighting implementation from a custom dual-highlighter shiki powered setup to using react-shiki, with significant simplification of the highlighting logic. react-shiki codebase is available locally at `../../../react-shiki`.
-
-The goal of this migration is to handle the syntax highlighting logic in Streamdown with react-shiki rather than Streamdown's own custom implementation of Shiki.
-
----
-
-## 1. Multi-Theme Highlighting Approach
-
-### 1.1 Streamdown's Original Implementation
-
-Streamdown maintained two separate Shiki highlighter instances - one for light theme and one for dark theme. This was implemented via a `HighlighterManager` class that:
-
-- Created and managed separate `lightHighlighter` and `darkHighlighter` instances
-- Generated two separate HTML outputs (one for each theme)
-- Used Tailwind's `dark:hidden` and `dark:block` classes to show/hide the appropriate themed output
-
-The `HighlighterManager` class handled:
-- **Highlighter initialization**: Created highlighters with `createHighlighter({ themes: [theme], langs: [...], engine: jsEngine })`
-- **Theme change detection**: Recreated highlighters when themes changed, clearing the language cache
-- **Synchronized operations**: Used `initializationPromise` to prevent concurrent initialization
-
-**Context-driven themes**: Themes to use for highlighting are provided via `ShikiThemeContext` from the parent application (defaults to `["github-light", "github-dark"]`). This does not control the switching mechanism.
-
-### 1.2 Design Context & Historical PRs
-
-📝 NOTE FOR FURTHER RESEARCH: Use gh cli to view more relevant PRs and issues for more context in an effort to understand the decisions the streamdown engineers made when architecting the syntax highlighting components of streamdown. Focus primarily on PRs/commits that changed the code-block.tsx file, or other relevant files that relate to syntax highlighting implementation 📝
-
-**PR #40: Introduction of Dual Highlighters**
-
-**Finding**: PR #40 ("fix: codeblock dark mode and background") introduced the dual highlighter approach.
-
-**Observation**: The PR description and commits do not provide explicit justification for why dual highlighters were chosen over Shiki's native multi-theme support with CSS variables. Maybe it has to do with the way the streamdown is expected to be used by applications like `apps/website` and user projects.
-
-**Hypothesis**: This decision was likely made because:
-1. Tailwind's class-based dark mode (`dark:` prefix) is straightforward to implement with dual HTML outputs
-2. May have predated robust multi-theme support in Shiki
-3. Avoids CSS variable complexity in favor of explicit DOM-based theme switching
-4. Provides predictable, isolated outputs for each theme
-
-**Further research needed**: Review Shiki's multi-theme timeline and capabilities at the time of PR #40.
-
-### 1.3 Current react-shiki Implementation
-
-⚠️ NOTE FOR UPDATE: This isn't completely accurate, the react-shiki highlighter is still exporting a light and dark highlighter instance ⚠️
-📝 NOTE FOR FURTHER RESEARCH: Research and review the current streamdown react-shiki implementation to better understand it's architecture with respect to the dual highlighter approach 📝
-
-**Before**: Two separate highlighters, two HTML outputs, Tailwind class-based switching
-**After**: One highlighter with both themes, single HTML output with CSS variables, automatic theme switching via `color-scheme`
-
-```typescript
-// Old approach (simplified)
-const lightHighlighter = await createHighlighter({ themes: [lightTheme], ... });
-const darkHighlighter = await createHighlighter({ themes: [darkTheme], ... });
-const lightHtml = lightHighlighter.codeToHtml(code, { theme: lightTheme });
-const darkHtml = darkHighlighter.codeToHtml(code, { theme: darkTheme });
-// Render both with dark:hidden/dark:block classes
-
-// New approach
-const highlighter = await createHighlighter({
- themes: [lightTheme, darkTheme],
- engine: jsEngine
-});
-const html = useShikiHighlighter(code, language,
- { light: lightTheme, dark: darkTheme },
- { highlighter, defaultColor: 'light-dark()' }
-);
-// Single HTML with CSS variables that respond to color-scheme
-```
-
-### 1.4 Open Questions & Research Needed
-
-📝 NOTE FOR FURTHER RESEARCH: Find out how the apps/website project uses streamdown's highlighting, and how they manage dark mode 📝
-📝 NOTE FOR FURTHER RESEARCH: How does using `dark:hidden`/`dark:block` coincide with the rest of the light/dark theme switching approach? Particularly the dual highlighter approach. If dual highlighters are still both being run, then there isn't a performance benefit to it and it seems redundant 📝
-
-**Questions to answer**:
-1. Is the current implementation actually using a single highlighter or still dual highlighters?
-2. Why do we still export a light and dark highlighter instance?
-3. Why do we still use `dark:hidden`/`dark:block` Tailwind classes / How does using `dark:hidden`/`dark:block` coincide with the rest of the light/dark theme switching approach?
-4. Does Shiki's CSS variable approach (`defaultColor: 'light-dark()'`) work seamlessly with Streamdown's Tailwind setup?
-5. Do we need to set `color-scheme: light dark` in the CSS?
-6. Is there any JavaScript theme management that needs updating?
-7. How does `apps/website` currently implement theme switching? Uses `color-scheme` property? JavaScript toggle?
-8. Investigation into how `apps/website` handles theme switching on the client
-10. Review of any performance considerations that influenced the dual highlighter decision
-11. Timeline of Shiki's multi-theme CSS variable support relative to Streamdown's implementation
-
-### 1.5 Testing Requirements
-
-- `apps/website` theme switching should work as expected
-- System preference changes (prefers-color-scheme media query)
-- Manual theme toggle (if applicable)
-- SSR/initial render in both light and dark modes
-- Visual regression testing compared to dual highlighter approach
+Migration from custom dual-highlighter implementation to react-shiki with Shiki singleton. Reduces ~100 lines of custom highlighter management to ~30 lines while improving language persistence and eliminating race conditions.
---
-## 2. HTML Transformations (preClassName & Background Removal)
-
-### 2.1 Streamdown's Original Implementation
+## 1. Multi-Theme Highlighting
-- **preClassName injection**: Used regex to inject custom classes into the `` tag: `html.replace(PRE_TAG_REGEX, ']*)(style="[^"]*background[^";]*;?[^"]*")([^>]*>)/g, '$1$3')`
+**Original (PR #40, Aug 2025)**: Two highlighter instances (light/dark), generated two HTML outputs, used `dark:hidden`/`dark:block` for theme switching.
-### 2.2 Design Context & Historical PRs
+**Migrated**: Single `getSingletonHighlighter` with both themes, one HTML output with CSS variables, `light-dark()` function for theme switching.
-**PR #69**: Major UX redesign that introduced:
-- `preClassName` parameter for custom styling injection
-- `removePreBackground` function to strip theme backgrounds
-- Enhanced header structure with language labels and action buttons
+**Breaking change**: Requires `color-scheme` property (not just `.dark` class). `next-themes` sets both automatically, but standard Tailwind dark mode users must add `color-scheme` management.
-### 2.3 Current react-shiki Implementation
-
-Created two transformers to replicate Streamdown's original HTML transformations using Shiki's transformer API:
-
-**1. preClassTransformer**: Injects custom classes into the `` tag via the `pre(node)` hook
-**2. removeBackgroundTransformer**: Strips background styles from the `` element, handling both string and object style properties
-
-**Improvement over original**: Both transformers operate on the HAST (Hypertext Abstract Syntax Tree) rather than regex-based string manipulation.
-
-### 2.4 Testing Requirements
-
-- Verify custom classes applied correctly
-- Verify backgrounds removed
-- **Testing**: Passed all tests in packages/streamdown/__tests__/
+**Solutions**:
+1. Document `color-scheme` requirement
+2. Add MutationObserver to sync `.dark` class → `color-scheme`
---
-## 3. Language Resolution & Dynamic Loading
-
-### 3.1 Streamdown's Original Implementation
-
-**Issue #78 → PR #80: Multi-Language Rendering Bug**
+## 2. HTML Transformations
-Version 1.1.7 failed when rendering multiple code blocks with different languages simultaneously. Only the first language loaded; subsequent blocks threw Shiki errors and failed to render.
+**Original**: Regex-based string manipulation for `preClassName` injection and background removal.
-**Root cause**: Global cache had race conditions and didn't properly track loaded languages across concurrent component instances.
-
-**Solution**: `HighlighterManager` singleton with centralized language tracking (`Set`), synchronized initialization (`initializationPromise`), and language preservation during theme changes (pre-loads all previously loaded languages when recreating highlighters).
+**Migrated**: HAST transformers (`preClassTransformer`, `removeBackgroundTransformer`) operating on abstract syntax tree.
---
-The `HighlighterManager` class (~100 lines) handled:
-- **Fallback handling**: Defaulted to "text" language for unsupported languages
-- **Language loading**: Tracked loaded languages in a `Set` to avoid redundant loading
-- **Synchronized language loading**: Synchronized language loading across both highlighters
-- **Language pre-loading on theme change**: When themes changed and highlighters were recreated, all previously loaded languages were pre-loaded into the new highlighters
-- **JavaScript engine for CSP compliance**: Used `createJavaScriptRegexEngine({ forgiving: true })` to avoid CSP violations from WASM (PR #77: "fix: rendering code blocks throws csp errors using vite build"). This avoids WASM while providing best-effort results for unsupported grammars.
-
-**Data attributes**: Rich data attributes on elements (`data-code-block-container`, `data-code-block-header`, `data-language`) for testing and styling.
+## 3. Language Loading
-### 3.2 Current react-shiki Implementation
+**Original (Issue #78, PR #80)**: `HighlighterManager` class (~100 lines) with manual `Set` tracking, synchronized loading across dual highlighters, `initializationPromise` for race conditions. Theme changes cleared languages, recreated highlighters, pre-loaded all languages. Used `createJavaScriptRegexEngine` per PR #77.
-Retained Streamdown's explicit language validation with fallback to "text" for unsupported languages. While react-shiki handles language resolution internally, this preserves exact compatibility during migration and can potentially be removed if react-shiki's built-in handling proves sufficient.
-
-**Highlighter creation**:
+**Migrated**: `getSingletonHighlighter` with manual language loading (required when passing custom highlighter to react-shiki).
```typescript
-import { createHighlighter } from "shiki";
-import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
-
-async function createStreamdownHighlighter(themes: [BundledTheme, BundledTheme]) {
- return await createHighlighter({
- themes: themes,
- langs: [], // Languages loaded dynamically as needed
+async function getStreamdownHighlighter(themes, lang) {
+ const highlighter = await getSingletonHighlighter({
+ themes,
+ langs: [],
engine: createJavaScriptRegexEngine({ forgiving: true })
});
-}
-```
-
-**Key architectural insight**: Using `createHighlighter` from the main "shiki" bundle (not `react-shiki/core`) provides access to all bundled languages while still allowing engine customization. This custom highlighter is then passed to react-shiki's `useShikiHighlighter` hook via the `highlighter` option.
-**New behavior**:
-- Single highlighter instance persists across theme changes
-- Shiki's default dynamic language loading should handle loading languages as needed
-- No explicit language tracking required
+ if (lang !== "text" && Object.hasOwn(bundledLanguages, lang)) {
+ if (!highlighter.getLoadedLanguages().includes(lang)) {
+ await highlighter.loadLanguage(lang);
+ }
+ }
-### 3.3 Open Questions & Research Needed
-
-
-**Potential blindspots to investigate**:
-
-1. **Can we remove explicit fallback?** Does react-shiki's built-in language handling sufficiently cover Streamdown's explicit fallback behavior?
-
-2. **Theme changes**: When `lightTheme` or `darkTheme` context values change, we recreate the highlighter. Does this lose loaded languages?
- - **Current implementation**: Yes, we recreate the highlighter in `useEffect([lightTheme, darkTheme])`
- - **Question**: Does this cause languages to be re-downloaded/re-parsed?
- - **Mitigation needed?**: Consider caching the highlighter at a higher level or implementing theme loading without recreation
-
-3. **Multiple code blocks**: Do languages get shared across different `CodeBlock` component instances?
- - **Current implementation**: Each component creates its own highlighter instance
- - **Question**: Is this inefficient? Should we use a singleton pattern?
- - **Note**: Original Streamdown used a singleton `HighlighterManager`
-
-4. **Language loading errors**: How does the new setup handle languages that fail to load?
- - **Original**: Explicit fallback to "text"
- - **New**: Relies on Shiki's internal error handling
- - **Verification needed**: Test with invalid language identifiers
-
-### 3.4 Action Items
-
-1. Review how react-shiki handles language caching across re-renders
-2. Consider implementing a module-level highlighter singleton (like original Streamdown)
-3. Add comprehensive logging to track language loading behavior during development
-4. Performance testing with multiple code blocks and frequent theme changes
-5. Test with various language identifiers to ensure compatibility
-
----
+ return highlighter;
+}
+```
-## 4. Code Reduction & Simplification
+**Why manual loading**: React-shiki's `highlighterFactory` auto-loads languages, but skips when custom highlighter provided. Falls back to `plaintext` if language not loaded.
-- **Removed**: ~100 lines of `HighlighterManager` class
-- **Added**: ~30 lines of transformer and highlighter creation logic
-- **Net result**: Simpler, more maintainable code
-- **Testing**: Passed all tests in packages/streamdown/__tests__/
+**Improvements**: Languages persist in Registry (never cleared), no manual tracking, no synchronization, no race conditions. ~100 lines → ~8 lines.
---
-## 5. Testing in apps/website
+## 4. Testing
-**Priority**: High
-**Status**: Not yet tested
+**All 253 package tests passing**: Multi-language rendering, concurrent blocks, component functionality, data attributes, language fallback.
-**Required actions**:
-1. Build and run the `apps/website` project with the migrated code
-2. Verify syntax highlighting works correctly
-3. Test theme switching behavior (light/dark mode transitions)
-4. Confirm no visual regressions compared to dual highlighter approach
-5. Verify no CSP violations in apps/website build
+**Website verified**: Theme toggle, system preferences, SSR, syntax highlighting all working with `next-themes` (sets both `.dark` class and `color-scheme`).
---
-## 6. Summary
-
-The migration from Streamdown's dual highlighter approach to react-shiki simplifies the codebase. The key remaining work involves:
-
-1. **Verification**: Testing in the actual website application
-2. **Theme switching**: Ensuring the CSS variable approach works with Streamdown's existing theme management
-3. **Performance**: Confirming that Shiki's dynamic language loading handles all edge cases previously covered by manual language tracking
-4. **Optimization**: Evaluating whether a highlighter singleton would be more efficient than per-component instances
+## Architecture
-The migration reduces complexity while leveraging react-shiki's well-tested multi-theme support.
+| Aspect | Original | Migrated |
+|--------|----------|----------|
+| Highlighters | Dual instances | Singleton |
+| HTML output | Two (light/dark) | One + CSS variables |
+| Theme switching | DOM visibility | `color-scheme` / `light-dark()` |
+| Languages | Manual `Set` + sync | Shiki Registry |
+| Code | ~100 lines | ~30 lines |
From f23404c9f5bb57921fb923684a236f25d789a56a Mon Sep 17 00:00:00 2001
From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com>
Date: Mon, 13 Oct 2025 15:07:44 -0700
Subject: [PATCH 6/8] update transformers and tests
---
.../streamdown/__tests__/code-block.test.tsx | 172 ++++++++++++++++++
packages/streamdown/lib/code-block.tsx | 94 +++++-----
2 files changed, 220 insertions(+), 46 deletions(-)
diff --git a/packages/streamdown/__tests__/code-block.test.tsx b/packages/streamdown/__tests__/code-block.test.tsx
index 82be3826..23633f55 100644
--- a/packages/streamdown/__tests__/code-block.test.tsx
+++ b/packages/streamdown/__tests__/code-block.test.tsx
@@ -349,4 +349,176 @@ describe("CodeBlock with multiple languages", () => {
{ timeout: 5000 }
);
});
+
+ it("should remove background styles from pre element", async () => {
+ const { container } = render(
+
+
+
+ );
+
+ await waitFor(
+ () => {
+ const pre = container.querySelector("pre.shiki");
+ expect(pre).toBeTruthy();
+
+ const style = pre?.getAttribute("style");
+ expect(style).toBeTruthy();
+
+ // Should NOT contain ANY background properties
+ expect(style).not.toMatch(/background[^;]*;?/);
+ expect(style).not.toMatch(/background-color/);
+ expect(style).not.toMatch(/background-image/);
+ expect(style).not.toMatch(/background:/);
+
+ // Should still have CSS variables for theming (proves we didn't remove all styles)
+ expect(style).toContain("--shiki-light");
+ expect(style).toContain("--shiki-dark");
+
+ // Verify it actually has syntax highlighting tokens
+ const tokens = pre.querySelectorAll("span.line span");
+ expect(tokens.length).toBeGreaterThan(0);
+ },
+ { timeout: 5000 }
+ );
+ });
+
+ it("should apply preClassName to pre element", async () => {
+ const { container } = render(
+
+
+
+ );
+
+ await waitFor(
+ () => {
+ const pre = container.querySelector("pre.shiki");
+ expect(pre).toBeTruthy();
+
+ // Verify the actual class attribute contains our custom class
+ const classAttr = pre?.getAttribute("class");
+ expect(classAttr).toContain("custom-pre-class");
+ expect(classAttr).toContain("shiki");
+
+ // Also use toHaveClass for convenience
+ expect(pre).toHaveClass("custom-pre-class");
+ expect(pre).toHaveClass("shiki");
+ expect(pre).toHaveClass("shiki-themes");
+
+ // Verify highlighting actually happened (not just an empty pre)
+ const code = pre?.querySelector("code");
+ expect(code).toBeTruthy();
+ expect(code?.textContent).toContain("const x = 1");
+ },
+ { timeout: 5000 }
+ );
+ });
+
+ it("should handle preClassName with multiple existing classes", async () => {
+ const { container } = render(
+
+
+
+ );
+
+ await waitFor(
+ () => {
+ const pre = container.querySelector("pre.shiki");
+ expect(pre).toBeTruthy();
+
+ // Should merge all classes
+ expect(pre?.className).toContain("shiki");
+ expect(pre?.className).toContain("my-custom-class another-class");
+ },
+ { timeout: 5000 }
+ );
+ });
+
+ it("should work without preClassName (transformer handles undefined)", async () => {
+ const { container } = render(
+
+
+
+ );
+
+ await waitFor(
+ () => {
+ const pre = container.querySelector("pre.shiki");
+ expect(pre).toBeTruthy();
+
+ // Should only have Shiki's classes
+ const classAttr = pre?.getAttribute("class");
+ expect(classAttr).toContain("shiki");
+ expect(classAttr).not.toContain("undefined");
+ expect(classAttr).not.toContain("null");
+
+ // Highlighting should still work
+ const tokens = pre?.querySelectorAll("span.line span");
+ expect(tokens.length).toBeGreaterThan(0);
+ },
+ { timeout: 5000 }
+ );
+ });
+
+ it("should produce valid HTML with transformers applied (integration test)", async () => {
+ const { container } = render(
+
+
+
+ );
+
+ await waitFor(
+ () => {
+ const pre = container.querySelector("pre");
+ expect(pre).toBeTruthy();
+
+ // Get actual rendered HTML
+ const preHTML = pre?.outerHTML;
+ expect(preHTML).toBeTruthy();
+
+ // Verify structure: pre > code > span.line > span (tokens)
+ expect(preHTML).toContain("({
});
// shiki transformers for background removal and pre class injection
+// operates on AST, more reliable than regex
const createPreClassTransformer = (className?: string): ShikiTransformer => ({
name: "streamdown:pre-class",
pre(node) {
- if (className) {
- const existingClasses = node.properties?.className || [];
- const classes = Array.isArray(existingClasses)
- ? existingClasses
- : [existingClasses];
- node.properties = {
- ...node.properties,
- className: [
- ...classes.filter(
- (c): c is string | number =>
- typeof c === "string" || typeof c === "number"
- ),
- className,
- ],
- };
+ if (!className) {
+ return;
+ }
+
+ const existingClass = node.properties?.class;
+ if (typeof existingClass === "string") {
+ node.properties.class = `${existingClass} ${className}`;
+ } else {
+ node.properties.class = className;
}
},
});
+// original implementation removed the entire style attribute, didn't
+// support use of Shiki's native dark mode/multi-theme support
+// this parses AST with more targeted regex to only remove background* properties
const removeBackgroundTransformer: ShikiTransformer = {
name: "streamdown:remove-background",
pre(node) {
- if (node.properties?.style) {
- if (typeof node.properties.style === "string") {
- node.properties.style = node.properties.style
- .replace(/background[^;]*;?/g, "")
- .trim();
- } else if (
- typeof node.properties.style === "object" &&
- node.properties.style !== null &&
- !Array.isArray(node.properties.style)
- ) {
- // Handle object style (if present)
- const styleObj = node.properties.style as Record<
- string,
- string | undefined
- >;
- // biome-ignore lint/performance/noDelete: needed to remove style properties
- delete styleObj.background;
- // biome-ignore lint/performance/noDelete: needed to remove style properties
- delete styleObj.backgroundColor;
- }
+ const style = node.properties?.style;
+ if (typeof style === "string") {
+ node.properties.style = style.replace(/background[^;]*;?/g, "").trim();
}
},
};
+// draft of approach without transformers
+// const processedHtml = useMemo(() => {
+// if (!rawHtml || typeof rawHtml !== 'string') return "";
+// return addPreClassToHtml(
+// removeBackgroundFromHtml(rawHtml),
+// preClassName
+// );
+// }, [rawHtml, preClassName]);
+//
+// const removeBackgroundFromHtml = (html: string) =>
+// html.replace(/style="([^"]*)"/g, (_, styles) => {
+// const cleaned = styles.replace(/background[^;]*;?/g, '').trim();
+// return cleaned ? `style="${cleaned}"` : '';
+// });
+//
+// const addPreClassToHtml = (html: string, className?: string) => {
+// if (!className) return html;
+// return html.replace(
+// /
+// ` {
const [lightTheme, darkTheme] = useContext(ShikiThemeContext);
- // Check if language is supported
const isLanguageSupported = (lang: string): lang is BundledLanguage => {
return Object.hasOwn(bundledLanguages, lang);
};
@@ -104,6 +105,8 @@ export const CodeBlock = ({
? language
: ("text" as SpecialLanguage);
+ // no longer uses two highlighter for light/dark. relies on shiki's native multi-theme and light-dark() support
+ // WARN: BREAKING - does not automatically work for class based dark mode when color-scheme is not set
const html = useShikiHighlighter(
code,
langToUse,
@@ -111,7 +114,7 @@ export const CodeBlock = ({
{
outputFormat: "html",
defaultColor: "light-dark()",
- engine: createJavaScriptRegexEngine({ forgiving: true }),
+ engine: createJavaScriptRegexEngine({ forgiving: true }), // PR #77 - js engine prevents csp errors
transformers: [
createPreClassTransformer(preClassName),
removeBackgroundTransformer,
@@ -119,7 +122,6 @@ export const CodeBlock = ({
}
);
- // Always render the full structure, with empty HTML if highlighter not ready
return (
Date: Mon, 13 Oct 2025 16:07:56 -0700
Subject: [PATCH 7/8] refactor: simplify copy button timeout handling
Commit 23d8efe introduced the if (!isCopied) guard to fix onCopy being
called multiple times. This commit preserves that guard and removes the
unnecessary useEffect cleanup pattern. React 18+ handles unmounted state
updates safely, and effects aren't needed for event handler timeouts.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---
apps/website/components/code-block.tsx | 10 ++++++----
packages/streamdown/lib/code-block.tsx | 12 +-----------
2 files changed, 7 insertions(+), 15 deletions(-)
diff --git a/apps/website/components/code-block.tsx b/apps/website/components/code-block.tsx
index c1fec29a..6c39ddf4 100644
--- a/apps/website/components/code-block.tsx
+++ b/apps/website/components/code-block.tsx
@@ -94,10 +94,12 @@ export const CodeBlockCopyButton = ({
}
try {
- await navigator.clipboard.writeText(code);
- setIsCopied(true);
- onCopy?.();
- setTimeout(() => setIsCopied(false), timeout);
+ if (!isCopied) {
+ await navigator.clipboard.writeText(code);
+ setIsCopied(true);
+ onCopy?.();
+ setTimeout(() => setIsCopied(false), timeout);
+ }
} catch (error) {
onError?.(error as Error);
}
diff --git a/packages/streamdown/lib/code-block.tsx b/packages/streamdown/lib/code-block.tsx
index 25e87e02..91efe629 100644
--- a/packages/streamdown/lib/code-block.tsx
+++ b/packages/streamdown/lib/code-block.tsx
@@ -530,7 +530,6 @@ export const CodeBlockCopyButton = ({
...props
}: CodeBlockCopyButtonProps & { code?: string }) => {
const [isCopied, setIsCopied] = useState(false);
- const timeoutRef = useRef(0);
const contextCode = useContext(CodeBlockContext).code;
const code = propCode ?? contextCode;
@@ -545,22 +544,13 @@ export const CodeBlockCopyButton = ({
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
- timeoutRef.current = window.setTimeout(
- () => setIsCopied(false),
- timeout
- );
+ setTimeout(() => setIsCopied(false), timeout);
}
} catch (error) {
onError?.(error as Error);
}
};
- useEffect(() => {
- return () => {
- window.clearTimeout(timeoutRef.current);
- };
- }, []);
-
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
From 36aff6c2329880fb0152d5e9bdae079e3f895bd2 Mon Sep 17 00:00:00 2001
From: AVGVSTVS96 <122117267+AVGVSTVS96@users.noreply.github.com>
Date: Sat, 18 Oct 2025 01:36:41 -0700
Subject: [PATCH 8/8] integrate v1.4 features into react-shiki branch
- Add isAnimating prop and StreamdownRuntimeContext for streaming UX
- Replace harden-react-markdown with rehype-harden plugin system
- Export defaultRehypePlugins and defaultRemarkPlugins for customization
- Add useRef timeout cleanup to prevent memory leaks
- Disable copy/download buttons during streaming
- Update test apps and website to use isAnimating prop
- All 258 tests passing with react-shiki simplifications preserved
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---
apps/test/app/page.tsx | 3 +
apps/website/package.json | 2 +-
packages/streamdown/lib/code-block.tsx | 24 +++++--
packages/streamdown/lib/table.tsx | 1 -
packages/streamdown/package.json | 3 +-
pnpm-lock.yaml | 25 ++-----
react-shiki-migration-context.md | 96 +++++++++++++++++++++++---
7 files changed, 120 insertions(+), 34 deletions(-)
diff --git a/apps/test/app/page.tsx b/apps/test/app/page.tsx
index 7d2b9820..da91c112 100644
--- a/apps/test/app/page.tsx
+++ b/apps/test/app/page.tsx
@@ -1,6 +1,9 @@
import { gateway } from "@ai-sdk/gateway";
import { Chat } from "./components/chat";
+// ISR: Generate on-demand, cache for 5 minutes
+export const revalidate = 300;
+
export default async function Page() {
const { models } = await gateway.getAvailableModels();
const list = models
diff --git a/apps/website/package.json b/apps/website/package.json
index b2dddd37..b63e12c2 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -21,7 +21,7 @@
"react": "19.1.1",
"react-dom": "19.1.1",
"react-markdown": "^10.1.0",
- "react-shiki": "^0.8.0",
+ "react-shiki": "^0.9.0",
"rehype-harden": "^1.1.5",
"shiki": "^3.12.2",
"streamdown": "workspace:*",
diff --git a/packages/streamdown/lib/code-block.tsx b/packages/streamdown/lib/code-block.tsx
index 91efe629..da1fb745 100644
--- a/packages/streamdown/lib/code-block.tsx
+++ b/packages/streamdown/lib/code-block.tsx
@@ -6,6 +6,8 @@ import {
createContext,
type HTMLAttributes,
useContext,
+ useEffect,
+ useRef,
useState,
} from "react";
import { createJavaScriptRegexEngine, useShikiHighlighter } from "react-shiki";
@@ -15,7 +17,7 @@ import {
type SpecialLanguage,
} from "shiki";
import type { ShikiTransformer } from "shiki/core";
-import { ShikiThemeContext } from "../index";
+import { ShikiThemeContext, StreamdownRuntimeContext } from "../index";
import { cn, save } from "./utils";
type CodeBlockProps = HTMLAttributes & {
@@ -487,6 +489,7 @@ export const CodeBlockDownloadButton = ({
language?: BundledLanguage;
}) => {
const contextCode = useContext(CodeBlockContext).code;
+ const { isAnimating } = useContext(StreamdownRuntimeContext);
const code = propCode ?? contextCode;
const extension =
language && language in languageExtensionMap
@@ -507,9 +510,10 @@ export const CodeBlockDownloadButton = ({
return (