From f35b0b8b7d1489487a998fe99a080f8f74e68b59 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 12 Apr 2026 10:23:49 +0700 Subject: [PATCH] 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 component so only the last active block re-renders. --- packages/app/src/components/message.tsx | 50 +++++++++--- .../__tests__/split-markdown-blocks.test.ts | 77 +++++++++++++++++++ .../app/src/utils/split-markdown-blocks.ts | 60 +++++++++++++++ 3 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/utils/__tests__/split-markdown-blocks.test.ts create mode 100644 packages/app/src/utils/split-markdown-blocks.ts diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 258bd77e5..eed1bc283 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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; + rules: RenderRules; + parser: MarkdownIt; + onLinkPress: (url: string) => boolean; +} + +const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({ + text, + styles, + rules, + parser, + onLinkPress, +}: MemoizedMarkdownBlockProps) { + return ( + + {text} + + ); +}); + 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 ( - - {message} - + {blocks.map((block, index) => ( + + + + ))} ); }); diff --git a/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts new file mode 100644 index 000000000..a014708fa --- /dev/null +++ b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts @@ -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", + ]); + }); +}); diff --git a/packages/app/src/utils/split-markdown-blocks.ts b/packages/app/src/utils/split-markdown-blocks.ts new file mode 100644 index 000000000..4a6942872 --- /dev/null +++ b/packages/app/src/utils/split-markdown-blocks.ts @@ -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); +}