(
+ * {children}
+ * ),
+ * code: ({ language, value }) => (
+ * {value}
+ * ),
+ * }}
+ * />
+ * );
+ * }
+ * ```
+ */
+export const MarkdownRenderer = memo(function MarkdownRenderer({
+ tree,
+ components: customComponents = {},
+ keyPrefix = 'md',
+}: MarkdownRendererProps): React.ReactElement | null {
+ // Merge custom components with defaults
+ const components = useMemo(
+ () => ({
+ ...defaultComponents,
+ ...customComponents,
+ }),
+ [customComponents],
+ );
+
+ if (!tree) {
+ return null;
+ }
+
+ return <>{renderNode(tree, components, keyPrefix, 0)}>;
+});
+
+/**
+ * HOC to create a memoized markdown component.
+ * Useful for creating custom renderers that only re-render when the tree changes.
+ */
+export function createMarkdownComponent(
+ Component: React.ComponentType
,
+ components?: Partial,
+): React.ComponentType {
+ return memo(function MarkdownComponent(props: P) {
+ const renderedTree = useMemo(
+ () => ,
+ [props.tree],
+ );
+
+ return ;
+ });
+}
+
+export { defaultComponents };
diff --git a/packages/react-native/src/server/index.ts b/packages/react-native/src/server/index.ts
new file mode 100644
index 000000000000..0a1a872c70fd
--- /dev/null
+++ b/packages/react-native/src/server/index.ts
@@ -0,0 +1,60 @@
+// Server-side markdown parsing and streaming utilities
+export {
+ parseMarkdownToTree,
+ StreamingMarkdownParser,
+} from './markdown-parser';
+
+export {
+ applyMarkdownTreePatch,
+ createMarkdownTreeDiff,
+ estimatePatchEfficiency,
+ isStreamingAppend,
+} from './markdown-tree-diff';
+
+export {
+ createMarkdownStreamFromStreamText,
+ createMarkdownStreamResponse,
+ createMarkdownStreamResponseFromStreamText,
+ createMarkdownTreeStream,
+ type CreateMarkdownStreamResponseOptions,
+ type MarkdownStreamOptions,
+ type MarkdownUIMessageChunk,
+ type MarkdownUIMessageStreamOptions,
+} from './markdown-stream';
+
+// Enhanced streaming that works with standard useChat
+export {
+ createMarkdownEnhancedTransform,
+ wrapWithMarkdownParsing,
+ type MarkdownEnhancedStreamOptions,
+} from './markdown-enhanced-stream';
+
+// Re-export types
+export type {
+ MarkdownBlockNode,
+ MarkdownBlockquote,
+ MarkdownBreak,
+ MarkdownCode,
+ MarkdownEmphasis,
+ MarkdownHeading,
+ MarkdownImage,
+ MarkdownInlineCode,
+ MarkdownInlineNode,
+ MarkdownLink,
+ MarkdownList,
+ MarkdownListItem,
+ MarkdownNode,
+ MarkdownNodeBase,
+ MarkdownParagraph,
+ MarkdownRoot,
+ MarkdownStrikethrough,
+ MarkdownStrong,
+ MarkdownTable,
+ MarkdownTableCell,
+ MarkdownTableRow,
+ MarkdownText,
+ MarkdownThematicBreak,
+ MarkdownTreeChunk,
+ MarkdownTreePatch,
+ MarkdownTreePatchOp,
+} from '../types';
diff --git a/packages/react-native/src/server/markdown-enhanced-stream.ts b/packages/react-native/src/server/markdown-enhanced-stream.ts
new file mode 100644
index 000000000000..b52340cf76eb
--- /dev/null
+++ b/packages/react-native/src/server/markdown-enhanced-stream.ts
@@ -0,0 +1,261 @@
+import type { MarkdownRoot, MarkdownTreeChunk } from '../types';
+import { StreamingMarkdownParser } from './markdown-parser';
+import { createMarkdownTreeDiff } from './markdown-tree-diff';
+
+/**
+ * Options for creating a markdown-enhanced UI message stream.
+ */
+export interface MarkdownEnhancedStreamOptions {
+ /**
+ * Generate unique IDs for markdown trees.
+ * Default: simple incrementing ID.
+ */
+ generateTreeId?: () => string;
+
+ /**
+ * Minimum interval between tree patches (ms).
+ * Default: 50ms
+ */
+ throttleMs?: number;
+}
+
+let treeIdCounter = 0;
+function defaultGenerateTreeId(): string {
+ return `md-tree-${++treeIdCounter}-${Date.now()}`;
+}
+
+/**
+ * Creates a TransformStream that intercepts text content and adds
+ * markdown tree chunks alongside the standard UI message chunks.
+ *
+ * This allows you to use the standard AI SDK streaming while also
+ * getting server-parsed markdown trees for React Native.
+ *
+ * @example
+ * ```ts
+ * import { streamText } from 'ai';
+ * import { createMarkdownEnhancedTransform } from '@ai-sdk/react-native/server';
+ *
+ * export async function POST(req: Request) {
+ * const { messages } = await req.json();
+ *
+ * const result = streamText({
+ * model: openai('gpt-4o'),
+ * messages,
+ * });
+ *
+ * // Pipe through the markdown enhancer
+ * const enhancedStream = result.toUIMessageStream()
+ * .pipeThrough(createMarkdownEnhancedTransform());
+ *
+ * return new Response(enhancedStream, {
+ * headers: { 'Content-Type': 'text/event-stream' },
+ * });
+ * }
+ * ```
+ */
+export function createMarkdownEnhancedTransform(
+ options: MarkdownEnhancedStreamOptions = {},
+): TransformStream {
+ const { generateTreeId = defaultGenerateTreeId, throttleMs = 50 } = options;
+
+ const decoder = new TextDecoder();
+ const encoder = new TextEncoder();
+
+ // Track active text parts and their parsers
+ const textParts = new Map<
+ string,
+ {
+ parser: StreamingMarkdownParser;
+ treeId: string;
+ lastTree: MarkdownRoot | null;
+ lastSendTime: number;
+ }
+ >();
+
+ let buffer = '';
+
+ return new TransformStream({
+ transform(chunk, controller) {
+ buffer += decoder.decode(chunk, { stream: true });
+
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ // Forward the original line
+ controller.enqueue(encoder.encode(line + '\n'));
+
+ if (!line.startsWith('data: ')) continue;
+
+ const data = line.slice(6);
+ if (data === '[DONE]') continue;
+
+ try {
+ const parsed = JSON.parse(data);
+
+ // Handle text-start: create a new parser
+ if (parsed.type === 'text-start') {
+ const treeId = generateTreeId();
+ textParts.set(parsed.id, {
+ parser: new StreamingMarkdownParser(),
+ treeId,
+ lastTree: null,
+ lastSendTime: 0,
+ });
+
+ // Send tree start
+ const startChunk: MarkdownTreeChunk = {
+ type: 'markdown-tree-start',
+ id: treeId,
+ };
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify(startChunk)}\n`),
+ );
+ }
+
+ // Handle text-delta: update the parser and send patches
+ if (parsed.type === 'text-delta' && parsed.delta) {
+ const textPart = textParts.get(parsed.id);
+ if (textPart) {
+ const newTree = textPart.parser.append(parsed.delta);
+ const now = Date.now();
+
+ // Throttle updates
+ if (now - textPart.lastSendTime >= throttleMs) {
+ const treeChunk = createTreeChunk(
+ textPart.treeId,
+ textPart.lastTree,
+ newTree,
+ );
+ if (treeChunk) {
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify(treeChunk)}\n`),
+ );
+ }
+ textPart.lastTree = newTree;
+ textPart.lastSendTime = now;
+ }
+ }
+ }
+
+ // Handle text-end: send final tree
+ if (parsed.type === 'text-end') {
+ const textPart = textParts.get(parsed.id);
+ if (textPart) {
+ const finalTree = textPart.parser.getTree();
+ const endChunk: MarkdownTreeChunk = {
+ type: 'markdown-tree-end',
+ id: textPart.treeId,
+ tree: finalTree,
+ };
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify(endChunk)}\n`),
+ );
+ textParts.delete(parsed.id);
+ }
+ }
+ } catch {
+ // Not JSON, ignore
+ }
+ }
+ },
+
+ flush(controller) {
+ if (buffer) {
+ controller.enqueue(encoder.encode(buffer));
+ }
+
+ // Send final trees for any incomplete text parts
+ for (const [_, textPart] of textParts) {
+ const finalTree = textPart.parser.getTree();
+ const endChunk: MarkdownTreeChunk = {
+ type: 'markdown-tree-end',
+ id: textPart.treeId,
+ tree: finalTree,
+ };
+ controller.enqueue(
+ encoder.encode(`data: ${JSON.stringify(endChunk)}\n`),
+ );
+ }
+ textParts.clear();
+ },
+ });
+}
+
+function createTreeChunk(
+ treeId: string,
+ oldTree: MarkdownRoot | null,
+ newTree: MarkdownRoot,
+): MarkdownTreeChunk | null {
+ if (!oldTree) {
+ return {
+ type: 'markdown-tree-snapshot',
+ id: treeId,
+ tree: newTree,
+ };
+ }
+
+ const patch = createMarkdownTreeDiff(oldTree, newTree);
+ if (patch.length === 0) {
+ return null;
+ }
+
+ // If the patch is a full replacement, send a snapshot instead
+ if (patch.length === 1 && patch[0].op === 'replace' && patch[0].path === '') {
+ return {
+ type: 'markdown-tree-snapshot',
+ id: treeId,
+ tree: newTree,
+ };
+ }
+
+ return {
+ type: 'markdown-tree-patch',
+ id: treeId,
+ patch,
+ };
+}
+
+/**
+ * Wraps a UI message stream Response to add markdown tree parsing.
+ *
+ * This is the easiest way to add server-side markdown parsing to
+ * your existing AI SDK setup.
+ *
+ * @example
+ * ```ts
+ * import { streamText } from 'ai';
+ * import { wrapWithMarkdownParsing } from '@ai-sdk/react-native/server';
+ *
+ * export async function POST(req: Request) {
+ * const { messages } = await req.json();
+ *
+ * const result = streamText({
+ * model: openai('gpt-4o'),
+ * messages,
+ * });
+ *
+ * // Just wrap the response!
+ * return wrapWithMarkdownParsing(result.toUIMessageStreamResponse());
+ * }
+ * ```
+ */
+export function wrapWithMarkdownParsing(
+ response: Response,
+ options?: MarkdownEnhancedStreamOptions,
+): Response {
+ if (!response.body) {
+ return response;
+ }
+
+ const transformedBody = response.body.pipeThrough(
+ createMarkdownEnhancedTransform(options),
+ );
+
+ return new Response(transformedBody, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ });
+}
diff --git a/packages/react-native/src/server/markdown-parser.test.ts b/packages/react-native/src/server/markdown-parser.test.ts
new file mode 100644
index 000000000000..4aa875f24d22
--- /dev/null
+++ b/packages/react-native/src/server/markdown-parser.test.ts
@@ -0,0 +1,426 @@
+import { describe, it, expect } from 'vitest';
+import {
+ parseMarkdownToTree,
+ StreamingMarkdownParser,
+} from './markdown-parser';
+
+describe('parseMarkdownToTree', () => {
+ describe('basic elements', () => {
+ it('should parse plain text as a paragraph', () => {
+ const result = parseMarkdownToTree('Hello, world!');
+
+ expect(result).toEqual({
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello, world!' }],
+ },
+ ],
+ });
+ });
+
+ it('should parse headings', () => {
+ const result = parseMarkdownToTree('# Heading 1\n## Heading 2');
+
+ expect(result.children).toHaveLength(2);
+ expect(result.children[0]).toEqual({
+ type: 'heading',
+ depth: 1,
+ children: [{ type: 'text', value: 'Heading 1' }],
+ });
+ expect(result.children[1]).toEqual({
+ type: 'heading',
+ depth: 2,
+ children: [{ type: 'text', value: 'Heading 2' }],
+ });
+ });
+
+ it('should parse all heading levels', () => {
+ const markdown = `# H1
+## H2
+### H3
+#### H4
+##### H5
+###### H6`;
+
+ const result = parseMarkdownToTree(markdown);
+
+ for (let i = 0; i < 6; i++) {
+ expect(result.children[i]).toMatchObject({
+ type: 'heading',
+ depth: i + 1,
+ });
+ }
+ });
+ });
+
+ describe('inline formatting', () => {
+ it('should parse bold text with asterisks', () => {
+ const result = parseMarkdownToTree('This is **bold** text');
+
+ expect(result.children[0]).toEqual({
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'This is ' },
+ {
+ type: 'strong',
+ children: [{ type: 'text', value: 'bold' }],
+ },
+ { type: 'text', value: ' text' },
+ ],
+ });
+ });
+
+ it('should parse italic text with asterisks', () => {
+ const result = parseMarkdownToTree('This is *italic* text');
+
+ expect(result.children[0]).toEqual({
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'This is ' },
+ {
+ type: 'emphasis',
+ children: [{ type: 'text', value: 'italic' }],
+ },
+ { type: 'text', value: ' text' },
+ ],
+ });
+ });
+
+ it('should parse inline code', () => {
+ const result = parseMarkdownToTree('Use `console.log()` for debugging');
+
+ expect(result.children[0]).toEqual({
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'Use ' },
+ { type: 'inlineCode', value: 'console.log()' },
+ { type: 'text', value: ' for debugging' },
+ ],
+ });
+ });
+
+ it('should parse strikethrough', () => {
+ const result = parseMarkdownToTree('This is ~~deleted~~ text');
+
+ expect(result.children[0]).toEqual({
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'This is ' },
+ {
+ type: 'strikethrough',
+ children: [{ type: 'text', value: 'deleted' }],
+ },
+ { type: 'text', value: ' text' },
+ ],
+ });
+ });
+
+ it('should parse nested inline formatting with separate markers', () => {
+ // Using separate markers **_text_** for bold-italic
+ const result = parseMarkdownToTree('This is **_bold and italic_** text');
+
+ expect(result.children[0]).toMatchObject({
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'This is ' },
+ {
+ type: 'strong',
+ children: [
+ {
+ type: 'emphasis',
+ children: [{ type: 'text', value: 'bold and italic' }],
+ },
+ ],
+ },
+ { type: 'text', value: ' text' },
+ ],
+ });
+ });
+ });
+
+ describe('links and images', () => {
+ it('should parse links', () => {
+ const result = parseMarkdownToTree(
+ 'Visit [Google](https://google.com) for search',
+ );
+
+ expect(result.children[0]).toEqual({
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'Visit ' },
+ {
+ type: 'link',
+ url: 'https://google.com',
+ title: undefined,
+ children: [{ type: 'text', value: 'Google' }],
+ },
+ { type: 'text', value: ' for search' },
+ ],
+ });
+ });
+
+ it('should parse links with titles', () => {
+ const result = parseMarkdownToTree(
+ '[Link](https://example.com "Example Site")',
+ );
+
+ expect(result.children[0].children[0]).toMatchObject({
+ type: 'link',
+ url: 'https://example.com',
+ title: 'Example Site',
+ });
+ });
+
+ it('should parse images', () => {
+ const result = parseMarkdownToTree('');
+
+ expect(result.children[0].children[0]).toEqual({
+ type: 'image',
+ url: 'image.png',
+ alt: 'Alt text',
+ title: 'Title',
+ });
+ });
+ });
+
+ describe('code blocks', () => {
+ it('should parse fenced code blocks', () => {
+ const markdown = `\`\`\`javascript
+const x = 1;
+console.log(x);
+\`\`\``;
+
+ const result = parseMarkdownToTree(markdown);
+
+ expect(result.children[0]).toEqual({
+ type: 'code',
+ lang: 'javascript',
+ meta: undefined,
+ value: 'const x = 1;\nconsole.log(x);',
+ });
+ });
+
+ it('should parse code blocks without language', () => {
+ const markdown = `\`\`\`
+plain code
+\`\`\``;
+
+ const result = parseMarkdownToTree(markdown);
+
+ expect(result.children[0]).toEqual({
+ type: 'code',
+ lang: undefined,
+ meta: undefined,
+ value: 'plain code',
+ });
+ });
+
+ it('should handle unclosed code blocks', () => {
+ const markdown = `\`\`\`javascript
+const x = 1;
+// streaming...`;
+
+ const result = parseMarkdownToTree(markdown);
+
+ expect(result.children[0]).toMatchObject({
+ type: 'code',
+ lang: 'javascript',
+ value: 'const x = 1;\n// streaming...',
+ });
+ });
+ });
+
+ describe('lists', () => {
+ it('should parse unordered lists', () => {
+ const markdown = `- Item 1
+- Item 2
+- Item 3`;
+
+ const result = parseMarkdownToTree(markdown);
+
+ expect(result.children[0]).toMatchObject({
+ type: 'list',
+ ordered: false,
+ children: [
+ { type: 'listItem' },
+ { type: 'listItem' },
+ { type: 'listItem' },
+ ],
+ });
+ });
+
+ it('should parse ordered lists', () => {
+ const markdown = `1. First
+2. Second
+3. Third`;
+
+ const result = parseMarkdownToTree(markdown);
+
+ expect(result.children[0]).toMatchObject({
+ type: 'list',
+ ordered: true,
+ });
+ });
+ });
+
+ describe('blockquotes', () => {
+ it('should parse blockquotes', () => {
+ const markdown = `> This is a quote
+> with multiple lines`;
+
+ const result = parseMarkdownToTree(markdown);
+
+ expect(result.children[0]).toMatchObject({
+ type: 'blockquote',
+ });
+ });
+ });
+
+ describe('thematic breaks', () => {
+ it('should parse horizontal rules with dashes', () => {
+ const result = parseMarkdownToTree('---');
+
+ expect(result.children[0]).toEqual({ type: 'thematicBreak' });
+ });
+
+ it('should parse horizontal rules with asterisks', () => {
+ const result = parseMarkdownToTree('***');
+
+ expect(result.children[0]).toEqual({ type: 'thematicBreak' });
+ });
+
+ it('should parse horizontal rules with underscores', () => {
+ const result = parseMarkdownToTree('___');
+
+ expect(result.children[0]).toEqual({ type: 'thematicBreak' });
+ });
+ });
+
+ describe('tables', () => {
+ it('should parse simple tables', () => {
+ const markdown = `| Header 1 | Header 2 |
+|----------|----------|
+| Cell 1 | Cell 2 |`;
+
+ const result = parseMarkdownToTree(markdown);
+
+ expect(result.children[0]).toMatchObject({
+ type: 'table',
+ children: [{ type: 'tableRow' }, { type: 'tableRow' }],
+ });
+ });
+ });
+
+ describe('complex documents', () => {
+ it('should parse a complex markdown document', () => {
+ const markdown = `# Welcome
+
+This is a **paragraph** with *formatting*.
+
+## Code Example
+
+\`\`\`typescript
+function greet(name: string): string {
+ return \`Hello, \${name}!\`;
+}
+\`\`\`
+
+- Item 1
+- Item 2
+
+> A blockquote
+
+---
+
+The end.`;
+
+ const result = parseMarkdownToTree(markdown);
+
+ expect(result.children).toHaveLength(8);
+ expect(result.children[0].type).toBe('heading');
+ expect(result.children[1].type).toBe('paragraph');
+ expect(result.children[2].type).toBe('heading');
+ expect(result.children[3].type).toBe('code');
+ expect(result.children[4].type).toBe('list');
+ expect(result.children[5].type).toBe('blockquote');
+ expect(result.children[6].type).toBe('thematicBreak');
+ expect(result.children[7].type).toBe('paragraph');
+ });
+ });
+});
+
+describe('StreamingMarkdownParser', () => {
+ it('should parse content incrementally', () => {
+ const parser = new StreamingMarkdownParser();
+
+ // First chunk
+ let tree = parser.append('# Hello');
+ expect(tree.children[0]).toMatchObject({
+ type: 'heading',
+ depth: 1,
+ });
+
+ // Second chunk
+ tree = parser.append('\n\nThis is ');
+ expect(tree.children).toHaveLength(2);
+
+ // Third chunk
+ tree = parser.append('**bold** text.');
+ expect(tree.children[1]).toMatchObject({
+ type: 'paragraph',
+ });
+ });
+
+ it('should handle streaming code blocks', () => {
+ const parser = new StreamingMarkdownParser();
+
+ parser.append('```javascript\n');
+ parser.append('const x = 1;\n');
+ let tree = parser.append('console.log(x);');
+
+ // Code block is still "open"
+ expect(tree.children[0]).toMatchObject({
+ type: 'code',
+ lang: 'javascript',
+ });
+
+ // Close the code block
+ tree = parser.append('\n```');
+ expect(tree.children[0]).toMatchObject({
+ type: 'code',
+ lang: 'javascript',
+ value: 'const x = 1;\nconsole.log(x);',
+ });
+ });
+
+ it('should allow getting the tree without appending', () => {
+ const parser = new StreamingMarkdownParser();
+
+ parser.append('Hello');
+ const tree1 = parser.getTree();
+ const tree2 = parser.getTree();
+
+ expect(tree1).toEqual(tree2);
+ });
+
+ it('should track full content', () => {
+ const parser = new StreamingMarkdownParser();
+
+ parser.append('Hello, ');
+ parser.append('world!');
+
+ expect(parser.getContent()).toBe('Hello, world!');
+ });
+
+ it('should reset properly', () => {
+ const parser = new StreamingMarkdownParser();
+
+ parser.append('Hello');
+ parser.reset();
+
+ expect(parser.getContent()).toBe('');
+ expect(parser.getTree()).toEqual({ type: 'root', children: [] });
+ });
+});
diff --git a/packages/react-native/src/server/markdown-parser.ts b/packages/react-native/src/server/markdown-parser.ts
new file mode 100644
index 000000000000..7ece0bed2082
--- /dev/null
+++ b/packages/react-native/src/server/markdown-parser.ts
@@ -0,0 +1,645 @@
+import type {
+ MarkdownBlockquote,
+ MarkdownBreak,
+ MarkdownCode,
+ MarkdownEmphasis,
+ MarkdownHeading,
+ MarkdownImage,
+ MarkdownInlineCode,
+ MarkdownInlineNode,
+ MarkdownLink,
+ MarkdownList,
+ MarkdownListItem,
+ MarkdownNode,
+ MarkdownParagraph,
+ MarkdownRoot,
+ MarkdownStrikethrough,
+ MarkdownStrong,
+ MarkdownTable,
+ MarkdownTableCell,
+ MarkdownTableRow,
+ MarkdownText,
+ MarkdownThematicBreak,
+} from '../types';
+
+/**
+ * A lightweight streaming-friendly markdown parser that produces a JSON tree.
+ *
+ * This parser is designed to work incrementally - it can parse partial
+ * markdown content and produce a valid tree structure that can be updated
+ * as more content streams in.
+ */
+
+interface ParserState {
+ currentText: string;
+ inCodeBlock: boolean;
+ codeBlockLang?: string;
+ codeBlockMeta?: string;
+ codeBlockContent: string;
+ inBlockquote: boolean;
+ blockquoteContent: string[];
+ listStack: Array<{
+ ordered: boolean;
+ start?: number;
+ items: MarkdownListItem[];
+ currentItemContent: string[];
+ indent: number;
+ }>;
+}
+
+function createInitialState(): ParserState {
+ return {
+ currentText: '',
+ inCodeBlock: false,
+ codeBlockLang: undefined,
+ codeBlockMeta: undefined,
+ codeBlockContent: '',
+ inBlockquote: false,
+ blockquoteContent: [],
+ listStack: [],
+ };
+}
+
+/**
+ * Parse inline markdown content (bold, italic, code, links, etc.)
+ */
+function parseInlineContent(text: string): MarkdownInlineNode[] {
+ const nodes: MarkdownInlineNode[] = [];
+ let remaining = text;
+
+ while (remaining.length > 0) {
+ // Check for various inline patterns
+ let matched = false;
+
+ // Inline code: `code`
+ const inlineCodeMatch = remaining.match(/^`([^`]+)`/);
+ if (inlineCodeMatch) {
+ nodes.push({ type: 'inlineCode', value: inlineCodeMatch[1] });
+ remaining = remaining.slice(inlineCodeMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // Bold with asterisks: **text**
+ const boldMatch = remaining.match(/^\*\*(.+?)\*\*/s);
+ if (boldMatch) {
+ nodes.push({
+ type: 'strong',
+ children: parseInlineContent(boldMatch[1]),
+ });
+ remaining = remaining.slice(boldMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // Bold with underscores: __text__
+ const boldUnderscoreMatch = remaining.match(/^__(.+?)__/s);
+ if (boldUnderscoreMatch) {
+ nodes.push({
+ type: 'strong',
+ children: parseInlineContent(boldUnderscoreMatch[1]),
+ });
+ remaining = remaining.slice(boldUnderscoreMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // Strikethrough: ~~text~~
+ const strikethroughMatch = remaining.match(/^~~(.+?)~~/s);
+ if (strikethroughMatch) {
+ nodes.push({
+ type: 'strikethrough',
+ children: parseInlineContent(strikethroughMatch[1]),
+ });
+ remaining = remaining.slice(strikethroughMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // Italic with asterisks: *text*
+ const italicMatch = remaining.match(/^\*([^*]+)\*/);
+ if (italicMatch) {
+ nodes.push({
+ type: 'emphasis',
+ children: parseInlineContent(italicMatch[1]),
+ });
+ remaining = remaining.slice(italicMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // Italic with underscores: _text_
+ const italicUnderscoreMatch = remaining.match(/^_([^_]+)_/);
+ if (italicUnderscoreMatch) {
+ nodes.push({
+ type: 'emphasis',
+ children: parseInlineContent(italicUnderscoreMatch[1]),
+ });
+ remaining = remaining.slice(italicUnderscoreMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // Images: 
+ const imageMatch = remaining.match(
+ /^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/,
+ );
+ if (imageMatch) {
+ const image: MarkdownImage = {
+ type: 'image',
+ url: imageMatch[2],
+ alt: imageMatch[1] || undefined,
+ title: imageMatch[3] || undefined,
+ };
+ nodes.push(image);
+ remaining = remaining.slice(imageMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // Links: [text](url "title")
+ const linkMatch = remaining.match(
+ /^\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/,
+ );
+ if (linkMatch) {
+ const link: MarkdownLink = {
+ type: 'link',
+ url: linkMatch[2],
+ title: linkMatch[3] || undefined,
+ children: parseInlineContent(linkMatch[1]),
+ };
+ nodes.push(link);
+ remaining = remaining.slice(linkMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // Auto-links:
+ const autoLinkMatch = remaining.match(/^<(https?:\/\/[^>]+)>/);
+ if (autoLinkMatch) {
+ const link: MarkdownLink = {
+ type: 'link',
+ url: autoLinkMatch[1],
+ children: [{ type: 'text', value: autoLinkMatch[1] }],
+ };
+ nodes.push(link);
+ remaining = remaining.slice(autoLinkMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // Line breaks: two spaces followed by newline, or explicit
+ const breakMatch = remaining.match(/^( \n|
)/i);
+ if (breakMatch) {
+ nodes.push({ type: 'break' } as MarkdownBreak);
+ remaining = remaining.slice(breakMatch[0].length);
+ matched = true;
+ continue;
+ }
+
+ // If nothing matched, consume one character as text
+ if (!matched) {
+ // Find the next special character
+ const nextSpecial = remaining.search(/[`*_~[\] } {
+ // Code block start/end
+ const codeBlockMatch = line.match(/^```(\w+)?(?:\s+(.+))?$/);
+ if (codeBlockMatch) {
+ if (state.inCodeBlock) {
+ // End of code block
+ const code: MarkdownCode = {
+ type: 'code',
+ lang: state.codeBlockLang,
+ meta: state.codeBlockMeta,
+ value: state.codeBlockContent.replace(/\n$/, ''), // Remove trailing newline
+ };
+ return {
+ type: 'code-block-end',
+ node: code,
+ updateState: {
+ inCodeBlock: false,
+ codeBlockLang: undefined,
+ codeBlockMeta: undefined,
+ codeBlockContent: '',
+ },
+ };
+ } else {
+ // Start of code block
+ return {
+ type: 'code-block-start',
+ updateState: {
+ inCodeBlock: true,
+ codeBlockLang: codeBlockMatch[1] || undefined,
+ codeBlockMeta: codeBlockMatch[2] || undefined,
+ codeBlockContent: '',
+ },
+ };
+ }
+ }
+
+ // Inside code block
+ if (state.inCodeBlock) {
+ return {
+ type: 'code-block-content',
+ updateState: {
+ codeBlockContent: state.codeBlockContent + line + '\n',
+ },
+ };
+ }
+
+ // Thematic break
+ if (/^(---+|___+|\*\*\*+)\s*$/.test(line)) {
+ return {
+ type: 'thematic-break',
+ node: { type: 'thematicBreak' } as MarkdownThematicBreak,
+ };
+ }
+
+ // Heading with # syntax
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
+ if (headingMatch) {
+ const heading: MarkdownHeading = {
+ type: 'heading',
+ depth: headingMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6,
+ children: parseInlineContent(headingMatch[2].trim()),
+ };
+ return { type: 'heading', node: heading };
+ }
+
+ // Blockquote
+ const blockquoteMatch = line.match(/^>\s?(.*)$/);
+ if (blockquoteMatch) {
+ return {
+ type: 'blockquote',
+ updateState: {
+ inBlockquote: true,
+ blockquoteContent: [...state.blockquoteContent, blockquoteMatch[1]],
+ },
+ };
+ }
+
+ // End blockquote if we were in one
+ if (state.inBlockquote && !blockquoteMatch) {
+ const blockquote: MarkdownBlockquote = {
+ type: 'blockquote',
+ children: parseMarkdownToTree(state.blockquoteContent.join('\n'))
+ .children as MarkdownNode[],
+ };
+ return {
+ type: 'blockquote-end',
+ node: blockquote,
+ updateState: {
+ inBlockquote: false,
+ blockquoteContent: [],
+ },
+ };
+ }
+
+ // Ordered list item (check this first to avoid matching with unordered)
+ const orderedListMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
+ if (orderedListMatch) {
+ const indent = orderedListMatch[1].length;
+ const start = parseInt(orderedListMatch[2], 10);
+ const content = orderedListMatch[3];
+ return {
+ type: 'ordered-list-item',
+ node: createListItem(content, true, start, indent),
+ };
+ }
+
+ // Unordered list item
+ const unorderedListMatch = line.match(/^(\s*)([-*+])\s+(.*)$/);
+ if (unorderedListMatch) {
+ const indent = unorderedListMatch[1].length;
+ const content = unorderedListMatch[3];
+ return {
+ type: 'unordered-list-item',
+ node: createListItem(content, false, undefined, indent),
+ };
+ }
+
+ // Task list item
+ const taskListMatch = line.match(/^(\s*)([-*+])\s+\[([ xX])\]\s+(.*)$/);
+ if (taskListMatch) {
+ const indent = taskListMatch[1].length;
+ const checked = taskListMatch[3].toLowerCase() === 'x';
+ const content = taskListMatch[4];
+ const item = createListItem(content, false, undefined, indent);
+ (item as MarkdownListItem & { checked: boolean }).checked = checked;
+ return { type: 'list-item', node: item };
+ }
+
+ // Empty line
+ if (line.trim() === '') {
+ return { type: 'empty' };
+ }
+
+ // Table row
+ const tableMatch = line.match(/^\|(.+)\|$/);
+ if (tableMatch) {
+ const cells = tableMatch[1].split('|').map(cell => cell.trim());
+ // Check if this is a separator row
+ if (cells.every(cell => /^:?-+:?$/.test(cell))) {
+ return { type: 'table-separator', node: undefined };
+ }
+ const row: MarkdownTableRow = {
+ type: 'tableRow',
+ children: cells.map(cell => ({
+ type: 'tableCell',
+ children: parseInlineContent(cell),
+ })) as MarkdownTableCell[],
+ };
+ return { type: 'table-row', node: row };
+ }
+
+ // Regular paragraph
+ return {
+ type: 'paragraph',
+ node: {
+ type: 'paragraph',
+ children: parseInlineContent(line),
+ } as MarkdownParagraph,
+ };
+}
+
+function createListItem(
+ content: string,
+ _ordered: boolean,
+ _start: number | undefined,
+ _indent: number,
+): MarkdownListItem {
+ return {
+ type: 'listItem',
+ children: [
+ {
+ type: 'paragraph',
+ children: parseInlineContent(content),
+ } as MarkdownParagraph,
+ ],
+ };
+}
+
+/**
+ * Parse a complete markdown string into a tree structure.
+ */
+export function parseMarkdownToTree(markdown: string): MarkdownRoot {
+ const root: MarkdownRoot = {
+ type: 'root',
+ children: [],
+ };
+
+ const lines = markdown.split('\n');
+ const state = createInitialState();
+
+ let currentList: MarkdownList | null = null;
+ let currentTable: MarkdownTable | null = null;
+ let pendingParagraphLines: string[] = [];
+
+ const flushParagraph = () => {
+ if (pendingParagraphLines.length > 0) {
+ const text = pendingParagraphLines.join('\n');
+ if (text.trim()) {
+ root.children.push({
+ type: 'paragraph',
+ children: parseInlineContent(text),
+ } as MarkdownParagraph);
+ }
+ pendingParagraphLines = [];
+ }
+ };
+
+ const flushList = () => {
+ if (currentList) {
+ root.children.push(currentList);
+ currentList = null;
+ }
+ };
+
+ const flushTable = () => {
+ if (currentTable) {
+ root.children.push(currentTable);
+ currentTable = null;
+ }
+ };
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const result = parseBlockLine(line, state);
+
+ // Update parser state
+ if (result.updateState) {
+ Object.assign(state, result.updateState);
+ }
+
+ switch (result.type) {
+ case 'code-block-start':
+ flushParagraph();
+ flushList();
+ flushTable();
+ break;
+
+ case 'code-block-content':
+ // Content is accumulated in state
+ break;
+
+ case 'code-block-end':
+ if (result.node) {
+ root.children.push(result.node);
+ }
+ break;
+
+ case 'heading':
+ case 'thematic-break':
+ flushParagraph();
+ flushList();
+ flushTable();
+ if (result.node) {
+ root.children.push(result.node);
+ }
+ break;
+
+ case 'blockquote':
+ // Accumulated in state
+ break;
+
+ case 'blockquote-end':
+ flushParagraph();
+ flushList();
+ flushTable();
+ if (result.node) {
+ root.children.push(result.node);
+ }
+ // Re-parse the current line since it's not a blockquote
+ i--;
+ break;
+
+ case 'ordered-list-item':
+ flushParagraph();
+ flushTable();
+ if (result.node) {
+ if (!currentList || !currentList.ordered) {
+ flushList();
+ currentList = {
+ type: 'list',
+ ordered: true,
+ children: [],
+ };
+ }
+ currentList.children.push(result.node as MarkdownListItem);
+ }
+ break;
+
+ case 'unordered-list-item':
+ flushParagraph();
+ flushTable();
+ if (result.node) {
+ if (!currentList || currentList.ordered) {
+ flushList();
+ currentList = {
+ type: 'list',
+ ordered: false,
+ children: [],
+ };
+ }
+ currentList.children.push(result.node as MarkdownListItem);
+ }
+ break;
+
+ case 'table-row':
+ flushParagraph();
+ flushList();
+ if (!currentTable) {
+ currentTable = {
+ type: 'table',
+ children: [],
+ };
+ }
+ if (result.node) {
+ currentTable.children.push(result.node as MarkdownTableRow);
+ }
+ break;
+
+ case 'table-separator':
+ // Table separator, already in table context
+ break;
+
+ case 'empty':
+ flushParagraph();
+ flushList();
+ flushTable();
+ break;
+
+ case 'paragraph':
+ flushList();
+ flushTable();
+ pendingParagraphLines.push(line);
+ break;
+ }
+ }
+
+ // Handle any remaining state
+ if (state.inCodeBlock) {
+ // Unclosed code block - add it anyway
+ root.children.push({
+ type: 'code',
+ lang: state.codeBlockLang,
+ meta: state.codeBlockMeta,
+ value: state.codeBlockContent.replace(/\n$/, ''),
+ } as MarkdownCode);
+ }
+
+ if (state.inBlockquote) {
+ root.children.push({
+ type: 'blockquote',
+ children: parseMarkdownToTree(state.blockquoteContent.join('\n'))
+ .children as MarkdownNode[],
+ } as MarkdownBlockquote);
+ }
+
+ flushParagraph();
+ flushList();
+ flushTable();
+
+ return root;
+}
+
+/**
+ * Streaming-aware markdown parser that can handle partial content.
+ *
+ * This is designed to work with streaming LLM responses where the markdown
+ * content arrives incrementally.
+ */
+export class StreamingMarkdownParser {
+ private content: string = '';
+ private lastTree: MarkdownRoot | null = null;
+
+ /**
+ * Append new content and get the updated tree.
+ */
+ append(chunk: string): MarkdownRoot {
+ this.content += chunk;
+ this.lastTree = parseMarkdownToTree(this.content);
+ return this.lastTree;
+ }
+
+ /**
+ * Get the current tree without appending.
+ */
+ getTree(): MarkdownRoot {
+ if (!this.lastTree) {
+ this.lastTree = parseMarkdownToTree(this.content);
+ }
+ return this.lastTree;
+ }
+
+ /**
+ * Get the full content accumulated so far.
+ */
+ getContent(): string {
+ return this.content;
+ }
+
+ /**
+ * Reset the parser state.
+ */
+ reset(): void {
+ this.content = '';
+ this.lastTree = null;
+ }
+}
diff --git a/packages/react-native/src/server/markdown-stream.ts b/packages/react-native/src/server/markdown-stream.ts
new file mode 100644
index 000000000000..94fcda7cbf16
--- /dev/null
+++ b/packages/react-native/src/server/markdown-stream.ts
@@ -0,0 +1,370 @@
+import type {
+ MarkdownRoot,
+ MarkdownTreeChunk,
+ MarkdownTreePatch,
+} from '../types';
+import {
+ StreamingMarkdownParser,
+ parseMarkdownToTree,
+} from './markdown-parser';
+import {
+ createMarkdownTreeDiff,
+ isStreamingAppend,
+} from './markdown-tree-diff';
+
+// Simple ID generator
+function generateIdFunc(): string {
+ return Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
+}
+
+export interface MarkdownStreamOptions {
+ /**
+ * Generate a unique ID for the markdown tree.
+ * Defaults to the AI SDK's generateId function.
+ */
+ generateId?: () => string;
+
+ /**
+ * Minimum interval between patches in milliseconds.
+ * Batches rapid updates to reduce patch frequency.
+ * Default: 50ms
+ */
+ throttleMs?: number;
+
+ /**
+ * Maximum number of patches before sending a full snapshot.
+ * This helps prevent patch accumulation issues.
+ * Default: 20
+ */
+ maxPatchesBeforeSnapshot?: number;
+
+ /**
+ * Whether to send the initial tree as a snapshot.
+ * Default: true
+ */
+ sendInitialSnapshot?: boolean;
+}
+
+/**
+ * Transform a text stream into a markdown tree stream.
+ *
+ * This takes a stream of text chunks (e.g., from an LLM) and transforms it
+ * into a stream of markdown tree patches optimized for React Native rendering.
+ */
+export function createMarkdownTreeStream(
+ textStream: ReadableStream,
+ options: MarkdownStreamOptions = {},
+): ReadableStream {
+ const {
+ generateId = generateIdFunc,
+ throttleMs = 50,
+ maxPatchesBeforeSnapshot = 20,
+ sendInitialSnapshot = true,
+ } = options;
+
+ const treeId = generateId();
+ const parser = new StreamingMarkdownParser();
+ let lastTree: MarkdownRoot | null = null;
+ let patchCount = 0;
+ let lastSendTime = 0;
+ let pendingChunks: string[] = [];
+ let timeoutId: ReturnType | null = null;
+
+ return new ReadableStream({
+ async start(controller) {
+ // Send start event
+ controller.enqueue({ type: 'markdown-tree-start', id: treeId });
+
+ const reader = textStream.getReader();
+
+ const flushPendingChunks = () => {
+ if (pendingChunks.length === 0) return;
+
+ // Append all pending chunks
+ for (const chunk of pendingChunks) {
+ parser.append(chunk);
+ }
+ pendingChunks = [];
+
+ const newTree = parser.getTree();
+
+ // Decide whether to send patch or snapshot
+ const shouldSendSnapshot =
+ patchCount >= maxPatchesBeforeSnapshot ||
+ (lastTree === null && sendInitialSnapshot);
+
+ if (shouldSendSnapshot) {
+ controller.enqueue({
+ type: 'markdown-tree-snapshot',
+ id: treeId,
+ tree: newTree,
+ });
+ patchCount = 0;
+ } else {
+ const patch = createMarkdownTreeDiff(lastTree, newTree);
+
+ if (patch.length > 0) {
+ controller.enqueue({
+ type: 'markdown-tree-patch',
+ id: treeId,
+ patch,
+ });
+ patchCount++;
+ }
+ }
+
+ lastTree = newTree;
+ lastSendTime = Date.now();
+ };
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ // Clear any pending timeout
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ // Flush any remaining chunks
+ flushPendingChunks();
+
+ // Send final tree
+ const finalTree = parser.getTree();
+ controller.enqueue({
+ type: 'markdown-tree-end',
+ id: treeId,
+ tree: finalTree,
+ });
+
+ controller.close();
+ break;
+ }
+
+ // Add chunk to pending
+ pendingChunks.push(value);
+
+ // Check if we should flush immediately or throttle
+ const now = Date.now();
+ const timeSinceLastSend = now - lastSendTime;
+
+ if (timeSinceLastSend >= throttleMs) {
+ // Enough time has passed, flush immediately
+ flushPendingChunks();
+ } else if (!timeoutId) {
+ // Schedule a flush
+ timeoutId = setTimeout(() => {
+ timeoutId = null;
+ flushPendingChunks();
+ }, throttleMs - timeSinceLastSend);
+ }
+ }
+ } catch (error) {
+ controller.error(error);
+ }
+ },
+ });
+}
+
+/**
+ * Options for creating a markdown stream response
+ */
+export interface CreateMarkdownStreamResponseOptions {
+ /**
+ * Additional headers to include in the response.
+ */
+ headers?: Record;
+
+ /**
+ * Status code for the response.
+ * Default: 200
+ */
+ status?: number;
+
+ /**
+ * Callback called when streaming starts.
+ */
+ onStart?: (treeId: string) => void;
+
+ /**
+ * Callback called for each chunk sent.
+ */
+ onChunk?: (chunk: MarkdownTreeChunk) => void;
+
+ /**
+ * Callback called when streaming finishes.
+ */
+ onFinish?: (tree: MarkdownRoot) => void;
+
+ /**
+ * Callback called on error.
+ */
+ onError?: (error: Error) => void;
+
+ /**
+ * Markdown stream options.
+ */
+ streamOptions?: MarkdownStreamOptions;
+}
+
+/**
+ * Create an HTTP Response that streams markdown tree updates.
+ *
+ * This can be used with Next.js, Express, or any other framework that
+ * supports the standard Response API.
+ *
+ * @example
+ * ```ts
+ * // Next.js App Router
+ * export async function POST(req: Request) {
+ * const result = streamText({
+ * model: openai('gpt-4o'),
+ * messages: [{ role: 'user', content: 'Hello!' }],
+ * });
+ *
+ * return createMarkdownStreamResponse(result.textStream);
+ * }
+ * ```
+ */
+export function createMarkdownStreamResponse(
+ textStream: ReadableStream,
+ options: CreateMarkdownStreamResponseOptions = {},
+): Response {
+ const {
+ headers = {},
+ status = 200,
+ onStart,
+ onChunk,
+ onFinish,
+ onError,
+ streamOptions,
+ } = options;
+
+ const markdownStream = createMarkdownTreeStream(textStream, streamOptions);
+
+ // Transform to SSE format
+ const sseStream = new ReadableStream({
+ async start(controller) {
+ const reader = markdownStream.getReader();
+ const encoder = new TextEncoder();
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ break;
+ }
+
+ // Call appropriate callbacks
+ if (value.type === 'markdown-tree-start') {
+ onStart?.(value.id);
+ } else if (value.type === 'markdown-tree-end') {
+ onFinish?.(value.tree);
+ }
+
+ onChunk?.(value);
+
+ // Format as SSE
+ const data = JSON.stringify(value);
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`));
+ }
+
+ controller.close();
+ } catch (error) {
+ onError?.(error instanceof Error ? error : new Error(String(error)));
+ controller.error(error);
+ }
+ },
+ });
+
+ return new Response(sseStream, {
+ status,
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ ...headers,
+ },
+ });
+}
+
+/**
+ * Parse a complete markdown string and return it as a tree.
+ *
+ * Use this for non-streaming scenarios or to parse stored content.
+ */
+export { parseMarkdownToTree } from './markdown-parser';
+
+/**
+ * Apply a patch to a markdown tree.
+ *
+ * This is useful on the client side to apply received patches.
+ */
+export {
+ applyMarkdownTreePatch,
+ createMarkdownTreeDiff,
+ isStreamingAppend,
+} from './markdown-tree-diff';
+
+/**
+ * Integrate markdown tree streaming with AI SDK's streamText result.
+ *
+ * @example
+ * ```ts
+ * import { streamText } from 'ai';
+ * import { openai } from '@ai-sdk/openai';
+ * import { createMarkdownStreamFromStreamText } from '@ai-sdk/react-native/server';
+ *
+ * const result = streamText({
+ * model: openai('gpt-4o'),
+ * messages,
+ * });
+ *
+ * // Get a markdown tree stream from the text stream
+ * const markdownStream = createMarkdownStreamFromStreamText(result);
+ *
+ * // Or get a Response directly
+ * return createMarkdownStreamResponseFromStreamText(result);
+ * ```
+ */
+export function createMarkdownStreamFromStreamText(
+ result: { textStream: ReadableStream },
+ options?: MarkdownStreamOptions,
+): ReadableStream {
+ return createMarkdownTreeStream(result.textStream, options);
+}
+
+/**
+ * Create an HTTP Response from AI SDK's streamText result with markdown tree streaming.
+ */
+export function createMarkdownStreamResponseFromStreamText(
+ result: { textStream: ReadableStream },
+ options?: CreateMarkdownStreamResponseOptions,
+): Response {
+ return createMarkdownStreamResponse(result.textStream, options);
+}
+
+/**
+ * Create a UI message stream that includes markdown tree updates.
+ *
+ * This integrates with the AI SDK's UI message streaming system while also
+ * sending parsed markdown tree updates for efficient React Native rendering.
+ */
+export interface MarkdownUIMessageStreamOptions extends MarkdownStreamOptions {
+ /**
+ * Whether to include the raw text in addition to the markdown tree.
+ * Default: false
+ */
+ includeRawText?: boolean;
+}
+
+/**
+ * Custom chunk types that can be sent alongside standard UI message chunks.
+ */
+export type MarkdownUIMessageChunk =
+ | { type: 'markdown-tree-start'; id: string }
+ | { type: 'markdown-tree-patch'; id: string; patch: MarkdownTreePatch }
+ | { type: 'markdown-tree-snapshot'; id: string; tree: MarkdownRoot }
+ | { type: 'markdown-tree-end'; id: string; tree: MarkdownRoot };
diff --git a/packages/react-native/src/server/markdown-tree-diff.test.ts b/packages/react-native/src/server/markdown-tree-diff.test.ts
new file mode 100644
index 000000000000..5625d83eebf3
--- /dev/null
+++ b/packages/react-native/src/server/markdown-tree-diff.test.ts
@@ -0,0 +1,572 @@
+import { describe, it, expect } from 'vitest';
+import {
+ applyMarkdownTreePatch,
+ createMarkdownTreeDiff,
+ estimatePatchEfficiency,
+ isStreamingAppend,
+} from './markdown-tree-diff';
+import type { MarkdownRoot } from '../types';
+
+describe('createMarkdownTreeDiff', () => {
+ it('should return full snapshot for null old tree', () => {
+ const newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ ],
+ };
+
+ const patch = createMarkdownTreeDiff(null, newTree);
+
+ expect(patch).toHaveLength(1);
+ expect(patch[0]).toEqual({
+ op: 'replace',
+ path: '',
+ value: newTree,
+ });
+ });
+
+ it('should detect text appends', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ ],
+ };
+
+ const newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello, world!' }],
+ },
+ ],
+ };
+
+ const patch = createMarkdownTreeDiff(oldTree, newTree);
+
+ expect(patch).toContainEqual({
+ op: 'append-text',
+ path: '/children/0/children/0/value',
+ value: ', world!',
+ });
+ });
+
+ it('should detect added children', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'First' }],
+ },
+ ],
+ };
+
+ const newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'First' }],
+ },
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Second' }],
+ },
+ ],
+ };
+
+ const patch = createMarkdownTreeDiff(oldTree, newTree);
+
+ expect(patch).toContainEqual({
+ op: 'add',
+ path: '/children/1',
+ value: {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Second' }],
+ },
+ });
+ });
+
+ it('should detect removed children', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'First' }],
+ },
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Second' }],
+ },
+ ],
+ };
+
+ const newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'First' }],
+ },
+ ],
+ };
+
+ const patch = createMarkdownTreeDiff(oldTree, newTree);
+
+ expect(patch).toContainEqual({
+ op: 'remove',
+ path: '/children/1',
+ });
+ });
+
+ it('should detect type changes as replacements', () => {
+ // Use a larger tree to ensure patch is more efficient than snapshot
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ value: 'First paragraph with some content to make it larger',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Second paragraph' }],
+ },
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Third paragraph to keep' }],
+ },
+ ],
+ };
+
+ const newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'heading',
+ depth: 1,
+ children: [
+ {
+ type: 'text',
+ value: 'First paragraph with some content to make it larger',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Second paragraph' }],
+ },
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Third paragraph to keep' }],
+ },
+ ],
+ };
+
+ const patch = createMarkdownTreeDiff(oldTree, newTree);
+
+ // Should have a replace operation for the first child that changed type
+ expect(patch).toContainEqual({
+ op: 'replace',
+ path: '/children/0',
+ value: {
+ type: 'heading',
+ depth: 1,
+ children: [
+ {
+ type: 'text',
+ value: 'First paragraph with some content to make it larger',
+ },
+ ],
+ },
+ });
+ });
+
+ it('should return empty patch for identical trees', () => {
+ const tree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ ],
+ };
+
+ const patch = createMarkdownTreeDiff(tree, structuredClone(tree));
+
+ expect(patch).toHaveLength(0);
+ });
+});
+
+describe('applyMarkdownTreePatch', () => {
+ it('should apply replace operation on root', () => {
+ const newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'New' }],
+ },
+ ],
+ };
+
+ const result = applyMarkdownTreePatch(null, [
+ { op: 'replace', path: '', value: newTree },
+ ]);
+
+ expect(result).toEqual(newTree);
+ });
+
+ it('should apply append-text operation', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ ],
+ };
+
+ const result = applyMarkdownTreePatch(oldTree, [
+ {
+ op: 'append-text',
+ path: '/children/0/children/0/value',
+ value: ', world!',
+ },
+ ]);
+
+ expect(result.children[0]).toMatchObject({
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello, world!' }],
+ });
+ });
+
+ it('should apply add operation', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'First' }],
+ },
+ ],
+ };
+
+ const result = applyMarkdownTreePatch(oldTree, [
+ {
+ op: 'add',
+ path: '/children/1',
+ value: {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Second' }],
+ },
+ },
+ ]);
+
+ expect(result.children).toHaveLength(2);
+ expect(result.children[1]).toMatchObject({
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Second' }],
+ });
+ });
+
+ it('should apply remove operation', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'First' }],
+ },
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Second' }],
+ },
+ ],
+ };
+
+ const result = applyMarkdownTreePatch(oldTree, [
+ { op: 'remove', path: '/children/1' },
+ ]);
+
+ expect(result.children).toHaveLength(1);
+ expect(result.children[0]).toMatchObject({
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'First' }],
+ });
+ });
+
+ it('should apply multiple operations in sequence', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ ],
+ };
+
+ const result = applyMarkdownTreePatch(oldTree, [
+ {
+ op: 'append-text',
+ path: '/children/0/children/0/value',
+ value: ', world!',
+ },
+ {
+ op: 'add',
+ path: '/children/1',
+ value: {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'New paragraph' }],
+ },
+ },
+ ]);
+
+ expect(result.children).toHaveLength(2);
+ expect(result.children[0]).toMatchObject({
+ children: [{ type: 'text', value: 'Hello, world!' }],
+ });
+ expect(result.children[1]).toMatchObject({
+ children: [{ type: 'text', value: 'New paragraph' }],
+ });
+ });
+
+ it('should create a new tree without mutating the original', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ ],
+ };
+
+ const originalValue = (oldTree.children[0] as any).children[0].value;
+
+ applyMarkdownTreePatch(oldTree, [
+ {
+ op: 'append-text',
+ path: '/children/0/children/0/value',
+ value: ', world!',
+ },
+ ]);
+
+ // Original tree should be unchanged
+ expect((oldTree.children[0] as any).children[0].value).toBe(originalValue);
+ });
+});
+
+describe('isStreamingAppend', () => {
+ it('should return true for append-text only patches', () => {
+ const patch = [
+ { op: 'append-text' as const, path: '/children/0/value', value: 'more' },
+ ];
+
+ expect(isStreamingAppend(patch)).toBe(true);
+ });
+
+ it('should return true for add operations in children', () => {
+ const patch = [
+ { op: 'add' as const, path: '/children/1', value: { type: 'paragraph' } },
+ ];
+
+ expect(isStreamingAppend(patch)).toBe(true);
+ });
+
+ it('should return false for replace operations', () => {
+ const patch = [
+ {
+ op: 'replace' as const,
+ path: '/children/0',
+ value: { type: 'paragraph' },
+ },
+ ];
+
+ expect(isStreamingAppend(patch)).toBe(false);
+ });
+
+ it('should return false for remove operations', () => {
+ const patch = [{ op: 'remove' as const, path: '/children/0' }];
+
+ expect(isStreamingAppend(patch)).toBe(false);
+ });
+
+ it('should return false for empty patches', () => {
+ expect(isStreamingAppend([])).toBe(false);
+ });
+});
+
+describe('estimatePatchEfficiency', () => {
+ it('should calculate savings correctly', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ ],
+ };
+
+ const newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello, world!' }],
+ },
+ ],
+ };
+
+ const { patchSize, snapshotSize, savings } = estimatePatchEfficiency(
+ oldTree,
+ newTree,
+ );
+
+ expect(patchSize).toBeLessThan(snapshotSize);
+ expect(savings).toBeGreaterThan(0);
+ });
+
+ it('should handle null old tree', () => {
+ const newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ ],
+ };
+
+ const { savings } = estimatePatchEfficiency(null, newTree);
+
+ // Full snapshot, no savings
+ expect(savings).toBeLessThanOrEqual(0);
+ });
+});
+
+describe('round-trip diff and patch', () => {
+ it('should produce identical tree after applying diff', () => {
+ const oldTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'heading',
+ depth: 1,
+ children: [{ type: 'text', value: 'Title' }],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'Some ' },
+ { type: 'strong', children: [{ type: 'text', value: 'bold' }] },
+ { type: 'text', value: ' text.' },
+ ],
+ },
+ ],
+ };
+
+ const newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'heading',
+ depth: 1,
+ children: [{ type: 'text', value: 'Title' }],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ { type: 'text', value: 'Some ' },
+ { type: 'strong', children: [{ type: 'text', value: 'bold' }] },
+ { type: 'text', value: ' text with more content.' },
+ ],
+ },
+ {
+ type: 'code',
+ lang: 'javascript',
+ value: 'console.log("hello");',
+ },
+ ],
+ };
+
+ const patch = createMarkdownTreeDiff(oldTree, newTree);
+ const result = applyMarkdownTreePatch(oldTree, patch);
+
+ expect(result).toEqual(newTree);
+ });
+
+ it('should handle incremental streaming updates', () => {
+ let currentTree: MarkdownRoot = {
+ type: 'root',
+ children: [],
+ };
+
+ // Simulate streaming: add heading
+ let newTree: MarkdownRoot = {
+ type: 'root',
+ children: [
+ {
+ type: 'heading',
+ depth: 1,
+ children: [{ type: 'text', value: 'H' }],
+ },
+ ],
+ };
+ let patch = createMarkdownTreeDiff(currentTree, newTree);
+ currentTree = applyMarkdownTreePatch(currentTree, patch);
+
+ // Continue streaming: extend heading
+ newTree = {
+ type: 'root',
+ children: [
+ {
+ type: 'heading',
+ depth: 1,
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ ],
+ };
+ patch = createMarkdownTreeDiff(currentTree, newTree);
+ currentTree = applyMarkdownTreePatch(currentTree, patch);
+
+ // Add paragraph
+ newTree = {
+ type: 'root',
+ children: [
+ {
+ type: 'heading',
+ depth: 1,
+ children: [{ type: 'text', value: 'Hello' }],
+ },
+ {
+ type: 'paragraph',
+ children: [{ type: 'text', value: 'World' }],
+ },
+ ],
+ };
+ patch = createMarkdownTreeDiff(currentTree, newTree);
+ currentTree = applyMarkdownTreePatch(currentTree, patch);
+
+ expect(currentTree).toEqual(newTree);
+ });
+});
diff --git a/packages/react-native/src/server/markdown-tree-diff.ts b/packages/react-native/src/server/markdown-tree-diff.ts
new file mode 100644
index 000000000000..b70d43510297
--- /dev/null
+++ b/packages/react-native/src/server/markdown-tree-diff.ts
@@ -0,0 +1,318 @@
+import type {
+ MarkdownNode,
+ MarkdownRoot,
+ MarkdownTreePatch,
+ MarkdownTreePatchOp,
+} from '../types';
+
+/**
+ * Creates a minimal diff between two markdown trees.
+ *
+ * This is optimized for streaming scenarios where the new tree is typically
+ * an extension of the old tree (appending content), rather than arbitrary changes.
+ *
+ * The algorithm prioritizes:
+ * 1. Detecting text appends (common in streaming)
+ * 2. Detecting new nodes added at the end
+ * 3. Minimizing patch size
+ */
+export function createMarkdownTreeDiff(
+ oldTree: MarkdownRoot | null,
+ newTree: MarkdownRoot,
+): MarkdownTreePatch {
+ if (!oldTree) {
+ // No old tree, send the whole thing as a snapshot
+ return [{ op: 'replace', path: '', value: newTree }];
+ }
+
+ const patches: MarkdownTreePatchOp[] = [];
+
+ diffNodes(oldTree, newTree, '', patches);
+
+ // If patches are too large, just send a snapshot
+ const patchSize = JSON.stringify(patches).length;
+ const snapshotSize = JSON.stringify(newTree).length;
+
+ if (patchSize > snapshotSize * 0.8) {
+ return [{ op: 'replace', path: '', value: newTree }];
+ }
+
+ return patches;
+}
+
+function diffNodes(
+ oldNode: MarkdownNode,
+ newNode: MarkdownNode,
+ path: string,
+ patches: MarkdownTreePatchOp[],
+): void {
+ // Different types - replace entirely
+ if (oldNode.type !== newNode.type) {
+ patches.push({ op: 'replace', path, value: newNode });
+ return;
+ }
+
+ // Handle text nodes specially for streaming optimization
+ if (oldNode.type === 'text' && newNode.type === 'text') {
+ if (newNode.value.startsWith(oldNode.value)) {
+ // New text is an extension of old text - use append
+ const appendedText = newNode.value.slice(oldNode.value.length);
+ if (appendedText.length > 0) {
+ patches.push({
+ op: 'append-text',
+ path: `${path}/value`,
+ value: appendedText,
+ });
+ }
+ } else if (oldNode.value !== newNode.value) {
+ patches.push({
+ op: 'replace',
+ path: `${path}/value`,
+ value: newNode.value,
+ });
+ }
+ return;
+ }
+
+ // Handle code blocks
+ if (oldNode.type === 'code' && newNode.type === 'code') {
+ if (oldNode.lang !== newNode.lang) {
+ patches.push({
+ op: 'replace',
+ path: `${path}/lang`,
+ value: newNode.lang,
+ });
+ }
+ if (oldNode.meta !== newNode.meta) {
+ patches.push({
+ op: 'replace',
+ path: `${path}/meta`,
+ value: newNode.meta,
+ });
+ }
+ if (newNode.value.startsWith(oldNode.value)) {
+ const appendedText = newNode.value.slice(oldNode.value.length);
+ if (appendedText.length > 0) {
+ patches.push({
+ op: 'append-text',
+ path: `${path}/value`,
+ value: appendedText,
+ });
+ }
+ } else if (oldNode.value !== newNode.value) {
+ patches.push({
+ op: 'replace',
+ path: `${path}/value`,
+ value: newNode.value,
+ });
+ }
+ return;
+ }
+
+ // Handle inline code
+ if (oldNode.type === 'inlineCode' && newNode.type === 'inlineCode') {
+ if (oldNode.value !== newNode.value) {
+ patches.push({
+ op: 'replace',
+ path: `${path}/value`,
+ value: newNode.value,
+ });
+ }
+ return;
+ }
+
+ // Handle nodes with children
+ if ('children' in oldNode && 'children' in newNode) {
+ const oldChildren = oldNode.children as MarkdownNode[];
+ const newChildren = newNode.children as MarkdownNode[];
+
+ // First, diff existing children
+ const minLength = Math.min(oldChildren.length, newChildren.length);
+ for (let i = 0; i < minLength; i++) {
+ diffNodes(
+ oldChildren[i],
+ newChildren[i],
+ `${path}/children/${i}`,
+ patches,
+ );
+ }
+
+ // Handle removed children
+ if (oldChildren.length > newChildren.length) {
+ for (let i = oldChildren.length - 1; i >= newChildren.length; i--) {
+ patches.push({ op: 'remove', path: `${path}/children/${i}` });
+ }
+ }
+
+ // Handle added children
+ if (newChildren.length > oldChildren.length) {
+ for (let i = oldChildren.length; i < newChildren.length; i++) {
+ patches.push({
+ op: 'add',
+ path: `${path}/children/${i}`,
+ value: newChildren[i],
+ });
+ }
+ }
+ }
+
+ // Handle other properties that might differ
+ const oldObj = oldNode as unknown as { [key: string]: unknown };
+ const newObj = newNode as unknown as { [key: string]: unknown };
+ const oldKeys = Object.keys(oldObj).filter(
+ k => k !== 'type' && k !== 'children',
+ );
+ const newKeys = Object.keys(newObj).filter(
+ k => k !== 'type' && k !== 'children',
+ );
+
+ for (const key of new Set([...oldKeys, ...newKeys])) {
+ const oldValue = oldObj[key];
+ const newValue = newObj[key];
+
+ if (oldValue !== newValue) {
+ if (newValue === undefined) {
+ patches.push({ op: 'remove', path: `${path}/${key}` });
+ } else if (oldValue === undefined) {
+ patches.push({ op: 'add', path: `${path}/${key}`, value: newValue });
+ } else {
+ patches.push({
+ op: 'replace',
+ path: `${path}/${key}`,
+ value: newValue,
+ });
+ }
+ }
+ }
+}
+
+/**
+ * Apply a patch to a markdown tree, returning a new tree.
+ *
+ * This function is immutable - it does not modify the original tree.
+ */
+export function applyMarkdownTreePatch(
+ tree: MarkdownRoot | null,
+ patch: MarkdownTreePatch,
+): MarkdownRoot {
+ // Start with a deep clone to ensure immutability
+ let result = tree
+ ? structuredClone(tree)
+ : ({ type: 'root', children: [] } as MarkdownRoot);
+
+ for (const op of patch) {
+ result = applySingleOp(result, op);
+ }
+
+ return result;
+}
+
+type AnyObject = { [key: string]: unknown };
+
+function applySingleOp(
+ tree: MarkdownRoot,
+ op: MarkdownTreePatchOp,
+): MarkdownRoot {
+ if (op.path === '') {
+ // Replace entire tree
+ if (op.op === 'replace') {
+ return op.value as MarkdownRoot;
+ }
+ throw new Error(`Invalid operation on root: ${op.op}`);
+ }
+
+ // Parse the path
+ const segments = op.path.split('/').filter(Boolean);
+
+ // Navigate to the parent
+ let current: AnyObject | unknown[] = tree as unknown as AnyObject;
+ for (let i = 0; i < segments.length - 1; i++) {
+ const segment = segments[i];
+ if (Array.isArray(current)) {
+ current = current[parseInt(segment, 10)] as AnyObject;
+ } else {
+ current = current[segment] as AnyObject;
+ }
+ }
+
+ const lastSegment = segments[segments.length - 1];
+
+ switch (op.op) {
+ case 'replace':
+ if (Array.isArray(current)) {
+ current[parseInt(lastSegment, 10)] = op.value;
+ } else {
+ current[lastSegment] = op.value;
+ }
+ break;
+
+ case 'add':
+ if (Array.isArray(current)) {
+ const index = parseInt(lastSegment, 10);
+ if (index === current.length) {
+ current.push(op.value);
+ } else {
+ current.splice(index, 0, op.value);
+ }
+ } else {
+ current[lastSegment] = op.value;
+ }
+ break;
+
+ case 'remove':
+ if (Array.isArray(current)) {
+ current.splice(parseInt(lastSegment, 10), 1);
+ } else {
+ delete current[lastSegment];
+ }
+ break;
+
+ case 'append-text':
+ if (Array.isArray(current)) {
+ const item = current[parseInt(lastSegment, 10)];
+ if (typeof item === 'string') {
+ current[parseInt(lastSegment, 10)] = item + op.value;
+ }
+ } else if (typeof current[lastSegment] === 'string') {
+ current[lastSegment] = (current[lastSegment] as string) + op.value;
+ }
+ break;
+ }
+
+ return tree;
+}
+
+/**
+ * Checks if a patch is a "streaming append" pattern.
+ *
+ * Streaming appends are typically small patches that only add to existing
+ * content rather than modifying structure. These can be applied more efficiently.
+ */
+export function isStreamingAppend(patch: MarkdownTreePatch): boolean {
+ if (patch.length === 0) return false;
+
+ // A streaming append typically has:
+ // - Only 'append-text' operations, OR
+ // - Only 'add' operations at the end of children arrays
+
+ return patch.every(op => {
+ if (op.op === 'append-text') return true;
+ if (op.op === 'add' && op.path.includes('/children/')) return true;
+ return false;
+ });
+}
+
+/**
+ * Estimate the size savings of using patches vs a full snapshot.
+ */
+export function estimatePatchEfficiency(
+ oldTree: MarkdownRoot | null,
+ newTree: MarkdownRoot,
+): { patchSize: number; snapshotSize: number; savings: number } {
+ const patch = createMarkdownTreeDiff(oldTree, newTree);
+ const patchSize = JSON.stringify(patch).length;
+ const snapshotSize = JSON.stringify(newTree).length;
+ const savings = snapshotSize > 0 ? (1 - patchSize / snapshotSize) * 100 : 0;
+
+ return { patchSize, snapshotSize, savings };
+}
diff --git a/packages/react-native/src/types.ts b/packages/react-native/src/types.ts
new file mode 100644
index 000000000000..f3380d40da8b
--- /dev/null
+++ b/packages/react-native/src/types.ts
@@ -0,0 +1,349 @@
+/**
+ * Base interface for all markdown tree nodes
+ */
+export interface MarkdownNodeBase {
+ type: string;
+}
+
+/**
+ * Root node containing the entire markdown document
+ */
+export interface MarkdownRoot extends MarkdownNodeBase {
+ type: 'root';
+ children: MarkdownNode[];
+}
+
+/**
+ * Paragraph containing inline content
+ */
+export interface MarkdownParagraph extends MarkdownNodeBase {
+ type: 'paragraph';
+ children: MarkdownInlineNode[];
+}
+
+/**
+ * Heading with depth level 1-6
+ */
+export interface MarkdownHeading extends MarkdownNodeBase {
+ type: 'heading';
+ depth: 1 | 2 | 3 | 4 | 5 | 6;
+ children: MarkdownInlineNode[];
+}
+
+/**
+ * Plain text content
+ */
+export interface MarkdownText extends MarkdownNodeBase {
+ type: 'text';
+ value: string;
+}
+
+/**
+ * Bold/strong text
+ */
+export interface MarkdownStrong extends MarkdownNodeBase {
+ type: 'strong';
+ children: MarkdownInlineNode[];
+}
+
+/**
+ * Italic/emphasized text
+ */
+export interface MarkdownEmphasis extends MarkdownNodeBase {
+ type: 'emphasis';
+ children: MarkdownInlineNode[];
+}
+
+/**
+ * Strikethrough text
+ */
+export interface MarkdownStrikethrough extends MarkdownNodeBase {
+ type: 'strikethrough';
+ children: MarkdownInlineNode[];
+}
+
+/**
+ * Fenced code block
+ */
+export interface MarkdownCode extends MarkdownNodeBase {
+ type: 'code';
+ lang?: string;
+ meta?: string;
+ value: string;
+}
+
+/**
+ * Inline code
+ */
+export interface MarkdownInlineCode extends MarkdownNodeBase {
+ type: 'inlineCode';
+ value: string;
+}
+
+/**
+ * Hyperlink
+ */
+export interface MarkdownLink extends MarkdownNodeBase {
+ type: 'link';
+ url: string;
+ title?: string;
+ children: MarkdownInlineNode[];
+}
+
+/**
+ * Image
+ */
+export interface MarkdownImage extends MarkdownNodeBase {
+ type: 'image';
+ url: string;
+ alt?: string;
+ title?: string;
+}
+
+/**
+ * Ordered or unordered list
+ */
+export interface MarkdownList extends MarkdownNodeBase {
+ type: 'list';
+ ordered: boolean;
+ start?: number;
+ spread?: boolean;
+ children: MarkdownListItem[];
+}
+
+/**
+ * List item
+ */
+export interface MarkdownListItem extends MarkdownNodeBase {
+ type: 'listItem';
+ spread?: boolean;
+ checked?: boolean | null;
+ children: MarkdownNode[];
+}
+
+/**
+ * Block quote
+ */
+export interface MarkdownBlockquote extends MarkdownNodeBase {
+ type: 'blockquote';
+ children: MarkdownNode[];
+}
+
+/**
+ * Thematic break (horizontal rule)
+ */
+export interface MarkdownThematicBreak extends MarkdownNodeBase {
+ type: 'thematicBreak';
+}
+
+/**
+ * Line break
+ */
+export interface MarkdownBreak extends MarkdownNodeBase {
+ type: 'break';
+}
+
+/**
+ * Table
+ */
+export interface MarkdownTable extends MarkdownNodeBase {
+ type: 'table';
+ align?: Array<'left' | 'center' | 'right' | null>;
+ children: MarkdownTableRow[];
+}
+
+/**
+ * Table row
+ */
+export interface MarkdownTableRow extends MarkdownNodeBase {
+ type: 'tableRow';
+ children: MarkdownTableCell[];
+}
+
+/**
+ * Table cell
+ */
+export interface MarkdownTableCell extends MarkdownNodeBase {
+ type: 'tableCell';
+ children: MarkdownInlineNode[];
+}
+
+/**
+ * HTML content (raw)
+ */
+export interface MarkdownHtml extends MarkdownNodeBase {
+ type: 'html';
+ value: string;
+}
+
+/**
+ * All inline node types
+ */
+export type MarkdownInlineNode =
+ | MarkdownText
+ | MarkdownStrong
+ | MarkdownEmphasis
+ | MarkdownStrikethrough
+ | MarkdownInlineCode
+ | MarkdownLink
+ | MarkdownImage
+ | MarkdownBreak
+ | MarkdownHtml;
+
+/**
+ * All block-level node types
+ */
+export type MarkdownBlockNode =
+ | MarkdownParagraph
+ | MarkdownHeading
+ | MarkdownCode
+ | MarkdownList
+ | MarkdownBlockquote
+ | MarkdownThematicBreak
+ | MarkdownTable
+ | MarkdownHtml;
+
+/**
+ * All markdown node types (union)
+ */
+export type MarkdownNode =
+ | MarkdownRoot
+ | MarkdownParagraph
+ | MarkdownHeading
+ | MarkdownText
+ | MarkdownStrong
+ | MarkdownEmphasis
+ | MarkdownStrikethrough
+ | MarkdownCode
+ | MarkdownInlineCode
+ | MarkdownLink
+ | MarkdownImage
+ | MarkdownList
+ | MarkdownListItem
+ | MarkdownBlockquote
+ | MarkdownThematicBreak
+ | MarkdownBreak
+ | MarkdownTable
+ | MarkdownTableRow
+ | MarkdownTableCell
+ | MarkdownHtml;
+
+/**
+ * Operation types for JSON patching
+ */
+export type MarkdownTreePatchOp =
+ | {
+ op: 'replace';
+ path: string;
+ value: unknown;
+ }
+ | {
+ op: 'add';
+ path: string;
+ value: unknown;
+ }
+ | {
+ op: 'remove';
+ path: string;
+ }
+ | {
+ op: 'append-text';
+ path: string;
+ value: string;
+ };
+
+/**
+ * A batch of patch operations
+ */
+export type MarkdownTreePatch = MarkdownTreePatchOp[];
+
+/**
+ * UI Message chunk types for markdown tree streaming
+ */
+export type MarkdownTreeChunk =
+ | {
+ type: 'markdown-tree-start';
+ id: string;
+ }
+ | {
+ type: 'markdown-tree-patch';
+ id: string;
+ patch: MarkdownTreePatch;
+ }
+ | {
+ type: 'markdown-tree-snapshot';
+ id: string;
+ tree: MarkdownRoot;
+ }
+ | {
+ type: 'markdown-tree-end';
+ id: string;
+ tree: MarkdownRoot;
+ };
+
+/**
+ * Message with markdown tree
+ */
+export interface MarkdownMessage {
+ id: string;
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ markdownTree?: MarkdownRoot;
+ createdAt?: Date;
+}
+
+/**
+ * Props for custom component renderers
+ */
+export interface MarkdownComponentProps {
+ node: T;
+ children?: React.ReactNode;
+}
+
+/**
+ * Custom components map for MarkdownRenderer
+ */
+export interface MarkdownComponents {
+ root?: React.ComponentType>;
+ paragraph?: React.ComponentType>;
+ heading?: React.ComponentType<
+ MarkdownComponentProps & { level: 1 | 2 | 3 | 4 | 5 | 6 }
+ >;
+ text?: React.ComponentType>;
+ strong?: React.ComponentType>;
+ emphasis?: React.ComponentType>;
+ strikethrough?: React.ComponentType<
+ MarkdownComponentProps
+ >;
+ code?: React.ComponentType<
+ MarkdownComponentProps & { language?: string; value: string }
+ >;
+ inlineCode?: React.ComponentType<
+ MarkdownComponentProps & { value: string }
+ >;
+ link?: React.ComponentType<
+ MarkdownComponentProps & { href: string; title?: string }
+ >;
+ image?: React.ComponentType<
+ MarkdownComponentProps & {
+ src: string;
+ alt?: string;
+ title?: string;
+ }
+ >;
+ list?: React.ComponentType<
+ MarkdownComponentProps & { ordered: boolean }
+ >;
+ listItem?: React.ComponentType>;
+ blockquote?: React.ComponentType>;
+ thematicBreak?: React.ComponentType<
+ MarkdownComponentProps
+ >;
+ break?: React.ComponentType>;
+ table?: React.ComponentType>;
+ tableRow?: React.ComponentType>;
+ tableCell?: React.ComponentType>;
+ html?: React.ComponentType<
+ MarkdownComponentProps & { value: string }
+ >;
+}
diff --git a/packages/react-native/src/use-markdown-stream.tsx b/packages/react-native/src/use-markdown-stream.tsx
new file mode 100644
index 000000000000..18a514946e31
--- /dev/null
+++ b/packages/react-native/src/use-markdown-stream.tsx
@@ -0,0 +1,281 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { MarkdownRoot, MarkdownTreeChunk } from './types';
+import { applyMarkdownTreePatch } from './apply-patch';
+
+export type MarkdownStreamStatus = 'idle' | 'streaming' | 'done' | 'error';
+
+export interface UseMarkdownStreamOptions {
+ /**
+ * The API endpoint that streams markdown tree patches.
+ * This endpoint should use `createMarkdownStreamResponse` from the server utilities.
+ */
+ api: string;
+
+ /**
+ * Additional headers to send with the request.
+ */
+ headers?: Record;
+
+ /**
+ * Callback when streaming starts.
+ */
+ onStart?: () => void;
+
+ /**
+ * Callback when streaming finishes.
+ */
+ onFinish?: (tree: MarkdownRoot) => void;
+
+ /**
+ * Callback on error.
+ */
+ onError?: (error: Error) => void;
+
+ /**
+ * Custom fetch implementation.
+ */
+ fetch?: typeof fetch;
+}
+
+export interface UseMarkdownStreamResult {
+ /**
+ * The current markdown tree (updated in real-time as patches arrive).
+ */
+ tree: MarkdownRoot | null;
+
+ /**
+ * Current status of the stream.
+ */
+ status: MarkdownStreamStatus;
+
+ /**
+ * Error if any occurred.
+ */
+ error: Error | null;
+
+ /**
+ * Start streaming by sending a request to the API.
+ */
+ stream: (body: unknown) => Promise;
+
+ /**
+ * Stop the current stream.
+ */
+ stop: () => void;
+
+ /**
+ * Reset to initial state.
+ */
+ reset: () => void;
+}
+
+/**
+ * Hook for consuming server-streamed markdown tree patches.
+ *
+ * This implements the v0 approach: the server parses markdown and streams
+ * JSON tree patches to the client. The client just applies patches and renders.
+ *
+ * **This is the recommended approach for React Native** as it moves all
+ * markdown parsing work to the server.
+ *
+ * @example
+ * ```tsx
+ * // Server: use createMarkdownStreamResponse
+ * // Client:
+ * function StreamingMarkdown() {
+ * const { tree, stream, status } = useMarkdownStream({
+ * api: '/api/markdown-stream',
+ * });
+ *
+ * return (
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export function useMarkdownStream(
+ options: UseMarkdownStreamOptions,
+): UseMarkdownStreamResult {
+ const {
+ api,
+ headers: globalHeaders,
+ onStart,
+ onFinish,
+ onError,
+ fetch: fetchFn = fetch,
+ } = options;
+
+ const [tree, setTree] = useState(null);
+ const [status, setStatus] = useState('idle');
+ const [error, setError] = useState(null);
+
+ const abortControllerRef = useRef(null);
+ const treeRef = useRef(null);
+
+ const stop = useCallback(() => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+ if (status === 'streaming') {
+ setStatus('done');
+ }
+ }, [status]);
+
+ const reset = useCallback(() => {
+ stop();
+ setTree(null);
+ treeRef.current = null;
+ setStatus('idle');
+ setError(null);
+ }, [stop]);
+
+ const stream = useCallback(
+ async (body: unknown) => {
+ // Reset state
+ setTree(null);
+ treeRef.current = null;
+ setError(null);
+ setStatus('streaming');
+
+ abortControllerRef.current = new AbortController();
+
+ try {
+ onStart?.();
+
+ const response = await fetchFn(api, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'text/event-stream',
+ ...globalHeaders,
+ },
+ body: JSON.stringify(body),
+ signal: abortControllerRef.current.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ if (!response.body) {
+ throw new Error('Response body is null');
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process SSE events
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6);
+ if (data === '[DONE]') continue;
+
+ try {
+ const chunk: MarkdownTreeChunk = JSON.parse(data);
+
+ switch (chunk.type) {
+ case 'markdown-tree-start':
+ treeRef.current = { type: 'root', children: [] };
+ setTree(treeRef.current);
+ break;
+
+ case 'markdown-tree-snapshot':
+ treeRef.current = chunk.tree;
+ setTree(chunk.tree);
+ break;
+
+ case 'markdown-tree-patch':
+ if (treeRef.current) {
+ treeRef.current = applyMarkdownTreePatch(
+ treeRef.current,
+ chunk.patch,
+ );
+ setTree(treeRef.current);
+ }
+ break;
+
+ case 'markdown-tree-end':
+ treeRef.current = chunk.tree;
+ setTree(chunk.tree);
+ onFinish?.(chunk.tree);
+ break;
+ }
+ } catch (e) {
+ console.warn('Failed to parse markdown stream chunk:', e);
+ }
+ }
+ }
+ }
+
+ setStatus('done');
+ } catch (err) {
+ if ((err as Error).name === 'AbortError') {
+ setStatus('done');
+ return;
+ }
+
+ const error = err instanceof Error ? err : new Error(String(err));
+ setError(error);
+ setStatus('error');
+ onError?.(error);
+ } finally {
+ abortControllerRef.current = null;
+ }
+ },
+ [api, fetchFn, globalHeaders, onStart, onFinish, onError],
+ );
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ };
+ }, []);
+
+ return {
+ tree,
+ status,
+ error,
+ stream,
+ stop,
+ reset,
+ };
+}
+
+/**
+ * Hook that combines useChat with server-side markdown parsing.
+ *
+ * This gives you the full chat experience (messages, tools, etc.) while
+ * using server-side markdown parsing for text parts.
+ *
+ * The server should stream markdown trees using `createMarkdownStreamResponse`
+ * for text content, while the standard UI message stream handles everything else.
+ */
+export interface MarkdownChatMessage {
+ id: string;
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ /**
+ * Pre-parsed markdown tree from the server.
+ * Only present for assistant messages when using server-side parsing.
+ */
+ markdownTree?: MarkdownRoot;
+}
diff --git a/packages/react-native/src/use-markdown-tree.tsx b/packages/react-native/src/use-markdown-tree.tsx
new file mode 100644
index 000000000000..7da06267f1a8
--- /dev/null
+++ b/packages/react-native/src/use-markdown-tree.tsx
@@ -0,0 +1,142 @@
+import React, { useMemo, memo } from 'react';
+import type { MarkdownRoot } from './types';
+import { parseMarkdownToTree } from './server/markdown-parser';
+import {
+ MarkdownRenderer,
+ type MarkdownRendererProps,
+} from './markdown-renderer';
+
+export interface UseMarkdownTreeOptions {
+ /**
+ * Whether to parse the markdown. Set to false to disable parsing.
+ * Useful for conditional rendering.
+ */
+ enabled?: boolean;
+}
+
+/**
+ * Hook to parse markdown text into a tree structure.
+ *
+ * This hook memoizes the parsing result to avoid re-parsing on every render.
+ * Use this when you want to render markdown content from text parts.
+ *
+ * @example
+ * ```tsx
+ * function TextPartRenderer({ part }: { part: TextUIPart }) {
+ * const tree = useMarkdownTree(part.text);
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function useMarkdownTree(
+ text: string | undefined | null,
+ options: UseMarkdownTreeOptions = {},
+): MarkdownRoot | null {
+ const { enabled = true } = options;
+
+ return useMemo(() => {
+ if (!enabled || !text) {
+ return null;
+ }
+ return parseMarkdownToTree(text);
+ }, [text, enabled]);
+}
+
+/**
+ * Props for the MarkdownText component.
+ */
+export interface MarkdownTextProps extends Omit {
+ /**
+ * The markdown text to render.
+ */
+ text: string | undefined | null;
+
+ /**
+ * Fallback content to show when text is empty.
+ */
+ fallback?: React.ReactNode;
+
+ /**
+ * Whether to parse and render the markdown.
+ * Set to false to just show the raw text.
+ */
+ parseMarkdown?: boolean;
+}
+
+/**
+ * Component that renders markdown text efficiently.
+ *
+ * This component parses markdown on the client and renders it using
+ * the MarkdownRenderer. For even better performance on React Native,
+ * consider using server-side parsing with the /server utilities.
+ *
+ * @example
+ * ```tsx
+ * // Basic usage with text part
+ * function MessagePart({ part }: { part: TextUIPart }) {
+ * return ;
+ * }
+ *
+ * // With custom components
+ * function MessagePart({ part }: { part: TextUIPart }) {
+ * return (
+ * (
+ * {value}
+ * ),
+ * }}
+ * />
+ * );
+ * }
+ *
+ * // Usage in a full chat message
+ * function ChatMessage({ message }: { message: UIMessage }) {
+ * return (
+ *
+ * {message.parts.map((part, index) => {
+ * switch (part.type) {
+ * case 'text':
+ * return ;
+ * case 'reasoning':
+ * return ;
+ * case 'tool-*':
+ * return ;
+ * default:
+ * return null;
+ * }
+ * })}
+ *
+ * );
+ * }
+ * ```
+ */
+export const MarkdownText = memo(function MarkdownText({
+ text,
+ fallback = null,
+ parseMarkdown = true,
+ ...rendererProps
+}: MarkdownTextProps): React.ReactElement | null {
+ const tree = useMarkdownTree(text, { enabled: parseMarkdown });
+
+ if (!text) {
+ return <>{fallback}>;
+ }
+
+ if (!parseMarkdown || !tree) {
+ // Return raw text without markdown parsing
+ return <>{text}>;
+ }
+
+ return ;
+});
+
+/**
+ * Utility function to parse markdown text to a tree.
+ *
+ * Use this for one-off parsing outside of React components.
+ * Inside components, prefer useMarkdownTree for memoization.
+ */
+export { parseMarkdownToTree } from './server/markdown-parser';
diff --git a/packages/react-native/tsconfig.build.json b/packages/react-native/tsconfig.build.json
new file mode 100644
index 000000000000..80b6a0a84612
--- /dev/null
+++ b/packages/react-native/tsconfig.build.json
@@ -0,0 +1,6 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "composite": false
+ }
+}
diff --git a/packages/react-native/tsconfig.json b/packages/react-native/tsconfig.json
new file mode 100644
index 000000000000..9cc22f3c24bb
--- /dev/null
+++ b/packages/react-native/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./node_modules/@vercel/ai-tsconfig/react-library.json",
+ "compilerOptions": {
+ "target": "ES2018",
+ "stripInternal": true,
+ "composite": true,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["dist", "build", "node_modules", "tsup.config.ts"],
+ "references": [{ "path": "../react" }]
+}
diff --git a/packages/react-native/tsup.config.ts b/packages/react-native/tsup.config.ts
new file mode 100644
index 000000000000..e58f2306c986
--- /dev/null
+++ b/packages/react-native/tsup.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig([
+ {
+ entry: ['src/index.ts', 'src/server/index.ts'],
+ outDir: 'dist',
+ banner: {},
+ format: ['cjs', 'esm'],
+ dts: true,
+ sourcemap: true,
+ },
+]);
diff --git a/packages/react-native/turbo.json b/packages/react-native/turbo.json
new file mode 100644
index 000000000000..35be9c68fa8b
--- /dev/null
+++ b/packages/react-native/turbo.json
@@ -0,0 +1,8 @@
+{
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "outputs": ["dist/**"]
+ }
+ }
+}
diff --git a/packages/react-native/vitest.node.config.js b/packages/react-native/vitest.node.config.js
new file mode 100644
index 000000000000..c1184646d6de
--- /dev/null
+++ b/packages/react-native/vitest.node.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ test: {
+ environment: 'node',
+ include: ['src/**/*.test.ts'],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ce750ca66c36..6055f21e0c3f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2674,6 +2674,43 @@ importers:
specifier: 3.25.76
version: 3.25.76
+ packages/react-native:
+ dependencies:
+ '@ai-sdk/react':
+ specifier: workspace:*
+ version: link:../react
+ react:
+ specifier: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
+ version: 18.3.1
+ throttleit:
+ specifier: 2.1.0
+ version: 2.1.0
+ devDependencies:
+ '@types/node':
+ specifier: 20.17.24
+ version: 20.17.24
+ '@types/react':
+ specifier: ^18
+ version: 18.3.3
+ '@vercel/ai-tsconfig':
+ specifier: workspace:*
+ version: link:../../tools/tsconfig
+ eslint:
+ specifier: 8.57.1
+ version: 8.57.1
+ eslint-config-vercel-ai:
+ specifier: workspace:*
+ version: link:../../tools/eslint-config
+ tsup:
+ specifier: ^7.2.0
+ version: 7.2.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.17.24)(typescript@5.8.3))(typescript@5.8.3)
+ typescript:
+ specifier: 5.8.3
+ version: 5.8.3
+ zod:
+ specifier: 3.25.76
+ version: 3.25.76
+
packages/replicate:
dependencies:
'@ai-sdk/provider':
@@ -19430,7 +19467,7 @@ snapshots:
dependencies:
'@ampproject/remapping': 2.3.0
'@angular-devkit/architect': 0.2003.3(chokidar@4.0.3)
- '@angular-devkit/build-webpack': 0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)
+ '@angular-devkit/build-webpack': 0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9))
'@angular-devkit/core': 20.3.3(chokidar@4.0.3)
'@angular/build': 20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(@angular/compiler@20.3.2)(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@20.17.24)(chokidar@4.0.3)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.43.1)(tslib@2.8.1)(tsx@4.19.2)(typescript@5.8.3)(vitest@2.1.4(@edge-runtime/vm@5.0.0)(@types/node@22.7.4)(jsdom@26.0.0)(less@4.4.0)(lightningcss@1.30.2)(msw@2.7.0(@types/node@22.7.4)(typescript@5.8.3))(sass@1.90.0)(terser@5.43.1))(yaml@2.7.0)
'@angular/compiler-cli': 20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3)
@@ -19444,13 +19481,13 @@ snapshots:
'@babel/preset-env': 7.28.3(@babel/core@7.28.3)
'@babel/runtime': 7.28.3
'@discoveryjs/json-ext': 0.6.3
- '@ngtools/webpack': 20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2)
+ '@ngtools/webpack': 20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9))
ansi-colors: 4.1.3
autoprefixer: 10.4.21(postcss@8.5.6)
- babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2)
+ babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9))
browserslist: 4.25.1
- copy-webpack-plugin: 13.0.1(webpack@5.101.2)
- css-loader: 7.1.2(webpack@5.101.2)
+ copy-webpack-plugin: 13.0.1(webpack@5.101.2(esbuild@0.25.9))
+ css-loader: 7.1.2(webpack@5.101.2(esbuild@0.25.9))
esbuild-wasm: 0.25.9
fast-glob: 3.3.3
http-proxy-middleware: 3.0.5
@@ -19458,22 +19495,22 @@ snapshots:
jsonc-parser: 3.3.1
karma-source-map-support: 1.4.0
less: 4.4.0
- less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2)
- license-webpack-plugin: 4.0.2(webpack@5.101.2)
+ less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9))
+ license-webpack-plugin: 4.0.2(webpack@5.101.2(esbuild@0.25.9))
loader-utils: 3.3.1
- mini-css-extract-plugin: 2.9.4(webpack@5.101.2)
+ mini-css-extract-plugin: 2.9.4(webpack@5.101.2(esbuild@0.25.9))
open: 10.2.0
ora: 8.2.0
picomatch: 4.0.3
piscina: 5.1.3
postcss: 8.5.6
- postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2)
+ postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9))
resolve-url-loader: 5.0.0
rxjs: 7.8.2
sass: 1.90.0
- sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2)
+ sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9))
semver: 7.7.2
- source-map-loader: 5.0.0(webpack@5.101.2)
+ source-map-loader: 5.0.0(webpack@5.101.2(esbuild@0.25.9))
source-map-support: 0.5.21
terser: 5.43.1
tree-kill: 1.2.2
@@ -19483,7 +19520,7 @@ snapshots:
webpack-dev-middleware: 7.4.2(webpack@5.101.2)
webpack-dev-server: 5.2.2(webpack@5.101.2)
webpack-merge: 6.0.1
- webpack-subresource-integrity: 5.1.0(webpack@5.101.2)
+ webpack-subresource-integrity: 5.1.0(webpack@5.101.2(esbuild@0.25.9))
optionalDependencies:
'@angular/core': 20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)
'@angular/platform-browser': 20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))
@@ -19513,7 +19550,7 @@ snapshots:
- webpack-cli
- yaml
- '@angular-devkit/build-webpack@0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)':
+ '@angular-devkit/build-webpack@0.2003.3(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2(esbuild@0.25.9))':
dependencies:
'@angular-devkit/architect': 0.2003.3(chokidar@4.0.3)
rxjs: 7.8.2
@@ -24647,7 +24684,7 @@ snapshots:
'@next/swc-win32-x64-msvc@15.5.7':
optional: true
- '@ngtools/webpack@20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2)':
+ '@ngtools/webpack@20.3.3(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9))':
dependencies:
'@angular/compiler-cli': 20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3)
typescript: 5.8.3
@@ -29588,7 +29625,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2):
+ babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
'@babel/core': 7.28.3
find-up: 5.0.0
@@ -30335,7 +30372,7 @@ snapshots:
dependencies:
is-what: 5.5.0
- copy-webpack-plugin@13.0.1(webpack@5.101.2):
+ copy-webpack-plugin@13.0.1(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
glob-parent: 6.0.2
normalize-path: 3.0.0
@@ -30437,7 +30474,7 @@ snapshots:
dependencies:
postcss: 8.5.6
- css-loader@7.1.2(webpack@5.101.2):
+ css-loader@7.1.2(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
icss-utils: 5.1.0(postcss@8.5.6)
postcss: 8.5.6
@@ -34457,7 +34494,7 @@ snapshots:
dependencies:
readable-stream: 2.3.8
- less-loader@12.3.0(less@4.4.0)(webpack@5.101.2):
+ less-loader@12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
less: 4.4.0
optionalDependencies:
@@ -34484,7 +34521,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
- license-webpack-plugin@4.0.2(webpack@5.101.2):
+ license-webpack-plugin@4.0.2(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
webpack-sources: 3.3.3
optionalDependencies:
@@ -35348,7 +35385,7 @@ snapshots:
min-indent@1.0.1: {}
- mini-css-extract-plugin@2.9.4(webpack@5.101.2):
+ mini-css-extract-plugin@2.9.4(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
schema-utils: 4.3.2
tapable: 2.2.1
@@ -36792,7 +36829,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.7.0
- postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2):
+ postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.8.3)(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
cosmiconfig: 9.0.0(typescript@5.8.3)
jiti: 1.21.6
@@ -37779,7 +37816,7 @@ snapshots:
safer-buffer@2.1.2: {}
- sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2):
+ sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
neo-async: 2.6.2
optionalDependencies:
@@ -38134,7 +38171,7 @@ snapshots:
source-map-js@1.2.1: {}
- source-map-loader@5.0.0(webpack@5.101.2):
+ source-map-loader@5.0.0(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
iconv-lite: 0.6.3
source-map-js: 1.2.1
@@ -40425,7 +40462,7 @@ snapshots:
webpack-sources@3.3.3: {}
- webpack-subresource-integrity@5.1.0(webpack@5.101.2):
+ webpack-subresource-integrity@5.1.0(webpack@5.101.2(esbuild@0.25.9)):
dependencies:
typed-assert: 1.0.9
webpack: 5.101.2(esbuild@0.25.9)
diff --git a/tsconfig.json b/tsconfig.json
index 6343ac10aa9d..34e3dcc18a0b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -36,6 +36,7 @@
{ "path": "packages/provider" },
{ "path": "packages/provider-utils" },
{ "path": "packages/react" },
+ { "path": "packages/react-native" },
{ "path": "packages/replicate" },
{ "path": "packages/revai" },
{ "path": "packages/rsc" },