From f92e642c1a7a97c1a353efc0b127b4ad5cb8850b Mon Sep 17 00:00:00 2001 From: Willem Date: Mon, 5 Oct 2026 20:39:28 +0200 Subject: [PATCH 1/4] perf(markdown): parse a streaming reply in settled chunks (#1475) The streaming assistant message was re-parsed as a whole on every push, O(n^2) over one long answer. Split it at blank lines before column-0 blocks outside fences; each settled chunk is a memoized parse, so a push re-parses only the last chunk. Lists that continue across blank lines, raw-HTML-only chunks, link/footnote definitions and blank-spanning HTML blocks don't split. Non-streaming messages stay one parse. Bench (jsdom, 45 KB at 4 pushes/s): 4.7 s -> 0.3 s total render; per push at 45 KB 83-89 ms -> 3.6-4.5 ms. Co-Authored-By: Claude Opus 5.5 --- src/components/Markdown.tsx | 139 ++++++++++++------- src/components/markdownChunks.ts | 73 ++++++++++ src/components/thread/messages.tsx | 2 +- test/markdownStream.test.tsx | 209 +++++++++++++++++++++++++++++ test/perf/markdownStream.bench.tsx | 97 +++++++++++++ 5 files changed, 471 insertions(+), 49 deletions(-) create mode 100644 src/components/markdownChunks.ts create mode 100644 test/markdownStream.test.tsx create mode 100644 test/perf/markdownStream.bench.tsx diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index f6ab7a5f1..64ef30871 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,4 +1,5 @@ import { + Fragment, isValidElement, memo, useContext, @@ -8,9 +9,13 @@ import { useState, type ReactNode, } from "react"; -import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import ReactMarkdown, { + defaultUrlTransform, + type Components, +} from "react-markdown"; import remarkGfm from "remark-gfm"; import { isAbsolutePath } from "../pathLinks"; +import { markdownChunks } from "./markdownChunks"; import { linkifyNode, PathLinkContext, useResolvedMap } from "./PathLinks"; import styles from "./Markdown.module.css"; @@ -154,18 +159,16 @@ function CodeBlock({ children }: { children?: ReactNode }) { /** * Hold `text` steady between parses. * - * A streaming message pushes new text several times a second and ReactMarkdown - * re-parses ALL of it every time, so a run costs O(n^2) parse work on the - * renderer's main thread: 50KB of markdown is ~35ms per parse, which is what - * users feel as typing lag while an agent writes. Waiting `lastCost * 6` in - * between caps that at roughly a sixth of the main thread whatever the message - * length, and the trailing timer means the final text always lands. + * A streaming message pushes new text several times a second. A whole-text + * parse of 50KB of markdown is ~35ms, which is what users feel as typing lag + * while an agent writes. Waiting `lastCost * 6` in between caps that at + * roughly a sixth of the main thread, and the trailing timer means the final + * text always lands. Since #1475 the streaming message re-parses only its last + * chunk, so lastCost stays small and this mostly sits at the 60ms floor; it + * still guards a reply that falls back to one chunk (see markdownChunks). * * The first change after mount is not delayed, so a reply still starts drawing * the moment its first chunk arrives. - * - * ponytail: the streaming tail lags by up to one interval. Parse only the - * settled prefix incrementally if that ever reads as stutter. */ function useThrottledText(text: string, lastCost: { current: number }): string { const [shown, setShown] = useState(text); @@ -183,15 +186,90 @@ function useThrottledText(text: string, lastCost: { current: number }): string { return shown; } +const REMARK_PLUGINS = [remarkGfm]; + +const COMPONENTS: Components = { + pre: (props) => {props.children}, + code: (props) => ( + {linkifyNode(props.children)} + ), + p: (props) =>

{linkifyNode(props.children)}

, + li: (props) =>
  • {linkifyNode(props.children)}
  • , + td: (props) => {linkifyNode(props.children)}, + th: (props) => {linkifyNode(props.children)}, + h1: (props) =>

    {linkifyNode(props.children)}

    , + h2: (props) =>

    {linkifyNode(props.children)}

    , + h3: (props) =>

    {linkifyNode(props.children)}

    , + h4: (props) =>

    {linkifyNode(props.children)}

    , + blockquote: (props) =>
    {linkifyNode(props.children)}
    , + a: (props) => ( + + {props.children} + + ), + img: (props) => , +}; + +/** Test hook: how many times a markdown chunk has been parsed. */ +export const markdownParses = { count: 0 }; + +/** One parse. memo: a settled chunk of a streaming reply keeps its text. */ +const MarkdownChunk = memo(function MarkdownChunk({ text }: { text: string }) { + markdownParses.count++; + return ( + + {text} + + ); +}); + +/** + * The parse, unthrottled. A streaming reply is split at settled block + * boundaries (see markdownChunks) so each push re-parses only the last chunk + * instead of the whole reply. Every other message is one parse, as before. + * + * The "\n" between chunks is the text node react-markdown puts between + * top-level blocks, so the DOM matches a one-shot parse exactly. + */ +export function MarkdownBody({ + text, + streaming = false, +}: { + text: string; + streaming?: boolean; +}) { + const chunks = streaming ? markdownChunks(text) : [text]; + return ( +
    + {chunks.map((chunk, i) => ( + + {i > 0 && "\n"} + + + ))} +
    + ); +} + /** * Assistant-message markdown. react-markdown renders to React elements (no * dangerouslySetInnerHTML), so raw HTML in agent output is dropped, not * executed. * * memo: parsing is the expensive part of a streamed update, and only the - * message being written has new text. + * message being written has new text. `streaming` marks that message. */ -export const Markdown = memo(function Markdown({ text }: { text: string }) { +export const Markdown = memo(function Markdown({ + text, + streaming = false, +}: { + text: string; + streaming?: boolean; +}) { // Measured across this subtree's render + commit, i.e. the parse we are // pacing. Declared before the throttle so its effect runs first. const lastCost = useRef(0); @@ -200,40 +278,5 @@ export const Markdown = memo(function Markdown({ text }: { text: string }) { lastCost.current = performance.now() - started; }); const shown = useThrottledText(text, lastCost); - - return ( -
    - {props.children}, - code: (props) => ( - - {linkifyNode(props.children)} - - ), - p: (props) =>

    {linkifyNode(props.children)}

    , - li: (props) =>
  • {linkifyNode(props.children)}
  • , - td: (props) => {linkifyNode(props.children)}, - th: (props) => {linkifyNode(props.children)}, - h1: (props) =>

    {linkifyNode(props.children)}

    , - h2: (props) =>

    {linkifyNode(props.children)}

    , - h3: (props) =>

    {linkifyNode(props.children)}

    , - h4: (props) =>

    {linkifyNode(props.children)}

    , - blockquote: (props) => ( -
    {linkifyNode(props.children)}
    - ), - a: (props) => ( - - {props.children} - - ), - img: (props) => , - }} - > - {shown} -
    -
    - ); + return ; }); diff --git a/src/components/markdownChunks.ts b/src/components/markdownChunks.ts new file mode 100644 index 000000000..e257e5142 --- /dev/null +++ b/src/components/markdownChunks.ts @@ -0,0 +1,73 @@ +/** + * Split streaming markdown into independently parseable chunks (#1475). + * + * A chunk boundary is a line at column 0 that follows a blank line, outside + * any fenced code block. At such a line every container has closed: a blank + * line ends paragraphs, tables, blockquotes and HTML blocks, and an unindented + * line after it ends list items and indented code. So parsing each chunk on + * its own yields the same blocks as parsing the whole text. Everything before + * the last boundary is settled; only the last chunk still grows. + * + * When unsure, don't split: + * - a list marker never starts a chunk if the current chunk already has one + * (`- a\n\n- b` is ONE loose list); + * - a line starting with `<` never starts a chunk, and a chunk that starts + * with `<` never ends: a chunk of only raw HTML renders nothing, which would + * leave a stray separator; + * - the whole text stays one chunk if it has a link reference or footnote + * definition (it can resolve `[x]` in ANY chunk, even earlier ones) or an + * HTML block that may span blank lines (`", "", "B."].join("\n"), +}; + +const html = (text: string, streaming: boolean) => + renderToStaticMarkup(); + +describe("streaming markdown chunks", () => { + for (const [name, text] of Object.entries(CORPUS)) { + it(`${name}: every char-by-char prefix renders like one parse`, () => { + for (let n = 1; n <= text.length; n++) { + const prefix = text.slice(0, n); + assert.equal(html(prefix, true), html(prefix, false), `prefix ${n}`); + } + }); + } + + it("splits at blank lines before unindented blocks", () => { + assert.deepEqual(markdownChunks("# H\n\npara\n\n| a |\n|---|\n"), [ + "# H\n\n", + "para\n\n", + "| a |\n|---|\n", + ]); + }); + + it("never splits inside an open fence, whatever its marker", () => { + assert.deepEqual(markdownChunks("```js x\na\n\nb"), ["```js x\na\n\nb"]); + assert.deepEqual(markdownChunks("~~~\n```\n\nb\n~~~\n\nc"), [ + "~~~\n```\n\nb\n~~~\n\n", + "c", + ]); + // A shorter closing run doesn't close a longer fence. + assert.equal(markdownChunks("````\n```\n\nb").length, 1); + // A closing fence can't carry an info string. + assert.equal(markdownChunks("```\n``` js\n\nb").length, 1); + }); + + it("keeps a list that continues across blank lines in one chunk", () => { + assert.deepEqual(markdownChunks("- a\n\n- b\n\n1. c\n\nend"), [ + "- a\n\n- b\n\n1. c\n\n", + "end", + ]); + assert.deepEqual(markdownChunks("para\n\n- a"), ["para\n\n", "- a"]); + }); + + it("keeps indented continuations with their block", () => { + assert.deepEqual(markdownChunks("- a\n\n more\n\n code\n\nx"), [ + "- a\n\n more\n\n code\n\n", + "x", + ]); + }); + + it("falls back to one chunk for definitions and spanning HTML", () => { + for (const text of [ + "a [x]\n\nb\n\n[x]: /u", + "a[^1]\n\nb\n\n[^1]: note", + "> [x]: /u\n\nb", + "a\n\n", + "a\n\n
    \n\nb\n
    ", + ]) { + assert.deepEqual(markdownChunks(text), [text], text); + } + }); + + it("never starts or ends a chunk on a raw HTML line", () => { + assert.deepEqual(markdownChunks("a\n\n
    \n\nb"), ["a\n\n
    \n\n", "b"]); + assert.deepEqual(markdownChunks("
    \n\nb\n\nc"), ["
    \n\nb\n\nc"]); + }); + + it("final DOM after a chunked stream equals a one-shot mount", async () => { + const text = Object.values(CORPUS).slice(0, 3).join("\n\n"); + const one = await mount(); + const m = await mount(); + for (let n = 37; n < text.length; n += 37) { + await m.rerender(); + } + await m.rerender(); + assert.equal(m.html(), one.html(), "streaming state"); + await m.rerender(); + assert.equal(m.html(), one.html(), "after the stream ends"); + }); + + it("re-parses only the tail when settled chunks don't change", async () => { + const head = "# Head\n\nSettled paragraph.\n\n"; + const m = await mount(); + const before = markdownParses.count; + await m.rerender(); + assert.equal(markdownParses.count - before, 1, "one parse: the tail"); + assert.match(m.text(), /tail grows/); + }); + + it("non-streaming text is one parse", async () => { + const before = markdownParses.count; + await mount(); + assert.equal(markdownParses.count - before, 1); + }); +}); diff --git a/test/perf/markdownStream.bench.tsx b/test/perf/markdownStream.bench.tsx new file mode 100644 index 000000000..4e5e6c50f --- /dev/null +++ b/test/perf/markdownStream.bench.tsx @@ -0,0 +1,97 @@ +/** + * Streaming-markdown cost benchmark (#1475, finding 4). + * + * Streams a ~45 KB reply into MarkdownBody the way PM2's fake agent did (~45 KB + * per 25 s, delivered as `thread:updated` at 4 pushes/s, so ~450 chars a push) + * and times each synchronous render + commit in jsdom. Unthrottled on purpose: this is the cost of one push, the thing the + * throttle in Markdown paces. Compares one-shot parsing (every non-streaming + * message, and every push before #1475) with the chunked streaming path. + * + * Run (one mode per process, jsdom keeps a lot alive): + * node --import=./test/support/render.mjs test/perf/markdownStream.bench.tsx one-shot + * node --import=./test/support/render.mjs test/perf/markdownStream.bench.tsx chunked + */ +import { JSDOM } from "jsdom"; + +const dom = new JSDOM(""); +const g = globalThis as unknown as Record; +g.window = dom.window; +g.document = dom.window.document; +g.IS_REACT_ACT_ENVIRONMENT = true; +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { MarkdownBody } = await import("../../src/components/Markdown"); + +function section(i: number): string { + return [ + `## Step ${i}: update the \`worker-${i}\` module`, + "", + `This paragraph explains change ${i} in some detail, with a [link](https://example.com/${i}), **bold**, _emphasis_ and \`inline code\`. It wraps over a couple of sentences so paragraphs look like a real answer.`, + "", + "- first point about the change", + "- second point, with `src/components/Markdown.tsx`", + " continued on an indented line", + "- third point", + "", + "```ts", + `export function step${i}(input: string): string {`, + " const parts = input.split(\"\\n\");", + "", + " return parts.map((p) => p.trim()).join(\" \");", + "}", + "```", + "", + "| file | change | lines |", + "|---|---|---|", + `| a${i}.ts | added | ${i * 3} |`, + `| b${i}.ts | removed | ${i * 2} |`, + "", + "> Note: this is a quoted remark about the step.", + "", + "1. ordered one", + "2. ordered two", + "", + ].join("\n"); +} + +let reply = "# Plan\n\n"; +for (let i = 1; reply.length < 45_000; i++) reply += section(i); +reply = reply.slice(0, 45_000); + +const CHUNK = 450; +const MARKS = [5_000, 20_000, 45_000]; + +function run(streaming: boolean) { + const container = dom.window.document.createElement("div"); + const root = createRoot(container); + const costs: Array<[number, number]> = []; + let total = 0; + for (let n = CHUNK; ; n += CHUNK) { + const text = reply.slice(0, Math.min(n, reply.length)); + const t0 = performance.now(); + act(() => root.render()); + const ms = performance.now() - t0; + total += ms; + costs.push([text.length, ms]); + if (text.length === reply.length) break; + } + act(() => root.unmount()); + // Median of the 5 pushes ending at each mark: one push alone is noisy. + const at = MARKS.map((mark) => { + const near = costs + .filter(([len]) => len <= mark && len > mark - CHUNK * 5) + .map(([, ms]) => ms) + .sort((a, b) => a - b); + return near[near.length >> 1]; + }); + return { total, at, pushes: costs.length }; +} + +const streaming = process.argv[2] === "chunked"; +run(streaming); // warm-up: JIT +const r = run(streaming); +console.log( + `${streaming ? "chunked " : "one-shot"} pushes=${r.pushes} ` + + MARKS.map((m, i) => `${m / 1000}KB=${r.at[i].toFixed(2)}ms`).join(" ") + + ` total=${(r.total / 1000).toFixed(2)}s`, +); From 5514c637c487c5d42815f1debec80529f55bde9e Mon Sep 17 00:00:00 2001 From: Willem Date: Mon, 5 Oct 2026 20:54:19 +0200 Subject: [PATCH 2/4] fix(markdown): don't split streaming text around raw HTML or after indented code (#1475) A fuzz pass found two mismatches with the one-shot render: - an HTML block starting mid-chunk swallowed a fence line, desyncing the fence tracker. Any line starting with raw HTML now keeps one parse (3 of 600 sampled real replies have one). - a boundary right after indented code (tab or 4+ spaces) mis-parsed a following non-1 ordered item. No boundary after an indented line now. Adds a seeded 10k-mix fuzz test comparing chunked vs one-shot markup. Co-Authored-By: Claude Opus 5.5 --- src/components/markdownChunks.ts | 25 +++++++++++--------- test/markdownStream.test.tsx | 40 ++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/components/markdownChunks.ts b/src/components/markdownChunks.ts index e257e5142..2bf432c54 100644 --- a/src/components/markdownChunks.ts +++ b/src/components/markdownChunks.ts @@ -11,12 +11,14 @@ * When unsure, don't split: * - a list marker never starts a chunk if the current chunk already has one * (`- a\n\n- b` is ONE loose list); - * - a line starting with `<` never starts a chunk, and a chunk that starts - * with `<` never ends: a chunk of only raw HTML renders nothing, which would - * leave a stray separator; + * - never split right after indented code (a tab or 4+ spaces): an indented + * last line may belong to a container the scan doesn't model, and what + * follows (`3. z`) parses differently there; * - the whole text stays one chunk if it has a link reference or footnote - * definition (it can resolve `[x]` in ANY chunk, even earlier ones) or an - * HTML block that may span blank lines (`", "a\n\n
    \n\nb\n
    ", + "a\n\n
    \n\nb", + "
    \n\nb\n\nc", + "> q\n
    \n```js\nx\n\ny\n```\n\nz", ]) { assert.deepEqual(markdownChunks(text), [text], text); } }); - it("never starts or ends a chunk on a raw HTML line", () => { - assert.deepEqual(markdownChunks("a\n\n
    \n\nb"), ["a\n\n
    \n\n", "b"]); - assert.deepEqual(markdownChunks("
    \n\nb\n\nc"), ["
    \n\nb\n\nc"]); + it("random block mixes render like one parse (seeded fuzz)", () => { + const blocks = [ + "para one\nlazy line", "Setext\n===", "---", "- a\n- b", "- a\n\n cont", + "1. x\n2. y", "3. z", " - nested", "> quote\nlazy", "> q\n>\n> - li", + "```js\nx\n\ny\n```", "~~~\n```\n~~~", " indented\n\n code", + "| a | b |\n|---|---|\n| 1 | 2 |", "
    \nhtml\n
    ", "", + "text x", "* star", "+ plus", "Term\n: def", "[x]", " indented para", + "\t- tab list", "***", "# H", "line \nbreak", "```\nunclosed", + ]; + let seed = 7; + const rand = (n: number) => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed % n; + }; + const seps = ["\n", "\n\n", "\n\n\n"]; + for (let i = 0; i < 10_000; i++) { + let text = ""; + for (let k = 2 + rand(6); k > 0; k--) { + text += (text ? seps[rand(3)] : "") + blocks[rand(blocks.length)]; + } + assert.equal(html(text, true), html(text, false), JSON.stringify(text)); + } }); it("final DOM after a chunked stream equals a one-shot mount", async () => { From 9d9bcc0212d379ae960be70bf4284007d58ecf37 Mon Sep 17 00:00:00 2001 From: Willem Date: Mon, 5 Oct 2026 20:59:16 +0200 Subject: [PATCH 3/4] test(markdown): 6k fuzz mixes so the file fits the 20 s timeout under load Co-Authored-By: Claude Opus 5.5 --- test/markdownStream.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/markdownStream.test.tsx b/test/markdownStream.test.tsx index 017990a00..676f0b956 100644 --- a/test/markdownStream.test.tsx +++ b/test/markdownStream.test.tsx @@ -200,7 +200,7 @@ describe("streaming markdown chunks", () => { return seed % n; }; const seps = ["\n", "\n\n", "\n\n\n"]; - for (let i = 0; i < 10_000; i++) { + for (let i = 0; i < 6_000; i++) { let text = ""; for (let k = 2 + rand(6); k > 0; k--) { text += (text ? seps[rand(3)] : "") + blocks[rand(blocks.length)]; From b77d50956cad9a10a93d95eb49cf0892c3ce0e0e Mon Sep 17 00:00:00 2001 From: Willem Date: Mon, 5 Oct 2026 21:33:39 +0200 Subject: [PATCH 4/4] test(markdown): 1.5k fuzz mixes so the file fits Windows CI's 20 s limit (#1475) Co-Authored-By: Claude Opus 5.5 --- test/markdownStream.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/markdownStream.test.tsx b/test/markdownStream.test.tsx index 676f0b956..e91cd4efe 100644 --- a/test/markdownStream.test.tsx +++ b/test/markdownStream.test.tsx @@ -200,7 +200,10 @@ describe("streaming markdown chunks", () => { return seed % n; }; const seps = ["\n", "\n\n", "\n\n\n"]; - for (let i = 0; i < 6_000; i++) { + // ponytail: 1,500 keeps the file under the 20 s per-file limit on Windows + // CI (6,000 took ~8 s locally, >20 s there). 24k mixes over 3 seeds ran + // clean offline before merge; raise this locally when changing the splitter. + for (let i = 0; i < 1_500; i++) { let text = ""; for (let k = 2 + rand(6); k > 0; k--) { text += (text ? seps[rand(3)] : "") + blocks[rand(blocks.length)];