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
logd7d7f8f3eb7bf593925281eb62348e15a91252b8
tree6763870c830490611b874695139c466b18a1b42f
parenta9dabbf100d1f6c0b2ed7b5e879d469ac14fd186
signature Signed by PGP key B5690EEEBB952194

perf(web): resume syntax highlighting from completed lines (#11196)


3 files changed, 169 insertions(+), 2 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+9-2
...@@ -123,6 +123,7 @@ import { fnv1a32 } from "../lib/diffRendering";...@@ -123,6 +123,7 @@ import { fnv1a32 } from "../lib/diffRendering";
123import { LRUCache } from "../lib/lruCache";123import { LRUCache } from "../lib/lruCache";
124import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";124import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
125import { GitHubIcon } from "./Icons";125import { GitHubIcon } from "./Icons";
126import { createIncrementalHighlighter } from "../lib/incrementalHighlighting";
126import { RenderErrorBoundary } from "./RenderErrorBoundary";127import { RenderErrorBoundary } from "./RenderErrorBoundary";
127import { useTheme } from "../hooks/useTheme";128import { useTheme } from "../hooks/useTheme";
128import { getClientSettings, useClientSettings } from "../hooks/useSettings";129import { getClientSettings, useClientSettings } from "../hooks/useSettings";
...@@ -1050,9 +1051,15 @@ function UncachedShikiCodeBlock({...@@ -1050,9 +1051,15 @@ function UncachedShikiCodeBlock({
1050 isStreaming,1051 isStreaming,
1051}: UncachedShikiCodeBlockProps) {1052}: UncachedShikiCodeBlockProps) {
1052 const highlighter = use(getSyntaxHighlighterPromise(language));1053 const highlighter = use(getSyntaxHighlighterPromise(language));
1054 const incrementalHighlight = useMemo(
1055 () => (isStreaming ? createIncrementalHighlighter(highlighter, language, themeName) : null),
1056 [highlighter, isStreaming, language, themeName],
1057 );
1053 const highlightedHtml = useMemo(() => {1058 const highlightedHtml = useMemo(() => {
1054 try {1059 try {
1055 return highlighter.codeToHtml(code, { lang: language, theme: themeName });1060 return incrementalHighlight
1061 ? incrementalHighlight(code)
1062 : highlighter.codeToHtml(code, { lang: language, theme: themeName });
1056 } catch (error) {1063 } catch (error) {
1057 // Log highlighting failures for debugging while falling back to plain text1064 // Log highlighting failures for debugging while falling back to plain text
1058 console.warn(1065 console.warn(
...@@ -1062,7 +1069,7 @@ function UncachedShikiCodeBlock({...@@ -1062,7 +1069,7 @@ function UncachedShikiCodeBlock({
1062 // If highlighting fails for this language, render as plain text1069 // If highlighting fails for this language, render as plain text
1063 return highlighter.codeToHtml(code, { lang: "text", theme: themeName });1070 return highlighter.codeToHtml(code, { lang: "text", theme: themeName });
1064 }1071 }
1065 }, [code, highlighter, language, themeName]);1072 }, [code, highlighter, incrementalHighlight, language, themeName]);
10661073
1067 useEffect(() => {1074 useEffect(() => {
1068 if (!isStreaming) {1075 if (!isStreaming) {
apps/web/src/lib/incrementalHighlighting.test.ts created+86
...@@ -0,0 +1,86 @@
1import { getSharedHighlighter } from "@pierre/diffs";
2import { describe, expect, it } from "vite-plus/test";
3
4import { createIncrementalHighlighter } from "./incrementalHighlighting";
5
6const samples = {
7 typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n",
8 python: '#!/usr/bin/python\nx = """multi\nline"""\nprint(x)\n',
9 bash: "#!/bin/bash\ncat <<EOF\nhello\nEOF\necho hi\n",
10 html: "<script>\nconst x = 1;\n</script>\n<style>\np {color:red;}\n</style>\n",
11 markdown: "# heading\n\n```ts\nconst a = 1;\n```\n\ntext\n",
12 rust: 'fn main() {\n let x = r#"multi\nline"#;\n}\n',
13 tsx: 'const element = <div\n className="x">\n{value}\n</div>;\n',
14 json: '{\n "value": [1,\n 2, 3]\n}\n',
15 yaml: "key: |\n multiline\n value\nnext: true\n",
16 css: '/* comment\n continued */\np::before {\n content: "text";\n}\n',
17 sql: "SELECT 'multi\nline'\nFROM table_name;\n",
18} as const;
19
20const highlighterPromise = getSharedHighlighter({
21 langs: Object.keys(samples) as Array<keyof typeof samples>,
22 themes: ["pierre-dark", "pierre-light"],
23 preferredHighlighter: "shiki-wasm",
24});
25
26describe("incremental code highlighting", () => {
27 it.each(Object.entries(samples))(
28 "matches full HTML at every streaming prefix in %s",
29 async (language, code) => {
30 const highlighter = await highlighterPromise;
31 for (const theme of ["pierre-dark", "pierre-light"] as const) {
32 const highlight = createIncrementalHighlighter(highlighter, language, theme);
33 for (let end = 0; end <= code.length; end++) {
34 const text = code.slice(0, end);
35 expect(highlight(text), `${theme}, prefix ${end}`).toBe(
36 highlighter.codeToHtml(text, { lang: language, theme }),
37 );
38 }
39 }
40 },
41 );
42
43 it("resets after edits and truncation, including edits to a completed line", async () => {
44 const highlighter = await highlighterPromise;
45 const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark");
46 const inputs = [
47 "/* open\ncomment\n",
48 "/* open\ncomment\n*/\nconst x = 1;",
49 "const edited = 2;\nconst x = 1;",
50 "const edited = 2;\nconst x = 10;",
51 "const edited = 2;\n",
52 "",
53 "\n\n\nconst fresh = true;\n",
54 ];
55 for (const text of inputs) {
56 expect(highlight(text)).toBe(
57 highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }),
58 );
59 }
60 });
61
62 it.each(["text", "plaintext", "plain", "txt", "ansi"])(
63 "preserves %s without requesting grammar state",
64 async (language) => {
65 const highlighter = await highlighterPromise;
66 const highlight = createIncrementalHighlighter(highlighter, language, "pierre-dark");
67 for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) {
68 expect(highlight(text)).toBe(
69 highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }),
70 );
71 }
72 },
73 );
74
75 it("preserves partial CRLF and CR line endings", async () => {
76 const highlighter = await highlighterPromise;
77 const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark");
78 const code = "/* multi\r\nline */\r\nconst x = 1;\r\n";
79 for (let end = 0; end <= code.length; end++) {
80 const text = code.slice(0, end);
81 expect(highlight(text)).toBe(
82 highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }),
83 );
84 }
85 });
86});
apps/web/src/lib/incrementalHighlighting.ts created+74
...@@ -0,0 +1,74 @@
1import type { DiffsHighlighter } from "@pierre/diffs";
2
3import type { DiffThemeName } from "./diffRendering";
4
5function codeChildren(root: ReturnType<DiffsHighlighter["codeToHast"]>) {
6 const pre = root.children.find((node) => node.type === "element" && node.tagName === "pre");
7 if (pre?.type !== "element") throw new Error("Missing highlighted pre element");
8 const code = pre.children.find((node) => node.type === "element" && node.tagName === "code");
9 if (code?.type !== "element") throw new Error("Missing highlighted code element");
10 return code.children;
11}
12
13/** Resume tokenization after the last completed line. Keep its grammar state so
14 * multiline strings, comments, and embedded languages continue to highlight as
15 * they do in a full pass. The current line is always highlighted again.
16 */
17export function createIncrementalHighlighter(
18 highlighter: DiffsHighlighter,
19 language: string,
20 theme: DiffThemeName,
21) {
22 const options = { lang: language, theme };
23 const newline = { type: "text" as const, value: "\n" };
24 let cached:
25 | {
26 prefix: string;
27 state: ReturnType<DiffsHighlighter["getLastGrammarState"]>;
28 children: ReturnType<typeof codeChildren>;
29 }
30 | undefined;
31
32 return (code: string): string => {
33 // 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.
35 if (
36 !language ||
37 ["text", "plaintext", "plain", "txt", "ansi"].includes(language) ||
38 code.includes("\r")
39 ) {
40 return highlighter.codeToHtml(code, options);
41 }
42 if (cached && !code.startsWith(cached.prefix)) cached = undefined;
43 const end = code.lastIndexOf("\n") + 1;
44 if (end > (cached?.prefix.length ?? 0)) {
45 // Omit the final newline: Shiki would tokenize an extra empty line and
46 // advance the grammar state twice before we process the following line.
47 const root = highlighter.codeToHast(code.slice(cached?.prefix.length ?? 0, end - 1), {
48 ...options,
49 ...(cached ? { grammarState: cached.state } : {}),
50 });
51 const state = highlighter.getLastGrammarState(root);
52 if (!state) {
53 cached = undefined;
54 return highlighter.codeToHtml(code, options);
55 }
56 cached = {
57 prefix: code.slice(0, end),
58 state,
59 children: [...(cached ? [...cached.children, newline] : []), ...codeChildren(root)],
60 };
61 }
62 const prefix = cached;
63 if (!prefix) return highlighter.codeToHtml(code, options);
64 return highlighter.codeToHtml(code.slice(prefix.prefix.length), {
65 ...options,
66 grammarState: prefix.state,
67 transformers: [
68 {
69 code: (node) => ({ ...node, children: [...prefix.children, newline, ...node.children] }),
70 },
71 ],
72 });
73 };
74}