From 1a8ac63aa3ec2365a77b06a15d65d955ec93bd76 Mon Sep 17 00:00:00 2001
From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Date: Thu, 10 Sep 2026 21:44:15 -0700
Subject: [PATCH 1/3] perf(web): reuse completed Markdown prefixes while
streaming
---
apps/web/package.json | 2 +
apps/web/src/components/ChatMarkdown.tsx | 8 +-
apps/web/src/markdown-incremental.test.tsx | 112 +++++++++++++++++++++
apps/web/src/markdown-incremental.ts | 98 ++++++++++++++++++
pnpm-lock.yaml | 6 ++
5 files changed, 225 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/markdown-incremental.test.tsx
create mode 100644 apps/web/src/markdown-incremental.ts
diff --git a/apps/web/package.json b/apps/web/package.json
index c2ce73b42b11..069a222ca122 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -59,6 +59,7 @@
"@types/babel__core": "^7.20.5",
"@types/compression": "^1.8.1",
"@types/culori": "^4.0.1",
+ "@types/mdast": "^4.0.4",
"@types/react": "~19.2.14",
"@types/react-dom": "~19.2.3",
"@types/react-test-renderer": "19.1.0",
@@ -68,6 +69,7 @@
"compression": "^1.8.1",
"react-test-renderer": "19.2.6",
"tailwindcss": "^4.0.0",
+ "unified": "^11.0.5",
"vite": "catalog:",
"vite-plus": "catalog:"
}
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 192abaf385f7..88d34b8d58bc 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -69,6 +69,7 @@ import React, {
} from "react";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
import ReactMarkdown from "react-markdown";
+import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
import { defaultUrlTransform } from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
@@ -3104,12 +3105,17 @@ function ChatMarkdown({
localMediaPreview,
setLocalMediaPreview,
} = useChatMarkdownState({ text, ...props });
+ const incrementalParsing =
+ props.isStreaming === true &&
+ extraRemarkPlugins.length === 0 &&
+ /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text);
const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
...extraRemarkPlugins,
+ ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []),
],
- [extraRemarkPlugins, lineBreaks],
+ [extraRemarkPlugins, incrementalParsing, lineBreaks],
);
// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx
new file mode 100644
index 000000000000..9611e60cbb17
--- /dev/null
+++ b/apps/web/src/markdown-incremental.test.tsx
@@ -0,0 +1,112 @@
+import type { Root } from "mdast";
+import { renderToStaticMarkup } from "react-dom/server";
+import ReactMarkdown from "react-markdown";
+import rehypeRaw from "rehype-raw";
+import rehypeSanitize from "rehype-sanitize";
+import remarkGfm from "remark-gfm";
+import type { Plugin } from "unified";
+import { describe, expect, it } from "vite-plus/test";
+
+import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives";
+import { remarkGithubAlerts } from "./markdown-github-alerts";
+import { createIncrementalMarkdownPlugin } from "./markdown-incremental";
+import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation";
+
+function render(source: string, incremental?: Plugin<[], Root>) {
+ let tree: Root | undefined;
+ const capture: Plugin<[], Root> = () => (root) => {
+ tree = structuredClone(root);
+ };
+ const html = renderToStaticMarkup(
+