diff --git a/packages/react/src/components/code-block.tsx b/packages/react/src/components/code-block.tsx index e69e013f4..cbd6c2832 100644 --- a/packages/react/src/components/code-block.tsx +++ b/packages/react/src/components/code-block.tsx @@ -2,12 +2,11 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { jsx, jsxs, Fragment } from "react/jsx-runtime"; import { toJsxRuntime } from "hast-util-to-jsx-runtime"; import { + dualThemeOptions, getHighlighter, ensureLang, resolveLang, - useResolvedShikiTheme, type ShikiThemeProp, - type SupportedTheme, } from "../lib/shiki"; import { cn } from "../lib/utils"; import { Button } from "./button"; @@ -62,7 +61,7 @@ const CheckIcon = () => ( // Highlight hook // --------------------------------------------------------------------------- -function useHighlighted(code: string, lang: string, theme: SupportedTheme): ReactNode | null { +function useHighlighted(code: string, lang: string, theme?: ShikiThemeProp): ReactNode | null { const [, setTick] = useState(0); const resolvedLang = (resolveLang(lang) ?? "json") as Parameters[0]; @@ -71,7 +70,9 @@ function useHighlighted(code: string, lang: string, theme: SupportedTheme): Reac if (!isReady) return null; const highlighter = getHighlighter(); - const hast = highlighter.codeToHast(code, { lang: resolvedLang, theme }); + // Dual-theme light-dark() colors: the markup is correct in BOTH color + // schemes, so SSR/hydration can't paint the wrong palette first. + const hast = highlighter.codeToHast(code, { lang: resolvedLang, ...dualThemeOptions(theme) }); return toJsxRuntime(hast, { jsx, jsxs, Fragment }); } @@ -92,8 +93,7 @@ export function CodeBlock(props: { const [copied, setCopied] = useState(false); const language = useMemo(() => detectLanguage(code, langHint), [code, langHint]); - const resolvedTheme = useResolvedShikiTheme(theme); - const highlighted = useHighlighted(code, language, resolvedTheme); + const highlighted = useHighlighted(code, language, theme); const lines = code.split("\n"); const isLong = lines.length > 24; diff --git a/packages/react/src/components/expandable-code-block.tsx b/packages/react/src/components/expandable-code-block.tsx index 52dbb95f9..8617942fa 100644 --- a/packages/react/src/components/expandable-code-block.tsx +++ b/packages/react/src/components/expandable-code-block.tsx @@ -1,10 +1,5 @@ -import { useCallback, useMemo, useState } from "react"; -import { - getHighlighter, - useResolvedShikiTheme, - type ShikiThemeProp, - type SupportedTheme, -} from "../lib/shiki"; +import { useCallback, useMemo, useState, type CSSProperties } from "react"; +import { dualThemeOptions, getHighlighter, type ShikiThemeProp } from "../lib/shiki"; import { cn } from "../lib/utils"; import { Button } from "./button"; import type { ThemedToken } from "shiki/core"; @@ -103,15 +98,22 @@ const CheckIcon = () => ( // Shiki tokenization hook — non-blocking // --------------------------------------------------------------------------- -function useTokens(code: string, theme: SupportedTheme): ThemedToken[][] { +function useTokens(code: string, theme?: ShikiThemeProp): ThemedToken[][] { const highlighter = getHighlighter(); + // Dual-theme light-dark() colors (token.htmlStyle): correct in both color + // schemes from the first frame — no JS dark-mode probe to catch up to. const result = highlighter.codeToTokens(code, { lang: "typescript", - theme, + ...dualThemeOptions(theme), }); return result.tokens; } +/** A dual-theme token's inline style (light-dark color + per-theme CSS vars). */ +const tokenStyle = (token: Pick): CSSProperties | undefined => + (token.htmlStyle as CSSProperties | undefined) ?? + (token.color ? { color: token.color } : undefined); + // --------------------------------------------------------------------------- // Inline-expand logic // @@ -176,12 +178,13 @@ const applyExpansions = ( // --------------------------------------------------------------------------- type RenderToken = - | { kind: "text"; content: string; color?: string } - | { kind: "ref"; name: string; color?: string }; + | { kind: "text"; content: string; style?: CSSProperties } + | { kind: "ref"; name: string; style?: CSSProperties }; const splitToken = (token: ThemedToken, clickableNames: ReadonlySet): RenderToken[] => { + const style = tokenStyle(token); if (clickableNames.size === 0) { - return [{ kind: "text", content: token.content, color: token.color }]; + return [{ kind: "text", content: token.content, style }]; } const text = token.content; @@ -205,14 +208,14 @@ const splitToken = (token: ThemedToken, clickableNames: ReadonlySet): Re } if (earliest === -1) { - results.push({ kind: "text", content: remaining, color: token.color }); + results.push({ kind: "text", content: remaining, style }); break; } if (earliest > 0) { - results.push({ kind: "text", content: remaining.slice(0, earliest), color: token.color }); + results.push({ kind: "text", content: remaining.slice(0, earliest), style }); } - results.push({ kind: "ref", name: matchedName, color: token.color }); + results.push({ kind: "ref", name: matchedName, style }); remaining = remaining.slice(earliest + matchedName.length); } @@ -242,7 +245,7 @@ function HighlightedCode(props: { return parts.map((part, pi) => { if (part.kind === "text") { return ( - + {part.content} ); @@ -269,7 +272,7 @@ function HighlightedCode(props: { "cursor-pointer underline underline-offset-2 hover:opacity-80", isExpanded ? "decoration-current/50" : "decoration-current/30", )} - style={part.color ? { color: part.color } : undefined} + style={part.style} title={isExpanded ? `Collapse ${part.name}` : `Expand ${part.name}`} > {part.name} @@ -294,7 +297,6 @@ export function ExpandableCodeBlock(props: { theme?: ShikiThemeProp; }) { const { code, definitions = [], className, theme } = props; - const resolvedTheme = useResolvedShikiTheme(theme); // Auto-expand trivial aliases (primitives, simple unions, string literals) const trivialNames = useMemo(() => { const trivial = new Set(); @@ -341,7 +343,7 @@ export function ExpandableCodeBlock(props: { return formatTypeScript(withExpansions); }, [code, allExpanded, definitionMap, emptyAncestors]); - const tokens = useTokens(displayCode, resolvedTheme); + const tokens = useTokens(displayCode, theme); const handleToggle = useCallback((name: string) => { setExpanded((prev) => { diff --git a/packages/react/src/lib/shiki.ts b/packages/react/src/lib/shiki.ts index 2aca5ff75..78a4ef269 100644 --- a/packages/react/src/lib/shiki.ts +++ b/packages/react/src/lib/shiki.ts @@ -1,6 +1,5 @@ import { createHighlighterCoreSync, type HighlighterCore } from "shiki/core"; import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; -import { useIsDark } from "../hooks/use-is-dark"; // --------------------------------------------------------------------------- // Eagerly loaded languages (sync — available immediately) @@ -113,19 +112,31 @@ export const DEFAULT_DARK_THEME: SupportedTheme = "github-dark"; export type ShikiThemeProp = SupportedTheme | { light: SupportedTheme; dark: SupportedTheme }; /** - * Resolve a `ShikiThemeProp` (either a single theme or a `{ light, dark }` - * pair) to the theme that should currently be used, reacting to system - * dark-mode changes. When no theme is provided, the default github pair is - * used. + * Resolve a `ShikiThemeProp` to the `{ light, dark }` pair handed to shiki's + * dual-theme mode. A single theme means "this theme in BOTH modes". + * + * Dual-theme + `light-dark()` colors is what keeps highlighting correct from + * the FIRST frame: the rendered markup carries both palettes and the + * browser's own color-scheme picks one — no JS dark-mode probe, so an SSR'd + * page can't paint light-theme tokens and then snap once `useIsDark` syncs. */ -export function useResolvedShikiTheme(theme?: ShikiThemeProp): SupportedTheme { - const isDark = useIsDark(); - if (typeof theme === "string") return theme; - const light = theme?.light ?? DEFAULT_LIGHT_THEME; - const dark = theme?.dark ?? DEFAULT_DARK_THEME; - return isDark ? dark : light; +export function resolveShikiThemes(theme?: ShikiThemeProp): { + light: SupportedTheme; + dark: SupportedTheme; +} { + if (typeof theme === "string") return { light: theme, dark: theme }; + return { + light: theme?.light ?? DEFAULT_LIGHT_THEME, + dark: theme?.dark ?? DEFAULT_DARK_THEME, + }; } +/** The shiki options that render dual-theme `light-dark(...)` colors. */ +export const dualThemeOptions = (theme?: ShikiThemeProp) => ({ + themes: resolveShikiThemes(theme), + defaultColor: "light-dark()" as const, +}); + export function resolveLang(lang: string): SupportedLang | null { const l = lang.trim().toLowerCase(); if (supportedSet.has(l)) { @@ -197,16 +208,6 @@ import type { CodeHighlighterPlugin, ThemeInput } from "streamdown"; type HighlightResult = NonNullable>; const tokensCache = new Map(); -/** - * Read the current system color-scheme preference synchronously. Used in - * non-React contexts (like the streamdown plugin) where hooks aren't - * available. - */ -const prefersDarkNow = (): boolean => { - if (typeof window === "undefined") return false; - return window.matchMedia("(prefers-color-scheme: dark)").matches; -}; - export function createCodeHighlighterPlugin(): CodeHighlighterPlugin { return { name: "shiki" as const, @@ -217,28 +218,24 @@ export function createCodeHighlighterPlugin(): CodeHighlighterPlugin { highlight(options, callback) { const resolved = resolveLang(options.language); const lang = resolved ?? "json"; - const activeTheme = prefersDarkNow() ? DEFAULT_DARK_THEME : DEFAULT_LIGHT_THEME; - const key = `${activeTheme}:${lang}:${options.code.length}:${options.code.slice(0, 128)}`; + // Dual-theme tokens (light-dark() colors): correct in both color + // schemes, so the cache never holds the wrong palette and a mid-stream + // scheme flip needs no re-render. + const key = `${lang}:${options.code.length}:${options.code.slice(0, 128)}`; const cached = tokensCache.get(key); if (cached) return cached; const isReady = ensureLang(lang, () => { // Language just loaded — highlight and notify via callback - const result = highlighter.codeToTokens(options.code, { - lang, - themes: { light: activeTheme, dark: activeTheme }, - }); + const result = highlighter.codeToTokens(options.code, { lang, ...dualThemeOptions() }); tokensCache.set(key, result); callback?.(result); }); if (!isReady) return null; - const result = highlighter.codeToTokens(options.code, { - lang, - themes: { light: activeTheme, dark: activeTheme }, - }); + const result = highlighter.codeToTokens(options.code, { lang, ...dualThemeOptions() }); tokensCache.set(key, result); return result; },