From d1bbee09c7fd5198ca74cbfbf2584087368bf8aa Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 5 Oct 2026 09:28:13 +0000 Subject: [PATCH 1/2] perf: keep a loose list whole in chunked streaming rows Do not let the last block (or the blank lines before it) start a new chunk, so a partial list marker cannot seal the list. Remount a chunk when it is sealed and when the stream ends, so Streamdown's position-keyed memo cannot keep a stale first list item. Refs #5664 --- .../ChunkedStreamingMarkdown.test.tsx | 76 ++++++++++++++++++- .../Messages/ChunkedStreamingMarkdown.tsx | 23 ++++-- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx b/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx index 1c0e1616a11..9f321a9fc2e 100644 --- a/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx +++ b/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx @@ -39,7 +39,7 @@ describe("MarkdownChunker", () => { expect(previous.length).toBeGreaterThan(10); }); - test("an open last block stays in the open chunk until a later block follows it", () => { + test("an open last block stays in the open chunk until two later blocks follow it", () => { const chunker = new MarkdownChunker(100); const intro = "Intro paragraph.\n\n"; const fence = "```ts\n" + "const x = 1;\n".repeat(30); @@ -48,10 +48,15 @@ describe("MarkdownChunker", () => { expect(chunks.at(-1)).toContain("```ts"); expect(chunks.at(-1)?.endsWith("const x = 1;\n")).toBe(true); - const closed = intro + fence + "```\n\nAfter the fence."; + // Closed and followed by one paragraph: that last block could still join the fence's + // block, so the fence stays open too. + chunks = chunker.update(intro + fence + "```\n\nAfter the fence."); + expect(chunks.at(-1)).toContain("```ts"); + + const closed = intro + fence + "```\n\nAfter the fence.\n\nMore."; chunks = chunker.update(closed); - // Closed and followed by a paragraph: the fence is sealed, the paragraph is open. - expect(chunks.at(-1)?.trim()).toBe("After the fence."); + // A second block follows: the fence is sealed, the paragraphs are open. + expect(chunks.at(-1)?.trim()).toBe("After the fence.\n\nMore."); expect(chunks.slice(0, -1).join("").trimEnd().endsWith("```")).toBe(true); expect(chunks.join("")).toBe(closed); }); @@ -125,4 +130,67 @@ describe("ChunkedStreamingMarkdown", () => { expect(expected.length).toBeGreaterThan(100); expect(chunked).toEqual(expected); }); + + // #5664: a loose list longer than a chunk, streamed item by item. Some frames end mid-marker + // (`…\n\n30` before the `.`), where the partial marker parses as a paragraph after the list. + test.each([ + ["ordered", (k: number) => `${k}.`], + ["bullet", () => "-"], + ])( + "a loose %s list streamed across chunks renders like one static render", + async (_kind, marker) => { + const items = Array.from( + { length: 60 }, + (_, i) => + `${marker(i + 1)} Item ${i + 1} with **bold** and more words, lorem ipsum dolor sit.` + ); + const reply = items.join("\n\n"); + const renderRow = (content: string, isStreaming: boolean) => + flushSync(() => + root?.render( + + + + ) + ); + let text = ""; + for (const item of items) { + if (text.length > 0) { + text += "\n\n"; + // Every partial marker is its own frame, then the whole item. + const space = item.indexOf(" "); + for (let end = 1; end <= space; end++) renderRow(text + item.slice(0, end), true); + } + text += item; + renderRow(text, true); + } + renderRow(reply, false); + for (let i = 0; i < 100 && !container.textContent?.includes("Item 1 with"); i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + // Structure, not just text: one list with a

