Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/browser/features/Messages/ChunkedStreamingMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,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(
<ThemeProvider forcedTheme="dark">
<ChunkedStreamingMarkdown content={content} isStreaming={isStreaming} />
</ThemeProvider>
)
);
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 <p> 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(
<ThemeProvider forcedTheme="dark">
<MarkdownCore content={reply} />
</ThemeProvider>
)
);
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);
}
);
});
26 changes: 23 additions & 3 deletions src/browser/features/Messages/ChunkedStreamingMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ 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 a later block follows it: the last block can
Expand All @@ -32,8 +35,19 @@ 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));
// 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()) {
if (
index < tailStart &&
current.length > 0 &&
current.length + block.length > this.maxChars
) {
groups.push(current);
current = "";
}
Expand Down Expand Up @@ -103,7 +117,13 @@ export const ChunkedStreamingMarkdown: React.FC<ChunkedStreamingMarkdownProps> =
<div className="space-y-2">
{chunks.slice(start).map((chunk, offset) => (
<MarkdownCore
key={start + offset}
// Streamdown memoizes each element by its source position, so an element whose
// position did not change keeps a stale render: the first item of a list that turns
// loose keeps no <p>. 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}
Expand Down
Loading