authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-10 23:50:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 23:50:32-07:00
log8078c532ceeee5cb951325031f216171507f3d5e
tree4ca76678909eae59d9a63dd05c19759629d34286
parentd7d7f8f3eb7bf593925281eb62348e15a91252b8
signature Signed by PGP key B5690EEEBB952194

perf(web): preserve completed code-line DOM while streaming (#11198)


8 files changed, 164 insertions(+), 31 deletions(-)

apps/web/package.json+2
...@@ -34,6 +34,8 @@...@@ -34,6 +34,8 @@
34 "class-variance-authority": "^0.7.1",34 "class-variance-authority": "^0.7.1",
35 "culori": "^4.0.2",35 "culori": "^4.0.2",
36 "effect": "catalog:",36 "effect": "catalog:",
37 "hast-util-to-html": "^9.0.5",
38 "hast-util-to-jsx-runtime": "^2.3.6",
37 "heic-to": "^1.5.2",39 "heic-to": "^1.5.2",
38 "jose": "catalog:",40 "jose": "catalog:",
39 "jsonc-parser": "3.3.1",41 "jsonc-parser": "3.3.1",
apps/web/src/components/ChatMarkdown.test.tsx+27-4
...@@ -110,13 +110,36 @@ describe("ChatMarkdown favicon privacy", () => {...@@ -110,13 +110,36 @@ describe("ChatMarkdown favicon privacy", () => {
110});110});
111111
112describe("ChatMarkdown streaming", () => {112describe("ChatMarkdown streaming", () => {
113 it("does not retokenize completed lines when streaming finishes", async () => {
114 const highlighter = await getSyntaxHighlighterPromise("typescript");
115 const highlight = vi.spyOn(highlighter, "codeToHast");
116 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
117 let renderer: ReactTestRenderer | undefined;
118 const text = "```typescript\nconst completed = 1;\nconst current = 2;";
119 try {
120 await act(async () => {
121 renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} isStreaming />);
122 });
123 expect(highlight).toHaveBeenCalled();
124 highlight.mockClear();
125 await act(async () => {
126 renderer!.update(<ChatMarkdown cwd="/tmp/project" text={text + "\n```"} />);
127 });
128 expect(highlight.mock.calls.every(([code]) => !code.includes("const completed"))).toBe(true);
129 } finally {
130 await act(async () => renderer?.unmount());
131 vi.unstubAllGlobals();
132 vi.restoreAllMocks();
133 }
134 });
135
113 it("recovers highlighting after a failed fence changes without resetting its controls", async () => {136 it("recovers highlighting after a failed fence changes without resetting its controls", async () => {
114 const highlighter = await getSyntaxHighlighterPromise("text");137 const highlighter = await getSyntaxHighlighterPromise("text");
115 const codeToHtml = highlighter.codeToHtml.bind(highlighter);138 const codeToHast = highlighter.codeToHast.bind(highlighter);
116 let fail = true;139 let fail = true;
117 vi.spyOn(highlighter, "codeToHtml").mockImplementation((...args) => {140 vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => {
118 if (fail) throw new Error("Temporary highlighter failure");141 if (fail) throw new Error("Temporary highlighter failure");
119 return codeToHtml(...args);142 return codeToHast(...args);
120 });143 });
121 vi.spyOn(console, "error").mockImplementation(() => {});144 vi.spyOn(console, "error").mockImplementation(() => {});
122 vi.spyOn(console, "warn").mockImplementation(() => {});145 vi.spyOn(console, "warn").mockImplementation(() => {});
...@@ -156,7 +179,7 @@ describe("ChatMarkdown streaming", () => {...@@ -156,7 +179,7 @@ describe("ChatMarkdown streaming", () => {
156179
157 it("preserves code controls and details without highlighting an unchanged fence again", async () => {180 it("preserves code controls and details without highlighting an unchanged fence again", async () => {
158 const highlighter = await getSyntaxHighlighterPromise("text");181 const highlighter = await getSyntaxHighlighterPromise("text");
159 const highlight = vi.spyOn(highlighter, "codeToHtml");182 const highlight = vi.spyOn(highlighter, "codeToHast");
160 const writeText = vi.fn(async (_text: string) => {});183 const writeText = vi.fn(async (_text: string) => {});
161 vi.stubGlobal("navigator", { clipboard: { writeText } });184 vi.stubGlobal("navigator", { clipboard: { writeText } });
162 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);185 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
apps/web/src/components/ChatMarkdown.tsx+31-12
...@@ -69,6 +69,7 @@ import React, {...@@ -69,6 +69,7 @@ import React, {
69} from "react";69} from "react";
70import type { Components, Options as ReactMarkdownOptions } from "react-markdown";70import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
71import ReactMarkdown from "react-markdown";71import ReactMarkdown from "react-markdown";
72import { toHtml } from "hast-util-to-html";
72import { createIncrementalMarkdownPlugin } from "../markdown-incremental";73import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
73import { defaultUrlTransform } from "react-markdown";74import { defaultUrlTransform } from "react-markdown";
74import rehypeRaw from "rehype-raw";75import rehypeRaw from "rehype-raw";
...@@ -123,7 +124,8 @@ import { fnv1a32 } from "../lib/diffRendering";...@@ -123,7 +124,8 @@ import { fnv1a32 } from "../lib/diffRendering";
123import { LRUCache } from "../lib/lruCache";124import { LRUCache } from "../lib/lruCache";
124import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";125import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
125import { GitHubIcon } from "./Icons";126import { GitHubIcon } from "./Icons";
126import { createIncrementalHighlighter } from "../lib/incrementalHighlighting";127import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting";
128import { HighlightedCodeLines } from "./chat/HighlightedCodeLines";
127import { RenderErrorBoundary } from "./RenderErrorBoundary";129import { RenderErrorBoundary } from "./RenderErrorBoundary";
128import { useTheme } from "../hooks/useTheme";130import { useTheme } from "../hooks/useTheme";
129import { getClientSettings, useClientSettings } from "../hooks/useSettings";131import { getClientSettings, useClientSettings } from "../hooks/useSettings";
...@@ -1011,9 +1013,14 @@ function SuspenseShikiCodeBlock({...@@ -1011,9 +1013,14 @@ function SuspenseShikiCodeBlock({
1011 themeName,1013 themeName,
1012 isStreaming,1014 isStreaming,
1013}: SuspenseShikiCodeBlockProps) {1015}: SuspenseShikiCodeBlockProps) {
1016 const [hasStreamed, setHasStreamed] = useState(isStreaming);
1017 if (isStreaming && !hasStreamed) setHasStreamed(true);
1014 const language = extractFenceLanguage(className);1018 const language = extractFenceLanguage(className);
1015 const cacheKey = createHighlightCacheKey(code, language, themeName);1019 const cacheKey = createHighlightCacheKey(code, language, themeName);
1016 const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null;1020 // Once lines are mounted individually, keep that renderer when streaming
1021 // finishes so switching to cached HTML cannot clear an existing selection.
1022 const cachedHighlightedHtml =
1023 !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null;
10171024
1018 if (cachedHighlightedHtml != null) {1025 if (cachedHighlightedHtml != null) {
1019 return (1026 return (
...@@ -1031,6 +1038,7 @@ function SuspenseShikiCodeBlock({...@@ -1031,6 +1038,7 @@ function SuspenseShikiCodeBlock({
1031 themeName={themeName}1038 themeName={themeName}
1032 cacheKey={cacheKey}1039 cacheKey={cacheKey}
1033 isStreaming={isStreaming}1040 isStreaming={isStreaming}
1041 preserveLines={isStreaming || hasStreamed}
1034 />1042 />
1035 );1043 );
1036}1044}
...@@ -1041,6 +1049,7 @@ interface UncachedShikiCodeBlockProps {...@@ -1041,6 +1049,7 @@ interface UncachedShikiCodeBlockProps {
1041 themeName: DiffThemeName;1049 themeName: DiffThemeName;
1042 cacheKey: string;1050 cacheKey: string;
1043 isStreaming: boolean;1051 isStreaming: boolean;
1052 preserveLines: boolean;
1044}1053}
10451054
1046function UncachedShikiCodeBlock({1055function UncachedShikiCodeBlock({
...@@ -1049,16 +1058,19 @@ function UncachedShikiCodeBlock({...@@ -1049,16 +1058,19 @@ function UncachedShikiCodeBlock({
1049 themeName,1058 themeName,
1050 cacheKey,1059 cacheKey,
1051 isStreaming,1060 isStreaming,
1061 preserveLines,
1052}: UncachedShikiCodeBlockProps) {1062}: UncachedShikiCodeBlockProps) {
1053 const highlighter = use(getSyntaxHighlighterPromise(language));1063 const highlighter = use(getSyntaxHighlighterPromise(language));
1054 const incrementalHighlight = useMemo(1064 const incrementalHighlight = useMemo(
1055 () => (isStreaming ? createIncrementalHighlighter(highlighter, language, themeName) : null),1065 () =>
1056 [highlighter, isStreaming, language, themeName],1066 preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null,
1067 [highlighter, preserveLines, language, themeName],
1057 );1068 );
1058 const highlightedHtml = useMemo(() => {1069 const highlighted = useMemo(() => {
1059 try {1070 try {
1060 return incrementalHighlight1071 if (incrementalHighlight) return incrementalHighlight(code);
1061 ? incrementalHighlight(code)1072 return preserveLines
1073 ? highlighter.codeToHast(code, { lang: language, theme: themeName })
1062 : highlighter.codeToHtml(code, { lang: language, theme: themeName });1074 : highlighter.codeToHtml(code, { lang: language, theme: themeName });
1063 } catch (error) {1075 } catch (error) {
1064 // Log highlighting failures for debugging while falling back to plain text1076 // Log highlighting failures for debugging while falling back to plain text
...@@ -1067,22 +1079,29 @@ function UncachedShikiCodeBlock({...@@ -1067,22 +1079,29 @@ function UncachedShikiCodeBlock({
1067 error instanceof Error ? error.message : error,1079 error instanceof Error ? error.message : error,
1068 );1080 );
1069 // If highlighting fails for this language, render as plain text1081 // If highlighting fails for this language, render as plain text
1070 return highlighter.codeToHtml(code, { lang: "text", theme: themeName });1082 return preserveLines
1083 ? highlighter.codeToHast(code, { lang: "text", theme: themeName })
1084 : highlighter.codeToHtml(code, { lang: "text", theme: themeName });
1071 }1085 }
1072 }, [code, highlighter, incrementalHighlight, language, themeName]);1086 }, [code, highlighter, incrementalHighlight, language, preserveLines, themeName]);
10731087
1074 useEffect(() => {1088 useEffect(() => {
1075 if (!isStreaming) {1089 if (!isStreaming) {
1090 const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted);
1076 highlightedCodeCache.set(1091 highlightedCodeCache.set(
1077 cacheKey,1092 cacheKey,
1078 highlightedHtml,1093 highlightedHtml,
1079 estimateHighlightedSize(highlightedHtml, code),1094 estimateHighlightedSize(highlightedHtml, code),
1080 );1095 );
1081 }1096 }
1082 }, [cacheKey, code, highlightedHtml, isStreaming]);1097 }, [cacheKey, code, highlighted, isStreaming]);
10831098
1084 return (1099 return typeof highlighted === "string" ? (
1085 <div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlightedHtml }} />1100 <div className="chat-markdown-shiki" dangerouslySetInnerHTML={{ __html: highlighted }} />
1101 ) : (
1102 <div className="chat-markdown-shiki">
1103 <HighlightedCodeLines root={highlighted} />
1104 </div>
1086 );1105 );
1087}1106}
10881107
apps/web/src/components/chat/HighlightedCodeLines.test.tsx created+26
...@@ -0,0 +1,26 @@
1import { getSharedHighlighter } from "@pierre/diffs";
2import { toHtml } from "hast-util-to-html";
3import { renderToStaticMarkup } from "react-dom/server";
4import { describe, expect, it } from "vite-plus/test";
5
6import { createIncrementalHighlightedDocument } from "../../lib/incrementalHighlighting";
7import { HighlightedCodeLines } from "./HighlightedCodeLines";
8
9describe("highlighted code lines", () => {
10 it("preserves Shiki HTML, including colors, escaping, whitespace, and blank lines", async () => {
11 const highlighter = await getSharedHighlighter({
12 langs: ["typescript"],
13 themes: ["pierre-dark", "pierre-light"],
14 preferredHighlighter: "shiki-wasm",
15 });
16 for (const theme of ["pierre-dark", "pierre-light"] as const) {
17 const highlight = createIncrementalHighlightedDocument(highlighter, "typescript", theme);
18 const code =
19 'const html = "<img src=x onerror=alert(1)>";\n\n/* multi\nline */\n\tconst x = 1;\n';
20 for (let end = 0; end <= code.length; end++) {
21 const root = highlight(code.slice(0, end));
22 expect(renderToStaticMarkup(<HighlightedCodeLines root={root} />)).toBe(toHtml(root));
23 }
24 }
25 });
26});
apps/web/src/components/chat/HighlightedCodeLines.tsx created+48
...@@ -0,0 +1,48 @@
1import type { DiffsHighlighter } from "@pierre/diffs";
2import { toHtml } from "hast-util-to-html";
3import { toJsxRuntime } from "hast-util-to-jsx-runtime";
4import { cloneElement, isValidElement, memo, type DOMAttributes } from "react";
5import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
7type HighlightedRoot = ReturnType<DiffsHighlighter["codeToHast"]>;
8type HighlightedNode = HighlightedRoot["children"][number];
9const runtime = { Fragment, jsx, jsxs };
10
11function elementShell(node: Extract<HighlightedNode, { type: "element" }>) {
12 const element = toJsxRuntime({ ...node, children: [] }, runtime);
13 if (!isValidElement<DOMAttributes<HTMLElement>>(element)) {
14 throw new Error("Expected a highlighted code element");
15 }
16 return element;
17}
18
19const HighlightedLine = memo(function HighlightedLine({ node }: { node: HighlightedNode }) {
20 if (node.type !== "element") return toJsxRuntime(node, runtime);
21 return cloneElement(elementShell(node), {
22 dangerouslySetInnerHTML: { __html: toHtml({ type: "root", children: node.children }) },
23 });
24});
25
26/** Completed line nodes retain their identity in the incremental highlighter.
27 * Keep their DOM mounted too: replacing the entire pre makes the browser parse
28 * and resolve styles for thousands of unchanged token spans on each update.
29 */
30export function HighlightedCodeLines({ root }: { root: HighlightedRoot }) {
31 const pre = root.children[0];
32 if (pre?.type !== "element" || pre.tagName !== "pre") return toJsxRuntime(root, runtime);
33 const code = pre.children[0];
34 if (code?.type !== "element" || code.tagName !== "code") return toJsxRuntime(root, runtime);
35 return cloneElement(
36 elementShell(pre),
37 undefined,
38 cloneElement(
39 elementShell(code),
40 undefined,
41 code.children.map((node, index) => (
42 // A line's position is stable as tokens and new lines are appended.
43 // oxlint-disable-next-line react/no-array-index-key
44 <HighlightedLine key={index} node={node} />
45 )),
46 ),
47 );
48}
apps/web/src/lib/incrementalHighlighting.test.ts+18-9
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import { toHtml } from "hast-util-to-html";
1import { getSharedHighlighter } from "@pierre/diffs";2import { getSharedHighlighter } from "@pierre/diffs";
2import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
34
4import { createIncrementalHighlighter } from "./incrementalHighlighting";5import { createIncrementalHighlightedDocument } from "./incrementalHighlighting";
56
6const samples = {7const samples = {
7 typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n",8 typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n",
...@@ -29,10 +30,10 @@ describe("incremental code highlighting", () => {...@@ -29,10 +30,10 @@ describe("incremental code highlighting", () => {
29 async (language, code) => {30 async (language, code) => {
30 const highlighter = await highlighterPromise;31 const highlighter = await highlighterPromise;
31 for (const theme of ["pierre-dark", "pierre-light"] as const) {32 for (const theme of ["pierre-dark", "pierre-light"] as const) {
32 const highlight = createIncrementalHighlighter(highlighter, language, theme);33 const highlight = createIncrementalHighlightedDocument(highlighter, language, theme);
33 for (let end = 0; end <= code.length; end++) {34 for (let end = 0; end <= code.length; end++) {
34 const text = code.slice(0, end);35 const text = code.slice(0, end);
35 expect(highlight(text), `${theme}, prefix ${end}`).toBe(36 expect(toHtml(highlight(text)), `${theme}, prefix ${end}`).toBe(
36 highlighter.codeToHtml(text, { lang: language, theme }),37 highlighter.codeToHtml(text, { lang: language, theme }),
37 );38 );
38 }39 }
...@@ -42,7 +43,11 @@ describe("incremental code highlighting", () => {...@@ -42,7 +43,11 @@ describe("incremental code highlighting", () => {
4243
43 it("resets after edits and truncation, including edits to a completed line", async () => {44 it("resets after edits and truncation, including edits to a completed line", async () => {
44 const highlighter = await highlighterPromise;45 const highlighter = await highlighterPromise;
45 const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark");46 const highlight = createIncrementalHighlightedDocument(
47 highlighter,
48 "typescript",
49 "pierre-dark",
50 );
46 const inputs = [51 const inputs = [
47 "/* open\ncomment\n",52 "/* open\ncomment\n",
48 "/* open\ncomment\n*/\nconst x = 1;",53 "/* open\ncomment\n*/\nconst x = 1;",
...@@ -53,7 +58,7 @@ describe("incremental code highlighting", () => {...@@ -53,7 +58,7 @@ describe("incremental code highlighting", () => {
53 "\n\n\nconst fresh = true;\n",58 "\n\n\nconst fresh = true;\n",
54 ];59 ];
55 for (const text of inputs) {60 for (const text of inputs) {
56 expect(highlight(text)).toBe(61 expect(toHtml(highlight(text))).toBe(
57 highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }),62 highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }),
58 );63 );
59 }64 }
...@@ -63,9 +68,9 @@ describe("incremental code highlighting", () => {...@@ -63,9 +68,9 @@ describe("incremental code highlighting", () => {
63 "preserves %s without requesting grammar state",68 "preserves %s without requesting grammar state",
64 async (language) => {69 async (language) => {
65 const highlighter = await highlighterPromise;70 const highlighter = await highlighterPromise;
66 const highlight = createIncrementalHighlighter(highlighter, language, "pierre-dark");71 const highlight = createIncrementalHighlightedDocument(highlighter, language, "pierre-dark");
67 for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) {72 for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) {
68 expect(highlight(text)).toBe(73 expect(toHtml(highlight(text))).toBe(
69 highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }),74 highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }),
70 );75 );
71 }76 }
...@@ -74,11 +79,15 @@ describe("incremental code highlighting", () => {...@@ -74,11 +79,15 @@ describe("incremental code highlighting", () => {
7479
75 it("preserves partial CRLF and CR line endings", async () => {80 it("preserves partial CRLF and CR line endings", async () => {
76 const highlighter = await highlighterPromise;81 const highlighter = await highlighterPromise;
77 const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark");82 const highlight = createIncrementalHighlightedDocument(
83 highlighter,
84 "typescript",
85 "pierre-dark",
86 );
78 const code = "/* multi\r\nline */\r\nconst x = 1;\r\n";87 const code = "/* multi\r\nline */\r\nconst x = 1;\r\n";
79 for (let end = 0; end <= code.length; end++) {88 for (let end = 0; end <= code.length; end++) {
80 const text = code.slice(0, end);89 const text = code.slice(0, end);
81 expect(highlight(text)).toBe(90 expect(toHtml(highlight(text))).toBe(
82 highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }),91 highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }),
83 );92 );
84 }93 }
apps/web/src/lib/incrementalHighlighting.ts+6-6
...@@ -14,7 +14,7 @@ function codeChildren(root: ReturnType<DiffsHighlighter["codeToHast"]>) {...@@ -14,7 +14,7 @@ function codeChildren(root: ReturnType<DiffsHighlighter["codeToHast"]>) {
14 * multiline strings, comments, and embedded languages continue to highlight as14 * multiline strings, comments, and embedded languages continue to highlight as
15 * they do in a full pass. The current line is always highlighted again.15 * they do in a full pass. The current line is always highlighted again.
16 */16 */
17export function createIncrementalHighlighter(17export function createIncrementalHighlightedDocument(
18 highlighter: DiffsHighlighter,18 highlighter: DiffsHighlighter,
19 language: string,19 language: string,
20 theme: DiffThemeName,20 theme: DiffThemeName,
...@@ -29,7 +29,7 @@ export function createIncrementalHighlighter(...@@ -29,7 +29,7 @@ export function createIncrementalHighlighter(
29 }29 }
30 | undefined;30 | undefined;
3131
32 return (code: string): string => {32 return (code: string) => {
33 // Plain text and ANSI do not have a TextMate grammar state. A CR at the end33 // Plain text and ANSI do not have a TextMate grammar state. A CR at the end
34 // of a chunk can still become a CRLF, so keep that input on the full path.34 // of a chunk can still become a CRLF, so keep that input on the full path.
35 if (35 if (
...@@ -37,7 +37,7 @@ export function createIncrementalHighlighter(...@@ -37,7 +37,7 @@ export function createIncrementalHighlighter(
37 ["text", "plaintext", "plain", "txt", "ansi"].includes(language) ||37 ["text", "plaintext", "plain", "txt", "ansi"].includes(language) ||
38 code.includes("\r")38 code.includes("\r")
39 ) {39 ) {
40 return highlighter.codeToHtml(code, options);40 return highlighter.codeToHast(code, options);
41 }41 }
42 if (cached && !code.startsWith(cached.prefix)) cached = undefined;42 if (cached && !code.startsWith(cached.prefix)) cached = undefined;
43 const end = code.lastIndexOf("\n") + 1;43 const end = code.lastIndexOf("\n") + 1;
...@@ -51,7 +51,7 @@ export function createIncrementalHighlighter(...@@ -51,7 +51,7 @@ export function createIncrementalHighlighter(
51 const state = highlighter.getLastGrammarState(root);51 const state = highlighter.getLastGrammarState(root);
52 if (!state) {52 if (!state) {
53 cached = undefined;53 cached = undefined;
54 return highlighter.codeToHtml(code, options);54 return highlighter.codeToHast(code, options);
55 }55 }
56 cached = {56 cached = {
57 prefix: code.slice(0, end),57 prefix: code.slice(0, end),
...@@ -60,8 +60,8 @@ export function createIncrementalHighlighter(...@@ -60,8 +60,8 @@ export function createIncrementalHighlighter(
60 };60 };
61 }61 }
62 const prefix = cached;62 const prefix = cached;
63 if (!prefix) return highlighter.codeToHtml(code, options);63 if (!prefix) return highlighter.codeToHast(code, options);
64 return highlighter.codeToHtml(code.slice(prefix.prefix.length), {64 return highlighter.codeToHast(code.slice(prefix.prefix.length), {
65 ...options,65 ...options,
66 grammarState: prefix.state,66 grammarState: prefix.state,
67 transformers: [67 transformers: [
pnpm-lock.yaml+6
...@@ -637,6 +637,12 @@ importers:...@@ -637,6 +637,12 @@ importers:
637 effect:637 effect:
638 specifier: 4.0.0-rc.112638 specifier: 4.0.0-rc.112
639 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)639 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)
640 hast-util-to-html:
641 specifier: ^9.0.5
642 version: 9.0.5
643 hast-util-to-jsx-runtime:
644 specifier: ^2.3.6
645 version: 2.3.6
640 heic-to:646 heic-to:
641 specifier: ^1.5.2647 specifier: ^1.5.2
642 version: 1.5.2648 version: 1.5.2