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
loga9dabbf100d1f6c0b2ed7b5e879d469ac14fd186
tree7ddf14add43dbe03a0d0f30617681570280677ad
parent8fc253605e6d203c3654dbb1ce22fa1fd6fa0767
signature Signed by PGP key B5690EEEBB952194

perf(web): reuse completed Markdown prefixes while streaming (#11193)


5 files changed, 258 insertions(+), 1 deletions(-)

apps/web/package.json+2
...@@ -59,6 +59,7 @@...@@ -59,6 +59,7 @@
59 "@types/babel__core": "^7.20.5",59 "@types/babel__core": "^7.20.5",
60 "@types/compression": "^1.8.1",60 "@types/compression": "^1.8.1",
61 "@types/culori": "^4.0.1",61 "@types/culori": "^4.0.1",
62 "@types/mdast": "^4.0.4",
62 "@types/react": "~19.2.14",63 "@types/react": "~19.2.14",
63 "@types/react-dom": "~19.2.3",64 "@types/react-dom": "~19.2.3",
64 "@types/react-test-renderer": "19.1.0",65 "@types/react-test-renderer": "19.1.0",
...@@ -68,6 +69,7 @@...@@ -68,6 +69,7 @@
68 "compression": "^1.8.1",69 "compression": "^1.8.1",
69 "react-test-renderer": "19.2.6",70 "react-test-renderer": "19.2.6",
70 "tailwindcss": "^4.0.0",71 "tailwindcss": "^4.0.0",
72 "unified": "^11.0.5",
71 "vite": "catalog:",73 "vite": "catalog:",
72 "vite-plus": "catalog:"74 "vite-plus": "catalog:"
73 }75 }
apps/web/src/components/ChatMarkdown.tsx+7-1
...@@ -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 { createIncrementalMarkdownPlugin } from "../markdown-incremental";
72import { defaultUrlTransform } from "react-markdown";73import { defaultUrlTransform } from "react-markdown";
73import rehypeRaw from "rehype-raw";74import rehypeRaw from "rehype-raw";
74import rehypeSanitize, { defaultSchema } from "rehype-sanitize";75import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
...@@ -3104,12 +3105,17 @@ function ChatMarkdown({...@@ -3104,12 +3105,17 @@ function ChatMarkdown({
3104 localMediaPreview,3105 localMediaPreview,
3105 setLocalMediaPreview,3106 setLocalMediaPreview,
3106 } = useChatMarkdownState({ text, ...props });3107 } = useChatMarkdownState({ text, ...props });
3108 const incrementalParsing =
3109 props.isStreaming === true &&
3110 extraRemarkPlugins.length === 0 &&
3111 /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text);
3107 const remarkPlugins = useMemo(3112 const remarkPlugins = useMemo(
3108 () => [3113 () => [
3109 ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),3114 ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
3110 ...extraRemarkPlugins,3115 ...extraRemarkPlugins,
3116 ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []),
3111 ],3117 ],
3112 [extraRemarkPlugins, lineBreaks],3118 [extraRemarkPlugins, incrementalParsing, lineBreaks],
3113 );3119 );
31143120
3115 // react-markdown converts unparsed HTML nodes to text when skipHtml is false.3121 // react-markdown converts unparsed HTML nodes to text when skipHtml is false.
apps/web/src/markdown-incremental.test.tsx created+136
...@@ -0,0 +1,136 @@
1import type { Root } from "mdast";
2import { renderToStaticMarkup } from "react-dom/server";
3import ReactMarkdown from "react-markdown";
4import rehypeRaw from "rehype-raw";
5import rehypeSanitize from "rehype-sanitize";
6import remarkGfm from "remark-gfm";
7import type { Plugin } from "unified";
8import { describe, expect, it } from "vite-plus/test";
9
10import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives";
11import { remarkGithubAlerts } from "./markdown-github-alerts";
12import { createIncrementalMarkdownPlugin } from "./markdown-incremental";
13import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation";
14
15function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) {
16 let tree: Root | undefined;
17 const observeParsing: Plugin<[], Root> = function () {
18 const original = this.parser;
19 if (original) {
20 this.parser = (text, file) => {
21 parsedSources?.push(text);
22 return original(text, file);
23 };
24 }
25 };
26 const capture: Plugin<[], Root> = () => (root) => {
27 tree = structuredClone(root);
28 };
29 const html = renderToStaticMarkup(
30 <ReactMarkdown
31 remarkPlugins={[
32 observeParsing,
33 capture,
34 remarkGfm,
35 remarkGithubAlerts,
36 remarkNormalizeListItemIndentation,
37 remarkCodexDirectives,
38 ...(incremental ? [incremental] : []),
39 ]}
40 rehypePlugins={[rehypeRaw, rehypeSanitize]}
41 >
42 {source}
43 </ReactMarkdown>,
44 );
45 return { html, tree };
46}
47
48const prefix = "# Before\n\n```ts\nconst values = [1, 2];\n```\n\n";
49
50describe("incremental Markdown parsing", () => {
51 it("keeps the document prefix cached when list recovery parses contain fences", () => {
52 const source =
53 prefix +
54 "- first block\n\n ```ts\n const nested = 1;\n ```\n\n tail";
55 const incremental = createIncrementalMarkdownPlugin();
56 const parsedSources: string[] = [];
57 expect(render(source, incremental, parsedSources)).toEqual(render(source));
58 parsedSources.length = 0;
59 const next = source + " more";
60 expect(render(next, incremental, parsedSources)).toEqual(render(next));
61 expect(parsedSources).not.toContain(next);
62 expect(parsedSources.some((text) => text.startsWith("t3-markdown-inline-prefix:"))).toBe(true);
63 });
64
65 it.each([
66 "a\n===\n\nb\n---\n",
67 "- first\n\n continued\n\n- next\n",
68 "> quoted\n>\n> ```js\n> abc\n> ```\n\nend",
69 "<div>\nhello\n\n</div>\n\nend",
70 "[ref]\n\n[ref]: /later",
71 "a[^x]\n\n[^x]: note",
72 "a | b\n--|--\na | b\n",
73 "```\na\n```\n\nnext\n\n~~~\nb\n~~~\n\nmore",
74 "\n\n\tcode\n\nmore",
75 "text <https://example.com> *bold*",
76 "> [!NOTE]\n> alert\n\n- [ ] task",
77 "\uFEFFtext after a byte-order mark",
78 ])("preserves the parse tree, positions, and HTML while streaming %j", (tail) => {
79 const source = prefix + tail;
80 const incremental = createIncrementalMarkdownPlugin();
81 for (let end = 0; end <= source.length; end++) {
82 const text = source.slice(0, end);
83 expect(render(text, incremental), `prefix ${end}`).toEqual(render(text));
84 }
85 });
86
87 it.each(["\r\n", "\r"])("preserves partial %j line endings", (newline) => {
88 const source = (prefix + "next\n\n```\nlast\n```\n\nend").replaceAll("\n", newline);
89 const incremental = createIncrementalMarkdownPlugin();
90 for (let end = 0; end <= source.length; end++) {
91 const text = source.slice(0, end);
92 expect(render(text, incremental)).toEqual(render(text));
93 }
94 });
95
96 it("updates earlier references when definitions arrive after the cached prefix", () => {
97 const before = "[later] and footnote[^note]\n\n" + prefix;
98 const incremental = createIncrementalMarkdownPlugin();
99 for (const tail of ["text", "[later]: /target", "[later]: /target\n\n[^note]: a note"]) {
100 expect(render(before + tail, incremental)).toEqual(render(before + tail));
101 }
102 });
103
104 it("handles edits, replacements, and repeated renders without leaking transformed nodes", () => {
105 const incremental = createIncrementalMarkdownPlugin();
106 const documents = [
107 prefix + "- first\n - second",
108 prefix + "> [!NOTE]\n> transformed alert",
109 prefix + "plain text",
110 "replacement without fences",
111 prefix.replace("Before", "Edited") + "edited prefix",
112 prefix + "plain text",
113 prefix + "plain text",
114 ];
115 for (const document of documents) {
116 expect(render(document, incremental)).toEqual(render(document));
117 }
118 });
119
120 it("does not freeze unclosed, nested, indented, or mismatched fences", () => {
121 const prefixes = [
122 "```\nopen\n\n",
123 "````\n```\n\n",
124 "> ```\n> code\n> ```\n\n",
125 "- ```\n code\n ```\n\n",
126 " ```\n code\n ```\n\n",
127 "<script>\n```\ncode\n```\n\n",
128 ];
129 for (const start of prefixes) {
130 const incremental = createIncrementalMarkdownPlugin();
131 for (const tail of ["", "text", "\n```\n", "\n```\n\nnext"]) {
132 expect(render(start + tail, incremental)).toEqual(render(start + tail));
133 }
134 }
135 });
136});
apps/web/src/markdown-incremental.ts created+107
...@@ -0,0 +1,107 @@
1import type { Root, RootContent } from "mdast";
2import type { Parser as UnifiedParser, Plugin } from "unified";
3
4type Parser = UnifiedParser<Root>;
5
6interface ParsedPrefix {
7 source: string;
8 offset: number;
9 line: number;
10 children: RootContent[];
11}
12
13function hasDefinitions(node: Root | RootContent): boolean {
14 return (
15 node.type === "definition" ||
16 node.type === "footnoteDefinition" ||
17 ("children" in node && node.children.some((child) => hasDefinitions(child)))
18 );
19}
20
21function shiftPositions(node: Root | RootContent, offset: number, lines: number): void {
22 if (node.position) {
23 for (const point of [node.position.start, node.position.end]) {
24 if (point.offset !== undefined) point.offset += offset;
25 point.line += lines;
26 }
27 }
28 if ("children" in node) {
29 for (const child of node.children) shiftPositions(child, offset, lines);
30 }
31}
32
33/** Keep the full document pipeline while avoiding parsing a completed code-heavy
34 * prefix on every token. A closed top-level fence followed by a blank line is a
35 * parsing boundary. Definitions are document-wide, so they require a full parse.
36 */
37function createIncrementalMarkdownParser(parse: Parser): Parser {
38 let cached: ParsedPrefix | undefined;
39
40 return (source, file) => {
41 // A streaming CR can become half of a CRLF. A BOM at the suffix boundary
42 // would be stripped by a new parser although it is inside the full document.
43 if (source.includes("\r") || source.includes("\uFEFF")) return parse(source, file);
44
45 const prefix = cached && source.startsWith(cached.source) ? cached : undefined;
46 let root: Root;
47 if (prefix) {
48 root = parse(source.slice(prefix.offset), file);
49 if (hasDefinitions(root)) return parse(source, file);
50 shiftPositions(root, prefix.offset, prefix.line - 1);
51 if (root.position) root.position.start = { line: 1, column: 1, offset: 0 };
52 // Remark transforms mutate their input. The cache owns pristine nodes and
53 // each render receives its own copy, including source positions.
54 root.children.unshift(...structuredClone(prefix.children));
55 } else {
56 root = parse(source, file);
57 if (hasDefinitions(root)) return root;
58 }
59
60 for (let index = root.children.length - 1; index >= 0; index--) {
61 const node = root.children[index];
62 if (node?.type !== "code") continue;
63 const start = node.position?.start.offset;
64 const end = node.position?.end.offset;
65 if (start === undefined || end === undefined) continue;
66 if (prefix && end < prefix.offset) break;
67 const value = source.slice(start, end);
68 const opening = /^ {0,3}(`{3,}|~{3,})[^\n]*\n/.exec(value)?.[1];
69 if (!opening) continue;
70 const lastLine = value.slice(value.lastIndexOf("\n") + 1);
71 const closing = new RegExp(`^ {0,3}${opening[0]}{${opening.length},}[ \\t]*$`);
72 const separator = /^\n[ \t]*\n/.exec(source.slice(end))?.[0];
73 if (!closing.test(lastLine) || !separator) continue;
74 const offset = end + separator.length;
75 cached = {
76 source: source.slice(0, offset),
77 offset,
78 line: node.position!.end.line + 2,
79 children: structuredClone(root.children.slice(0, index + 1)),
80 };
81 break;
82 }
83 return root;
84 };
85}
86
87/** One cache per streaming renderer. Extra syntax plugins must use the normal
88 * parser because their document-wide dependencies are not known here.
89 */
90export function createIncrementalMarkdownPlugin(): Plugin<[], Root> {
91 let parser: Parser | undefined;
92 return function () {
93 const original = this.parser;
94 if (!original) return;
95 parser ??= createIncrementalMarkdownParser((source, file) => original(source, file) as Root);
96 const parseDocument = parser;
97 // ReactMarkdown creates a processor per render. Its first parse is the
98 // document; transforms can then parse synthetic recovery text on that same
99 // processor. Those parses must not read or replace the document's cache.
100 let documentParsed = false;
101 this.parser = (source, file) => {
102 if (documentParsed) return original(source, file);
103 documentParsed = true;
104 return parseDocument(source, file);
105 };
106 };
107}
pnpm-lock.yaml+6
...@@ -707,6 +707,9 @@ importers:...@@ -707,6 +707,9 @@ importers:
707 '@types/culori':707 '@types/culori':
708 specifier: ^4.0.1708 specifier: ^4.0.1
709 version: 4.0.1709 version: 4.0.1
710 '@types/mdast':
711 specifier: ^4.0.4
712 version: 4.0.4
710 '@types/react':713 '@types/react':
711 specifier: ~19.2.14714 specifier: ~19.2.14
712 version: 19.2.16715 version: 19.2.16
...@@ -734,6 +737,9 @@ importers:...@@ -734,6 +737,9 @@ importers:
734 tailwindcss:737 tailwindcss:
735 specifier: 4.3.3738 specifier: 4.3.3
736 version: 4.3.3739 version: 4.3.3
740 unified:
741 specifier: ^11.0.5
742 version: 11.0.5
737 vite:743 vite:
738 specifier: npm:@voidzero-dev/vite-plus-core@0.3.0744 specifier: npm:@voidzero-dev/vite-plus-core@0.3.0
739 version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)'745 version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0)'