in every item, exactly as one render. + const outline = (element: Element) => + [...element.querySelectorAll("ol, ul, li, li > p")].map( + (node) => `${node.tagName}${node.getAttribute("start") ?? ""}` + ); + const single = document.createElement("div"); + document.body.appendChild(single); + const singleRoot = createRoot(single); + flushSync(() => + singleRoot.render( + + + + ) + ); + const expected = outline(single); + const expectedText = single.textContent?.replace(/\s+/g, ""); + flushSync(() => singleRoot.unmount()); + + expect(expected.filter((tag) => tag === "LI")).toHaveLength(60); + expect(outline(container)).toEqual(expected); + expect(container.textContent?.replace(/\s+/g, "")).toBe(expectedText); + } + ); }); diff --git a/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx b/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx index 4441138b628..3735269e111 100644 --- a/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx +++ b/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx @@ -10,8 +10,9 @@ import { normalizeMarkdown } from "./MarkdownStyles"; /** * Splits growing markdown into chunks of whole Streamdown blocks, about `maxChars` each, and - * joins back to the input. A chunk is sealed once a later block follows it: the last block can - * still change (an open code fence, a growing list), so it always stays in the open last chunk. + * joins back to the input. A chunk is sealed once two later blocks follow it: the last block can + * still change (an open code fence, a growing list) or join the block before it, so both stay in + * the open last chunk. * Sealed chunks never change while the text only grows, and each update parses only the text * after them, so the cost of an update does not grow with the length of the reply. */ @@ -32,8 +33,14 @@ export class MarkdownChunker { } const groups: string[] = []; let current = ""; - for (const block of parseMarkdownIntoBlocks(text.slice(this.sealedText.length))) { - if (current.length > 0 && current.length + block.length > this.maxChars) { + const blocks = parseMarkdownIntoBlocks(text.slice(this.sealedText.length)); + for (const [index, block] of blocks.entries()) { + // The last block can still turn into part of the block before it: `…\n\n30` is a + // paragraph after a list until the `.` arrives (#5664). So neither the last block nor the + // blank lines before it start a new group; otherwise the list would be sealed and the + // next item would start a second list. + const startsGroup = index < blocks.length - 1 && block.trim().length > 0; + if (startsGroup && current.length > 0 && current.length + block.length > this.maxChars) { groups.push(current); current = ""; } @@ -103,7 +110,13 @@ export const ChunkedStreamingMarkdown: React.FC =

{chunks.slice(start).map((chunk, offset) => ( . A chunk therefore mounts fresh when it is sealed and when the + // stream ends (#5664). A sealed chunk keeps its key, so MarkdownCore's memo still skips it. + key={ + props.isStreaming && start + offset === lastIndex ? `open-${lastIndex}` : start + offset + } content={chunk} // Only the open last chunk can hold incomplete markdown. parseIncompleteMarkdown={props.isStreaming && start + offset === lastIndex} From c0f8c67ad1c26c64dc8904dcee1a04c448ad76bf Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 5 Oct 2026 09:41:49 +0000 Subject: [PATCH 2/2] perf: hold back only a partial list marker tail, not every last block A closed oversized block followed by a growing paragraph is sealed again, so the paragraph renders synchronously (Codex review). --- .../ChunkedStreamingMarkdown.test.tsx | 13 +++------- .../Messages/ChunkedStreamingMarkdown.tsx | 25 ++++++++++++------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx b/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx index 9f321a9fc2e..b5d35f04558 100644 --- a/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx +++ b/src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx @@ -39,7 +39,7 @@ describe("MarkdownChunker", () => { expect(previous.length).toBeGreaterThan(10); }); - test("an open last block stays in the open chunk until two later blocks follow it", () => { + test("an open last block stays in the open chunk until a later block follows it", () => { const chunker = new MarkdownChunker(100); const intro = "Intro paragraph.\n\n"; const fence = "```ts\n" + "const x = 1;\n".repeat(30); @@ -48,15 +48,10 @@ describe("MarkdownChunker", () => { expect(chunks.at(-1)).toContain("```ts"); expect(chunks.at(-1)?.endsWith("const x = 1;\n")).toBe(true); - // Closed and followed by one paragraph: that last block could still join the fence's - // block, so the fence stays open too. - chunks = chunker.update(intro + fence + "```\n\nAfter the fence."); - expect(chunks.at(-1)).toContain("```ts"); - - const closed = intro + fence + "```\n\nAfter the fence.\n\nMore."; + const closed = intro + fence + "```\n\nAfter the fence."; chunks = chunker.update(closed); - // A second block follows: the fence is sealed, the paragraphs are open. - expect(chunks.at(-1)?.trim()).toBe("After the fence.\n\nMore."); + // Closed and followed by a paragraph: the fence is sealed, the paragraph is open. + expect(chunks.at(-1)?.trim()).toBe("After the fence."); expect(chunks.slice(0, -1).join("").trimEnd().endsWith("```")).toBe(true); expect(chunks.join("")).toBe(closed); }); diff --git a/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx b/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx index 3735269e111..2dc083b8a0d 100644 --- a/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx +++ b/src/browser/features/Messages/ChunkedStreamingMarkdown.tsx @@ -8,11 +8,13 @@ import { import { MarkdownCore } from "./MarkdownCore"; import { normalizeMarkdown } from "./MarkdownStyles"; +// Blank lines, or the digits of an ordered list marker before its `.` or `)` arrives. +const PARTIAL_LIST_MARKER = /^\d{0,9}$/; + /** * Splits growing markdown into chunks of whole Streamdown blocks, about `maxChars` each, and - * joins back to the input. A chunk is sealed once two later blocks follow it: the last block can - * still change (an open code fence, a growing list) or join the block before it, so both stay in - * the open last chunk. + * joins back to the input. A chunk is sealed once a later block follows it: the last block can + * still change (an open code fence, a growing list), so it always stays in the open last chunk. * Sealed chunks never change while the text only grows, and each update parses only the text * after them, so the cost of an update does not grow with the length of the reply. */ @@ -34,13 +36,18 @@ export class MarkdownChunker { const groups: string[] = []; let current = ""; const blocks = parseMarkdownIntoBlocks(text.slice(this.sealedText.length)); + // A tail that is only a partial list marker (`…\n\n30` before its `.`) parses as a paragraph + // after the list, but joins the list once the marker is complete (#5664). So that tail and + // the blank lines before it never start a group: sealing the list there would make the next + // item start a second list. + let tailStart = blocks.length; + while (tailStart > 0 && PARTIAL_LIST_MARKER.test(blocks[tailStart - 1].trim())) tailStart--; for (const [index, block] of blocks.entries()) { - // The last block can still turn into part of the block before it: `…\n\n30` is a - // paragraph after a list until the `.` arrives (#5664). So neither the last block nor the - // blank lines before it start a new group; otherwise the list would be sealed and the - // next item would start a second list. - const startsGroup = index < blocks.length - 1 && block.trim().length > 0; - if (startsGroup && current.length > 0 && current.length + block.length > this.maxChars) { + if ( + index < tailStart && + current.length > 0 && + current.length + block.length > this.maxChars + ) { groups.push(current); current = ""; }