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
+41 -9
View File
@@ -9,6 +9,7 @@ import {
ViewStyle,
Platform,
} from "react-native";
import * as React from "react";
import {
useState,
useEffect,
@@ -23,7 +24,7 @@ import {
cloneElement,
} from "react";
import type { ReactNode, ComponentType } from "react";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
@@ -72,6 +73,7 @@ import {
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
@@ -712,6 +714,28 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
},
}));
interface MemoizedMarkdownBlockProps {
text: string;
styles: ReturnType<typeof createMarkdownStyles>;
rules: RenderRules;
parser: MarkdownIt;
onLinkPress: (url: string) => boolean;
}
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
text,
styles,
rules,
parser,
onLinkPress,
}: MemoizedMarkdownBlockProps) {
return (
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}>
{text}
</Markdown>
);
});
export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp,
@@ -895,6 +919,8 @@ export const AssistantMessage = memo(function AssistantMessage({
};
}, [handleLinkPress, markdownParser, onInlinePathPress]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
return (
<View
testID="assistant-message"
@@ -903,14 +929,20 @@ export const AssistantMessage = memo(function AssistantMessage({
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
]}
>
<Markdown
style={markdownStyles}
rules={markdownRules}
markdownit={markdownParser}
onLinkPress={handleLinkPress}
>
{message}
</Markdown>
{blocks.map((block, index) => (
<View
key={index}
style={index < blocks.length - 1 ? { marginBottom: theme.spacing[3] } : undefined}
>
<MemoizedMarkdownBlock
text={block}
styles={markdownStyles}
rules={markdownRules}
parser={markdownParser}
onLinkPress={handleLinkPress}
/>
</View>
))}
</View>
);
});
@@ -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);
}