Skip to content

Commit c2d8962

Browse files
AVGVSTVS96claude
andcommitted
feat: add TokenRenderer with streaming performance and multi-theme support
- Add fallback system with inherit/transparent for immediate rendering - Add TokenRenderer with useDeferredValue for concurrent rendering - Hook now returns fallback immediately (never null) - BREAKING CHANGE - Support multi-theme CSS variables via htmlStyle and parseThemeColor - Export TokenRenderer from all entry points - Add 8 tests for multi-theme token output The useDeferredValue pattern allows React to interrupt token rendering during rapid streaming updates, preventing progressive slowdown. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4f02547 commit c2d8962

File tree

8 files changed

+536
-10
lines changed

8 files changed

+536
-10
lines changed

package/src/core.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type {
1111
} from './lib/types';
1212

1313
export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins';
14+
export {
15+
TokenRenderer,
16+
type TokenRendererProps,
17+
} from './lib/token-renderer';
1418

1519
import {
1620
createShikiHighlighterComponent,
@@ -45,7 +49,7 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
4549
lang: Language,
4650
themeInput: Theme | Themes,
4751
options: HighlighterOptions<F> = {} as HighlighterOptions<F>
48-
): OutputFormatMap[F] | null => {
52+
): OutputFormatMap[F] => {
4953
const highlighter = validateCoreHighlighter(options.highlighter);
5054

5155
return useBaseHook(

package/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type {
1111
} from './lib/types';
1212

1313
export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins';
14+
export {
15+
TokenRenderer,
16+
type TokenRendererProps,
17+
} from './lib/token-renderer';
1418

1519
import {
1620
createShikiHighlighterComponent,
@@ -43,7 +47,7 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
4347
lang: Language,
4448
themeInput: Theme | Themes,
4549
options: HighlighterOptions<F> = {} as HighlighterOptions<F>
46-
): OutputFormatMap[F] | null => {
50+
): OutputFormatMap[F] => {
4751
return useBaseHook(
4852
code,
4953
lang,

package/src/lib/fallback.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { ReactNode } from 'react';
2+
import { createElement } from 'react';
3+
import type { TokensResult, ThemedToken } from 'shiki';
4+
import type { OutputFormat, OutputFormatMap } from './types';
5+
6+
/**
7+
* Creates unstyled tokens from plain code.
8+
* Used as fallback while highlighting is in progress.
9+
*/
10+
export const createFallbackTokens = (code: string): TokensResult => {
11+
const lines = code.split('\n');
12+
const tokens: ThemedToken[][] = lines.map((line) => [
13+
{
14+
content: line,
15+
offset: 0,
16+
color: 'inherit',
17+
},
18+
]);
19+
20+
return {
21+
tokens,
22+
fg: 'inherit',
23+
bg: 'transparent',
24+
themeName: '',
25+
rootStyle: '',
26+
};
27+
};
28+
29+
/**
30+
* Creates plain React nodes from code.
31+
* Preserves whitespace and line breaks.
32+
*/
33+
export const createFallbackReact = (code: string): ReactNode => {
34+
const lines = code.split('\n');
35+
return createElement(
36+
'pre',
37+
{ className: 'shiki' },
38+
createElement(
39+
'code',
40+
null,
41+
lines.map((line, i) =>
42+
createElement(
43+
'span',
44+
{ key: i, className: 'line' },
45+
createElement('span', null, line),
46+
i < lines.length - 1 ? '\n' : null
47+
)
48+
)
49+
)
50+
);
51+
};
52+
53+
/**
54+
* Escapes HTML special characters for safe rendering.
55+
*/
56+
const escapeHtml = (text: string): string =>
57+
text
58+
.replace(/&/g, '&amp;')
59+
.replace(/</g, '&lt;')
60+
.replace(/>/g, '&gt;')
61+
.replace(/"/g, '&quot;');
62+
63+
/**
64+
* Creates HTML string from plain code.
65+
* Matches Shiki's output structure for consistency.
66+
*/
67+
export const createFallbackHtml = (code: string): string => {
68+
const lines = code.split('\n');
69+
const lineHtml = lines
70+
.map(
71+
(line) =>
72+
`<span class="line"><span>${escapeHtml(line)}</span></span>`
73+
)
74+
.join('\n');
75+
76+
return `<pre class="shiki"><code>${lineHtml}</code></pre>`;
77+
};
78+
79+
/**
80+
* Creates a fallback result for the specified output format.
81+
* Used to provide immediate content while highlighting is in progress.
82+
*/
83+
export const createFallback = <F extends OutputFormat>(
84+
format: F,
85+
code: string
86+
): OutputFormatMap[F] => {
87+
switch (format) {
88+
case 'tokens':
89+
return createFallbackTokens(code) as OutputFormatMap[F];
90+
case 'html':
91+
return createFallbackHtml(code) as OutputFormatMap[F];
92+
default:
93+
return createFallbackReact(code) as OutputFormatMap[F];
94+
}
95+
};

package/src/lib/hook.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { resolveLanguage } from './language';
2424
import { resolveTheme } from './theme';
2525
import { buildShikiOptions } from './options';
2626
import { transformOutput } from './output';
27+
import { createFallback } from './fallback';
2728

2829
// Each entry point (index, web, core) provides a different factory for bundle optimization
2930
type HighlighterFactory = (
@@ -38,8 +39,13 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
3839
themeInput: Theme | Themes,
3940
options: HighlighterOptions<F> = {} as HighlighterOptions<F>,
4041
highlighterFactory: HighlighterFactory
41-
): OutputFormatMap[F] | null => {
42-
const [output, setOutput] = useState<OutputFormatMap[F] | null>(null);
42+
): OutputFormatMap[F] => {
43+
const format = (options.outputFormat ?? 'react') as F;
44+
45+
// Initialize with fallback - never null
46+
const [output, setOutput] = useState<OutputFormatMap[F]>(() =>
47+
createFallback(format, code)
48+
);
4349

4450
const [stableLang, langRev] = useStableOptions(lang);
4551
const [stableTheme, themeRev] = useStableOptions(themeInput);
@@ -93,7 +99,6 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
9399
const finalOptions = { ...shikiOptions, lang: langToUse };
94100

95101
if (isMounted) {
96-
const format = (stableOpts.outputFormat ?? 'react') as F;
97102
const result = transformOutput(
98103
format,
99104
highlighter,

package/src/lib/token-renderer.tsx

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { memo, useDeferredValue, useMemo } from 'react';
2+
import type { TokensResult, ThemedToken } from 'shiki';
3+
4+
export interface TokenRendererProps {
5+
/**
6+
* The TokensResult from useShikiHighlighter with outputFormat: 'tokens'
7+
*/
8+
tokens: TokensResult;
9+
10+
/**
11+
* Optional className for the pre element
12+
*/
13+
className?: string;
14+
15+
/**
16+
* Optional inline styles for the pre element.
17+
* If not provided, uses tokens.rootStyle (fg/bg from theme)
18+
*/
19+
style?: React.CSSProperties;
20+
}
21+
22+
/**
23+
* Renders a single token as a span with inline color.
24+
* For multi-theme, uses htmlStyle which contains CSS variables.
25+
*/
26+
const Token = memo(function Token({ token }: { token: ThemedToken }) {
27+
// Prefer htmlStyle (multi-theme with CSS variables) over color (single-theme)
28+
const style =
29+
token.htmlStyle ?? (token.color ? { color: token.color } : undefined);
30+
return <span style={style}>{token.content}</span>;
31+
});
32+
33+
/**
34+
* Renders a line of tokens.
35+
*/
36+
const Line = memo(function Line({
37+
tokens,
38+
isLast,
39+
}: {
40+
tokens: ThemedToken[];
41+
isLast: boolean;
42+
}) {
43+
return (
44+
<span className="line">
45+
{tokens.map((token, i) => (
46+
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional, never reorder
47+
<Token key={i} token={token} />
48+
))}
49+
{!isLast && '\n'}
50+
</span>
51+
);
52+
});
53+
54+
/**
55+
* Memoized token renderer with concurrent rendering support.
56+
*
57+
* Uses useDeferredValue to prevent blocking the main thread during
58+
* rapid updates (like streaming). This allows React to interrupt
59+
* rendering and keep the UI responsive.
60+
*
61+
* The component uses contentVisibility: 'auto' to skip rendering
62+
* of off-screen content, improving performance for long code blocks.
63+
*
64+
* @example
65+
* ```tsx
66+
* const tokens = useShikiHighlighter(code, 'ts', 'nord', {
67+
* outputFormat: 'tokens'
68+
* });
69+
*
70+
* return <TokenRenderer tokens={tokens} />;
71+
* ```
72+
*/
73+
/**
74+
* Parses Shiki's fg/bg strings which may contain CSS variables for multi-theme.
75+
* Format: "defaultValue;--css-var:value;--another-var:value"
76+
* Example: "#24292e;--shiki-dark:#e1e4e8"
77+
*/
78+
const parseThemeColor = (
79+
colorString: string | undefined,
80+
cssProperty: 'color' | 'backgroundColor'
81+
): Record<string, string> => {
82+
if (!colorString) return {};
83+
84+
const result: Record<string, string> = {};
85+
const parts = colorString.split(';');
86+
87+
for (const part of parts) {
88+
const trimmed = part.trim();
89+
if (!trimmed) continue;
90+
91+
if (trimmed.startsWith('--')) {
92+
// CSS variable: "--shiki-dark:#e1e4e8" or "--shiki-dark-bg:#24292e"
93+
const colonIndex = trimmed.indexOf(':');
94+
if (colonIndex > 0) {
95+
const varName = trimmed.slice(0, colonIndex);
96+
const varValue = trimmed.slice(colonIndex + 1);
97+
result[varName] = varValue;
98+
}
99+
} else {
100+
// Default color value: "#24292e"
101+
result[cssProperty] = trimmed;
102+
}
103+
}
104+
105+
return result;
106+
};
107+
108+
export const TokenRenderer = memo(function TokenRenderer({
109+
tokens,
110+
className,
111+
style,
112+
}: TokenRendererProps) {
113+
// Defer token updates to prevent blocking during streaming
114+
const deferredTokens = useDeferredValue(tokens);
115+
116+
// Parse fg/bg into CSSProperties, handling multi-theme CSS variables
117+
const baseStyle = useMemo((): React.CSSProperties => {
118+
const parsed: Record<string, string> = {
119+
// Enable content-visibility for off-screen optimization
120+
contentVisibility: 'auto',
121+
};
122+
123+
// Parse fg (e.g., "#24292e;--shiki-dark:#e1e4e8")
124+
Object.assign(parsed, parseThemeColor(deferredTokens.fg, 'color'));
125+
126+
// Parse bg (e.g., "#fff;--shiki-dark-bg:#24292e")
127+
Object.assign(
128+
parsed,
129+
parseThemeColor(deferredTokens.bg, 'backgroundColor')
130+
);
131+
132+
// Also parse rootStyle for single-theme fallback
133+
if (deferredTokens.rootStyle) {
134+
const parts = deferredTokens.rootStyle.split(';');
135+
for (const part of parts) {
136+
const [key, value] = part.split(':');
137+
if (key && value) {
138+
const camelKey = key
139+
.trim()
140+
.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
141+
parsed[camelKey] = value.trim();
142+
}
143+
}
144+
}
145+
146+
return parsed as React.CSSProperties;
147+
}, [deferredTokens.fg, deferredTokens.bg, deferredTokens.rootStyle]);
148+
149+
const mergedStyle = style ? { ...baseStyle, ...style } : baseStyle;
150+
const preClass = className ? `shiki ${className}` : 'shiki';
151+
152+
return (
153+
<pre className={preClass} style={mergedStyle}>
154+
<code>
155+
{deferredTokens.tokens.map((lineTokens, i) => (
156+
<Line
157+
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional, never reorder
158+
key={i}
159+
tokens={lineTokens}
160+
isLast={i === deferredTokens.tokens.length - 1}
161+
/>
162+
))}
163+
</code>
164+
</pre>
165+
);
166+
});

package/src/lib/types.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,22 +149,25 @@ interface TimeoutState {
149149
* Public API signature for the useShikiHighlighter hook.
150150
* Generic parameter narrows return type based on outputFormat option.
151151
*
152+
* Returns immediately with a fallback (unstyled code) while highlighting
153+
* is in progress - never returns null.
154+
*
152155
* @example
153-
* // Returns ReactNode | null
156+
* // Returns ReactNode
154157
* const jsx = useShikiHighlighter(code, 'ts', 'nord');
155158
*
156-
* // Returns string | null
159+
* // Returns string
157160
* const html = useShikiHighlighter(code, 'ts', 'nord', { outputFormat: 'html' });
158161
*
159-
* // Returns TokensResult | null
162+
* // Returns TokensResult
160163
* const result = useShikiHighlighter(code, 'ts', 'nord', { outputFormat: 'tokens' });
161164
*/
162165
export type UseShikiHighlighter = <F extends OutputFormat = 'react'>(
163166
code: string,
164167
lang: Language,
165168
themeInput: Theme | Themes,
166169
options?: HighlighterOptions<F>
167-
) => OutputFormatMap[F] | null;
170+
) => OutputFormatMap[F];
168171

169172
export type {
170173
Language,

package/src/web.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import type {
1111
} from './lib/types';
1212

1313
export { isInlineCode, rehypeInlineCodeProperty } from './lib/plugins';
14+
export {
15+
TokenRenderer,
16+
type TokenRendererProps,
17+
} from './lib/token-renderer';
1418

1519
import {
1620
createShikiHighlighterComponent,
@@ -43,7 +47,7 @@ export const useShikiHighlighter = <F extends OutputFormat = 'react'>(
4347
lang: Language,
4448
themeInput: Theme | Themes,
4549
options: HighlighterOptions<F> = {} as HighlighterOptions<F>
46-
): OutputFormatMap[F] | null => {
50+
): OutputFormatMap[F] => {
4751
return useBaseHook(
4852
code,
4953
lang,

0 commit comments

Comments
 (0)