Split streaming markdown into memoized blocks for mobile perf (#260)

Instead of re-parsing the entire markdown string on every streaming
token, split at paragraph boundaries (double newlines) while keeping
code fences intact. Each block renders as its own React.memo'd
<Markdown> component so only the last active block re-renders.
This commit is contained in:
Mohamed Boudra
2026-04-12 10:23:49 +07:00
committed by GitHub
parent fb5e2608e0
commit f35b0b8b7d
3 changed files with 178 additions and 9 deletions
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { splitMarkdownBlocks } from "../split-markdown-blocks";
describe("splitMarkdownBlocks", () => {
it("returns a single block for a single paragraph", () => {
expect(splitMarkdownBlocks("Hello world")).toEqual(["Hello world"]);
});
it("splits two paragraphs separated by a double newline", () => {
expect(splitMarkdownBlocks("First paragraph\n\nSecond paragraph")).toEqual([
"First paragraph",
"Second paragraph",
]);
});
it("keeps a fenced code block with internal double newlines as one block", () => {
expect(splitMarkdownBlocks("```ts\nconst a = 1;\n\nconst b = 2;\n```")).toEqual([
"```ts\nconst a = 1;\n\nconst b = 2;\n```",
]);
});
it("does not treat 4-space-indented backticks as a fence", () => {
expect(splitMarkdownBlocks("Before\n\n ```\n code\n ```\n\nAfter")).toEqual([
"Before",
" ```\n code\n ```",
"After",
]);
});
it("handles tilde fences", () => {
expect(splitMarkdownBlocks("Before\n\n~~~\ncode\n~~~\n\nAfter")).toEqual([
"Before",
"~~~\ncode\n~~~",
"After",
]);
});
it("splits mixed paragraph, code fence, and paragraph content into three blocks", () => {
expect(
splitMarkdownBlocks("Intro paragraph\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro paragraph"),
).toEqual([
"Intro paragraph",
"```ts\nconst a = 1;\n\nconst b = 2;\n```",
"Outro paragraph",
]);
});
it("keeps everything from an unclosed fence start as one block for streaming content", () => {
expect(
splitMarkdownBlocks("Before fence\n\n```ts\nconst a = 1;\n\nconst b = 2;"),
).toEqual(["Before fence", "```ts\nconst a = 1;\n\nconst b = 2;"]);
});
it("returns an empty array for empty input", () => {
expect(splitMarkdownBlocks("")).toEqual([]);
});
it("splits a heading followed by a paragraph into two blocks", () => {
expect(splitMarkdownBlocks("# Heading\n\nParagraph text")).toEqual([
"# Heading",
"Paragraph text",
]);
});
it("keeps consecutive list items together when there is no double newline", () => {
expect(splitMarkdownBlocks("- First item\n- Second item\n- Third item")).toEqual([
"- First item\n- Second item\n- Third item",
]);
});
it("treats triple newlines as a split point and filters empty blocks", () => {
expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([
"First paragraph",
"Second paragraph",
]);
});
});
@@ -0,0 +1,60 @@
function getFenceDelimiter(line: string) {
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line);
return match?.[2] ?? null;
}
export function splitMarkdownBlocks(text: string): string[] {
if (text.length === 0) {
return [];
}
const blocks: string[] = [];
let currentLines: string[] = [];
let activeFenceCharacter: "`" | "~" | null = null;
let activeFenceLength = 0;
let sawBlockSeparator = false;
for (const line of text.split("\n")) {
const isBlankLine = line.trim().length === 0;
if (!activeFenceCharacter && isBlankLine) {
if (currentLines.length > 0) {
sawBlockSeparator = true;
}
continue;
}
if (!activeFenceCharacter && sawBlockSeparator) {
blocks.push(currentLines.join("\n"));
currentLines = [];
sawBlockSeparator = false;
}
currentLines.push(line);
const fenceDelimiter = getFenceDelimiter(line);
if (!fenceDelimiter) {
continue;
}
if (!activeFenceCharacter) {
activeFenceCharacter = fenceDelimiter[0] as "`" | "~";
activeFenceLength = fenceDelimiter.length;
continue;
}
if (
fenceDelimiter[0] === activeFenceCharacter &&
fenceDelimiter.length >= activeFenceLength
) {
activeFenceCharacter = null;
activeFenceLength = 0;
}
}
if (currentLines.length > 0) {
blocks.push(currentLines.join("\n"));
}
return blocks.filter((block) => block.length > 0);
}