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