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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user