Skip to content

Commit 15645da

Browse files
PaulSenonhaydenbleaselclaude
authored
Feat/code block cls lower lazy boundary (#392)
* feat: Improve code block visual stability by moving lazy highlight boundary * style: update contributing linter rules and fixed missed linter issues * revert superfluous changes to CONTRIBUTING.md and geistdocs.tsx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1c07a91 commit 15645da

File tree

6 files changed

+270
-55
lines changed

6 files changed

+270
-55
lines changed

.changeset/mighty-bikes-eat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"streamdown": minor
3+
---
4+
5+
Move code block lazy loading to the highlighting layer so block shells render immediately with plain text content before syntax colors resolve. This improves visual stability and removes the spinner fallback for standard code blocks.

apps/website/content/docs/code-blocks.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ function example() {
211211
212212
The unterminated block parser ensures the code block renders properly even without the closing backticks.
213213
214+
### Loading Behavior
215+
216+
Code block shells render immediately with plain text content, then syntax colors are applied when highlighting resolves.
217+
218+
This keeps code readable on first paint and improves visual stability during lazy highlight loading.
219+
214220
### Disabling Interactions During Streaming
215221
216222
Use the `isAnimating` prop to disable copy buttons while streaming:
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { act, render, waitFor } from "@testing-library/react";
2+
import type { ComponentType } from "react";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import type { HighlightOptions, HighlightResult } from "../lib/plugin-types";
5+
6+
// Helper for controllable promise (to avoid arbitrary delays)
7+
const createDeferred = <T,>() => {
8+
let resolve!: (value: T | PromiseLike<T>) => void;
9+
let reject!: (reason?: unknown) => void;
10+
11+
const promise = new Promise<T>((res, rej) => {
12+
resolve = res;
13+
reject = rej;
14+
});
15+
16+
return { promise, resolve, reject };
17+
};
18+
19+
describe("Code block loading behavior", () => {
20+
afterEach(() => {
21+
vi.resetModules();
22+
vi.clearAllMocks();
23+
vi.doUnmock("../lib/code-block/highlighted-body");
24+
});
25+
26+
it("renders readable text and no loader before lazy module resolves", async () => {
27+
const lazyModule = createDeferred<{
28+
HighlightedCodeBlockBody: ComponentType<{
29+
code: string;
30+
language: string;
31+
raw: HighlightResult;
32+
}>;
33+
}>();
34+
35+
// mock the targeted part that MUST be lazyloaded
36+
let lazyLoaded = false;
37+
vi.doMock("../lib/code-block/highlighted-body", () =>
38+
lazyModule.promise.then((mod) => {
39+
lazyLoaded = true;
40+
return mod;
41+
})
42+
);
43+
44+
const { StreamdownContext } = await import("../index");
45+
const { CodeBlock } = await import("../lib/code-block");
46+
47+
const { container } = render(
48+
<StreamdownContext.Provider
49+
value={{
50+
shikiTheme: ["github-light", "github-dark"],
51+
controls: true,
52+
isAnimating: false,
53+
mode: "streaming",
54+
}}
55+
>
56+
<CodeBlock code={"const x = 1;\n"} language="javascript" />
57+
</StreamdownContext.Provider>
58+
);
59+
60+
// Before Highlighter lazy component loads we already see text content and no loader
61+
const body = container.querySelector('[data-streamdown="code-block-body"]');
62+
expect(body?.textContent).toContain("const x = 1;");
63+
expect(container.querySelector(".animate-spin")).toBeNull();
64+
expect(lazyLoaded).toBe(false);
65+
66+
// trigger mocked lazyload resolve
67+
lazyModule.resolve({
68+
HighlightedCodeBlockBody: () => (
69+
<pre data-streamdown="code-block-body">lazy module resolved</pre>
70+
),
71+
});
72+
73+
await waitFor(() => {
74+
expect(container.textContent).toContain("lazy module resolved");
75+
expect(lazyLoaded).toBe(true);
76+
});
77+
});
78+
79+
it("applies highlight styles only after manual callback resolution", async () => {
80+
const { StreamdownContext } = await import("../index");
81+
const { PluginContext } = await import("../lib/plugin-context");
82+
const { HighlightedCodeBlockBody } = await import(
83+
"../lib/code-block/highlighted-body"
84+
);
85+
86+
// keep external ref to trigger manually
87+
let resolveHighlight: ((result: HighlightResult) => void) | null = null;
88+
89+
const rawResult: HighlightResult = {
90+
bg: "transparent",
91+
fg: "inherit",
92+
tokens: [
93+
[
94+
{
95+
content: "const x = 1;",
96+
color: "inherit",
97+
bgColor: "transparent",
98+
htmlStyle: {},
99+
offset: 0,
100+
},
101+
],
102+
],
103+
};
104+
105+
const highlightedResult: HighlightResult = {
106+
...rawResult,
107+
tokens: [
108+
[
109+
{
110+
...rawResult.tokens[0][0],
111+
color: "#ff0000",
112+
},
113+
],
114+
],
115+
};
116+
117+
const codePlugin = {
118+
name: "shiki" as const,
119+
type: "code-highlighter" as const,
120+
highlight: vi.fn(
121+
(_: HighlightOptions, callback?: (result: HighlightResult) => void) => {
122+
resolveHighlight = callback ?? null;
123+
return null;
124+
}
125+
),
126+
supportsLanguage: vi.fn().mockReturnValue(true),
127+
getSupportedLanguages: vi.fn().mockReturnValue(["javascript"]),
128+
getThemes: vi.fn().mockReturnValue(["github-light", "github-dark"]),
129+
};
130+
131+
const { container } = render(
132+
<PluginContext.Provider value={{ code: codePlugin as any }}>
133+
<StreamdownContext.Provider
134+
value={{
135+
shikiTheme: ["github-light", "github-dark"],
136+
controls: true,
137+
isAnimating: false,
138+
mode: "streaming",
139+
}}
140+
>
141+
<HighlightedCodeBlockBody
142+
code="const x = 1;"
143+
language="javascript"
144+
raw={rawResult}
145+
/>
146+
</StreamdownContext.Provider>
147+
</PluginContext.Provider>
148+
);
149+
150+
const initialToken = container.querySelector(
151+
'[data-streamdown="code-block-body"] code > span > span'
152+
) as HTMLElement | null;
153+
expect(initialToken).toBeTruthy();
154+
expect(initialToken?.style.getPropertyValue("--sdm-c")).toBe("inherit");
155+
156+
await waitFor(() => {
157+
expect(codePlugin.highlight).toHaveBeenCalledTimes(1);
158+
expect(resolveHighlight).toBeTruthy();
159+
});
160+
161+
// Manually trigger the highlighting
162+
await act(async () => {
163+
resolveHighlight?.(highlightedResult);
164+
});
165+
166+
await waitFor(() => {
167+
const updatedToken = container.querySelector(
168+
'[data-streamdown="code-block-body"] code > span > span'
169+
) as HTMLElement | null;
170+
expect(updatedToken?.style.getPropertyValue("--sdm-c")).toBe("#ff0000");
171+
});
172+
});
173+
});

packages/streamdown/__tests__/components.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ describe("Markdown Components", () => {
247247
</Code>
248248
);
249249

250-
// Wait for lazy-loaded CodeBlock component
250+
// Wait for code block to render
251251
await waitFor(() => {
252252
const codeBlock = container.querySelector(
253253
'[data-streamdown="code-block"]'
@@ -295,7 +295,7 @@ describe("Markdown Components", () => {
295295
</Code>
296296
);
297297

298-
// Wait for lazy-loaded CodeBlock component
298+
// Wait for code block to render
299299
await waitFor(() => {
300300
const codeBlock = container.querySelector(
301301
'[data-streamdown="code-block"]'
@@ -332,7 +332,7 @@ describe("Markdown Components", () => {
332332
</Code>
333333
);
334334

335-
// Wait for Suspense boundary to resolve
335+
// Wait for code block to render
336336
await waitFor(() => {
337337
const codeBlock = container.querySelector(
338338
'[data-streamdown="code-block"]'
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { type HTMLAttributes, useContext, useEffect, useState } from "react";
2+
import type { BundledLanguage } from "shiki";
3+
import { StreamdownContext } from "../../index";
4+
import { useCodePlugin } from "../plugin-context";
5+
import type { HighlightResult } from "../plugin-types";
6+
import { CodeBlockBody } from "./body";
7+
8+
type HighlightedCodeBlockBodyProps = HTMLAttributes<HTMLPreElement> & {
9+
code: string;
10+
language: string;
11+
raw: HighlightResult;
12+
};
13+
14+
export const HighlightedCodeBlockBody = ({
15+
code,
16+
language,
17+
raw,
18+
className,
19+
...rest
20+
}: HighlightedCodeBlockBodyProps) => {
21+
const { shikiTheme } = useContext(StreamdownContext);
22+
const codePlugin = useCodePlugin();
23+
const [result, setResult] = useState<HighlightResult>(raw);
24+
25+
useEffect(() => {
26+
if (!codePlugin) {
27+
setResult(raw);
28+
return;
29+
}
30+
31+
const cachedResult = codePlugin.highlight(
32+
{
33+
code,
34+
language: language as BundledLanguage,
35+
themes: shikiTheme,
36+
},
37+
(highlightedResult) => {
38+
setResult(highlightedResult);
39+
}
40+
);
41+
42+
if (cachedResult) {
43+
setResult(cachedResult);
44+
return;
45+
}
46+
47+
setResult(raw);
48+
}, [code, language, shikiTheme, codePlugin, raw]);
49+
50+
return (
51+
<CodeBlockBody
52+
className={className}
53+
language={language}
54+
result={result}
55+
{...rest}
56+
/>
57+
);
58+
};

packages/streamdown/lib/code-block/index.tsx

Lines changed: 25 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
1-
import {
2-
type HTMLAttributes,
3-
useContext,
4-
useEffect,
5-
useMemo,
6-
useState,
7-
} from "react";
8-
import type { BundledLanguage } from "shiki";
9-
import { StreamdownContext } from "../../index";
10-
import { useCodePlugin } from "../plugin-context";
1+
import { type HTMLAttributes, lazy, Suspense, useMemo } from "react";
112
import type { HighlightResult } from "../plugin-types";
123
import { CodeBlockBody } from "./body";
134
import { CodeBlockContainer } from "./container";
@@ -23,6 +14,12 @@ type CodeBlockProps = HTMLAttributes<HTMLPreElement> & {
2314
isIncomplete?: boolean;
2415
};
2516

17+
const HighlightedCodeBlockBody = lazy(() =>
18+
import("./highlighted-body").then((mod) => ({
19+
default: mod.HighlightedCodeBlockBody,
20+
}))
21+
);
22+
2623
export const CodeBlock = ({
2724
code,
2825
language,
@@ -31,9 +28,6 @@ export const CodeBlock = ({
3128
isIncomplete = false,
3229
...rest
3330
}: CodeBlockProps) => {
34-
const { shikiTheme } = useContext(StreamdownContext);
35-
const codePlugin = useCodePlugin();
36-
3731
// Remove trailing newlines to prevent empty line at end of code blocks
3832
const trimmedCode = useMemo(
3933
() => code.replace(TRAILING_NEWLINES_REGEX, ""),
@@ -58,49 +52,28 @@ export const CodeBlock = ({
5852
[trimmedCode]
5953
);
6054

61-
// Use raw as initial state
62-
const [result, setResult] = useState<HighlightResult>(raw);
63-
64-
// Try to get cached result or subscribe to highlighting
65-
useEffect(() => {
66-
// If no code plugin, just use raw tokens (plain text)
67-
if (!codePlugin) {
68-
setResult(raw);
69-
return;
70-
}
71-
72-
const cachedResult = codePlugin.highlight(
73-
{
74-
code: trimmedCode,
75-
language: language as BundledLanguage,
76-
themes: shikiTheme,
77-
},
78-
(highlightedResult) => {
79-
setResult(highlightedResult);
80-
}
81-
);
82-
83-
if (cachedResult) {
84-
// Already cached, use it immediately
85-
setResult(cachedResult);
86-
return;
87-
}
88-
89-
// Not cached - reset to raw tokens while waiting for highlighting
90-
// This is critical for streaming: ensures we show current code, not stale tokens
91-
setResult(raw);
92-
}, [trimmedCode, language, shikiTheme, codePlugin, raw]);
93-
9455
return (
9556
<CodeBlockContext.Provider value={{ code }}>
9657
<CodeBlockContainer isIncomplete={isIncomplete} language={language}>
9758
<CodeBlockHeader language={language}>{children}</CodeBlockHeader>
98-
<CodeBlockBody
99-
className={className}
100-
language={language}
101-
result={result}
102-
{...rest}
103-
/>
59+
<Suspense
60+
fallback={
61+
<CodeBlockBody
62+
className={className}
63+
language={language}
64+
result={raw}
65+
{...rest}
66+
/>
67+
}
68+
>
69+
<HighlightedCodeBlockBody
70+
className={className}
71+
code={trimmedCode}
72+
language={language}
73+
raw={raw}
74+
{...rest}
75+
/>
76+
</Suspense>
10477
</CodeBlockContainer>
10578
</CodeBlockContext.Provider>
10679
);

0 commit comments

Comments
 (0)