"\n```';
+ const ast = parseBlockContent(codeWithSpecialChars);
+ const codeNode = findCodeNode(ast!);
+
+ expect(codeNode!.value).toContain('
{
+ describe('Basic image parsing', () => {
+ it('should parse image with alt text and URL', () => {
+ const ast = parseBlockContent('');
+ expect(ast).toBeDefined();
+
+ const imageNode = findImageNode(ast!);
+ expect(imageNode).not.toBeNull();
+ expect(imageNode!.url).toBe('https://example.com/image.png');
+ expect(imageNode!.alt).toBe('Alt text');
+ });
+
+ it('should parse image with title attribute', () => {
+ const ast = parseBlockContent('');
+ const imageNode = findImageNode(ast!);
+
+ expect(imageNode!.url).toBe('https://example.com/img.jpg');
+ expect(imageNode!.alt).toBe('Alt');
+ expect(imageNode!.title).toBe('Image title');
+ });
+
+ it('should parse image without alt text', () => {
+ const ast = parseBlockContent('');
+ const imageNode = findImageNode(ast!);
+
+ expect(imageNode!.url).toBe('https://example.com/image.png');
+ expect(imageNode!.alt).toBe('');
+ });
+
+ it('should handle various URL formats', () => {
+ const urls = [
+ 'https://example.com/image.png',
+ 'http://example.com/image.jpg',
+ '/relative/path/image.gif',
+ './local/image.webp',
+ ];
+
+ for (const url of urls) {
+ const ast = parseBlockContent(``);
+ const imageNode = findImageNode(ast!);
+ expect(imageNode!.url).toBe(url);
+ }
+ });
+ });
+});
+
+describe('Custom Image Renderer - Streaming', () => {
+ it('should auto-close incomplete image during streaming', () => {
+ const incompleteContent = ';
+ const fixedContent = fixIncompleteMarkdown(incompleteContent, tagState);
+
+ // Should auto-close the image syntax
+ expect(fixedContent).toContain('![Alt text]');
+ expect(fixedContent).toMatch(/\)$/);
+ });
+
+ it('should track image URL evolution during streaming', () => {
+ const stages = [
+ { input: '', hasImage: true },
+ ];
+
+ for (const stage of stages) {
+ const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, stage.input);
+ const fixedContent = fixIncompleteMarkdown(stage.input, tagState);
+ const ast = parseBlockContent(fixedContent);
+ const imageNode = findImageNode(ast!);
+
+ if (stage.hasImage) {
+ expect(imageNode).not.toBeNull();
+ }
+ }
+ });
+});
+
+describe('Custom Image Renderer - Edge Cases', () => {
+ it('should handle multiple images in content', () => {
+ // Add trailing content to finalize all blocks
+ const content = `
+
+Some text
+
+
+
+End text`;
+ // Use processNewContent to get multiple blocks
+ let registry = processNewContent(INITIAL_REGISTRY, content);
+
+ // Find all images across all blocks
+ const images: Image[] = [];
+ for (const block of registry.blocks) {
+ if (block.ast) {
+ images.push(...findAllImageNodes(block.ast));
+ }
+ }
+
+ expect(images.length).toBe(2);
+ expect(images[0].url).toBe('https://example.com/1.png');
+ expect(images[1].url).toBe('https://example.com/2.png');
+ });
+
+ it('should handle image mixed with other content', () => {
+ const content = `# Heading
+
+
+
+Some **bold** text`;
+ let registry = processNewContent(INITIAL_REGISTRY, content);
+
+ // Find blocks with images
+ const blocksWithImages = registry.blocks.filter((b) => {
+ if (b.ast) {
+ const img = findImageNode(b.ast);
+ return img !== null;
+ }
+ return false;
+ });
+
+ expect(blocksWithImages.length).toBeGreaterThan(0);
+ });
+});
+
+describe('Custom Image Renderer - Props Extraction', () => {
+ it('should extract props that would be passed to custom renderer', () => {
+ const ast = parseBlockContent('');
+ const imageNode = findImageNode(ast!);
+
+ // These are the props that would be passed to ImageRendererProps
+ const extractedProps = {
+ src: imageNode!.url,
+ alt: imageNode!.alt || undefined,
+ title: imageNode!.title || undefined,
+ };
+
+ expect(extractedProps.src).toBe('https://example.com/img.png');
+ expect(extractedProps.alt).toBe('Alt text');
+ expect(extractedProps.title).toBe('Title');
+ });
+
+ it('should handle missing optional props', () => {
+ const ast = parseBlockContent('');
+ const imageNode = findImageNode(ast!);
+
+ // alt is empty string, title is null in mdast
+ const alt = imageNode!.alt;
+ const title = imageNode!.title;
+
+ expect(alt).toBe('');
+ expect(title).toBeNull();
+ });
+});
+
+// ============================================================================
+// LINK RENDERER TESTS
+// ============================================================================
+
+describe('Custom Link Renderer - Link Detection', () => {
+ describe('Basic link parsing', () => {
+ it('should parse link with text and URL', () => {
+ const ast = parseBlockContent('[Click here](https://example.com)');
+ expect(ast).toBeDefined();
+
+ const linkNode = findLinkNode(ast!);
+ expect(linkNode).not.toBeNull();
+ expect(linkNode!.url).toBe('https://example.com');
+ });
+
+ it('should parse link with title attribute', () => {
+ const ast = parseBlockContent('[Link](https://example.com "Link title")');
+ const linkNode = findLinkNode(ast!);
+
+ expect(linkNode!.url).toBe('https://example.com');
+ expect(linkNode!.title).toBe('Link title');
+ });
+
+ it('should extract link text content', () => {
+ const ast = parseBlockContent('[Click me](https://example.com)');
+ const linkNode = findLinkNode(ast!);
+
+ const textContent = getTextContent(linkNode!);
+ expect(textContent).toBe('Click me');
+ });
+
+ it('should handle various URL formats', () => {
+ const urls = [
+ 'https://example.com',
+ 'http://example.com/path',
+ '/relative/path',
+ '#anchor',
+ 'mailto:test@example.com',
+ ];
+
+ for (const url of urls) {
+ const ast = parseBlockContent(`[link](${url})`);
+ const linkNode = findLinkNode(ast!);
+ expect(linkNode!.url).toBe(url);
+ }
+ });
+
+ it('should handle link with nested formatting', () => {
+ const ast = parseBlockContent('[**Bold link**](https://example.com)');
+ const linkNode = findLinkNode(ast!);
+
+ expect(linkNode).not.toBeNull();
+ expect(linkNode!.url).toBe('https://example.com');
+ // Link should have children for the bold formatting
+ expect(linkNode!.children.length).toBeGreaterThan(0);
+ });
+ });
+});
+
+describe('Custom Link Renderer - Streaming', () => {
+ it('should auto-close incomplete link during streaming', () => {
+ const incompleteContent = '[Link text](https://example';
+ const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, incompleteContent);
+ const fixedContent = fixIncompleteMarkdown(incompleteContent, tagState);
+
+ // Should auto-close the link syntax
+ expect(fixedContent).toContain('[Link text]');
+ expect(fixedContent).toMatch(/\)$/);
+ });
+
+ it('should handle partial link text streaming', () => {
+ const stages = [
+ '[Lin',
+ '[Link',
+ '[Link text](',
+ '[Link text](https://ex',
+ '[Link text](https://example.com)',
+ ];
+
+ for (const input of stages) {
+ const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, input);
+ const fixedContent = fixIncompleteMarkdown(input, tagState);
+ // Should not throw during parsing
+ const ast = parseBlockContent(fixedContent);
+ expect(ast).toBeDefined();
+ }
+ });
+});
+
+describe('Custom Link Renderer - Edge Cases', () => {
+ it('should handle multiple links in content', () => {
+ const content = 'Visit [Google](https://google.com) or [GitHub](https://github.com)';
+ const ast = parseBlockContent(content);
+ const links = findAllLinkNodes(ast!);
+
+ expect(links.length).toBe(2);
+ expect(links[0].url).toBe('https://google.com');
+ expect(links[1].url).toBe('https://github.com');
+ });
+
+ it('should handle autolinks (GFM)', () => {
+ const ast = parseBlockContent('Check out https://example.com for more info');
+ const linkNode = findLinkNode(ast!);
+
+ expect(linkNode).not.toBeNull();
+ expect(linkNode!.url).toBe('https://example.com');
+ });
+});
+
+describe('Custom Link Renderer - Props Extraction', () => {
+ it('should extract props that would be passed to custom renderer', () => {
+ const ast = parseBlockContent('[Click here](https://example.com "Title")');
+ const linkNode = findLinkNode(ast!);
+
+ // These are the props that would be passed to LinkRendererProps
+ const extractedProps = {
+ href: linkNode!.url,
+ title: linkNode!.title || undefined,
+ // children would be rendered ReactNodes from linkNode.children
+ };
+
+ expect(extractedProps.href).toBe('https://example.com');
+ expect(extractedProps.title).toBe('Title');
+ });
+});
+
+// ============================================================================
+// BLOCKQUOTE RENDERER TESTS
+// ============================================================================
+
+describe('Custom Blockquote Renderer - Blockquote Detection', () => {
+ describe('Basic blockquote parsing', () => {
+ it('should parse simple blockquote', () => {
+ const ast = parseBlockContent('> This is a quote');
+ expect(ast).toBeDefined();
+
+ const blockquoteNode = findBlockquoteNode(ast!);
+ expect(blockquoteNode).not.toBeNull();
+ });
+
+ it('should parse multiline blockquote', () => {
+ const ast = parseBlockContent('> Line one\n> Line two\n> Line three');
+ const blockquoteNode = findBlockquoteNode(ast!);
+
+ expect(blockquoteNode).not.toBeNull();
+ expect(blockquoteNode!.children.length).toBeGreaterThan(0);
+ });
+
+ it('should extract blockquote text content', () => {
+ const ast = parseBlockContent('> Hello world');
+ const blockquoteNode = findBlockquoteNode(ast!);
+
+ const textContent = getTextContent(blockquoteNode!);
+ expect(textContent).toContain('Hello world');
+ });
+
+ it('should handle blockquote with formatting', () => {
+ const ast = parseBlockContent('> This is **bold** and *italic*');
+ const blockquoteNode = findBlockquoteNode(ast!);
+
+ expect(blockquoteNode).not.toBeNull();
+ // Should have nested formatting nodes
+ expect(blockquoteNode!.children.length).toBeGreaterThan(0);
+ });
+
+ it('should handle nested blockquotes', () => {
+ const ast = parseBlockContent('> Outer quote\n>> Nested quote');
+ const blockquoteNode = findBlockquoteNode(ast!);
+
+ expect(blockquoteNode).not.toBeNull();
+ // Should have nested blockquote
+ const nestedBlockquote = findBlockquoteNode(blockquoteNode!.children[0] as Content);
+ // Note: nested blockquotes may be rendered differently by remark
+ });
+ });
+});
+
+describe('Custom Blockquote Renderer - Streaming', () => {
+ it('should handle partial blockquote streaming', () => {
+ const stages = [
+ '>',
+ '> Hel',
+ '> Hello',
+ '> Hello world',
+ ];
+
+ for (const input of stages) {
+ const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, input);
+ const fixedContent = fixIncompleteMarkdown(input, tagState);
+ const ast = parseBlockContent(fixedContent);
+ expect(ast).toBeDefined();
+ }
+ });
+
+ it('should track blockquote content evolution', () => {
+ const contentHistory: string[] = [];
+ const streamChunks = [
+ '> He',
+ '> Hello',
+ '> Hello, World!',
+ ];
+
+ for (const chunk of streamChunks) {
+ const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, chunk);
+ const fixedContent = fixIncompleteMarkdown(chunk, tagState);
+ const ast = parseBlockContent(fixedContent);
+
+ const blockquoteNode = findBlockquoteNode(ast!);
+ if (blockquoteNode) {
+ contentHistory.push(getTextContent(blockquoteNode));
+ }
+ }
+
+ expect(contentHistory.length).toBeGreaterThan(0);
+ expect(contentHistory[contentHistory.length - 1]).toContain('Hello, World!');
+ });
+});
+
+describe('Custom Blockquote Renderer - Edge Cases', () => {
+ it('should handle blockquote mixed with other content', () => {
+ const content = `# Heading
+
+> This is a quote
+
+Some regular text`;
+ let registry = processNewContent(INITIAL_REGISTRY, content);
+
+ const blockquoteBlocks = registry.blocks.filter((b) => b.type === 'blockquote');
+ expect(blockquoteBlocks.length).toBeGreaterThan(0);
+ });
+
+ it('should correctly identify blockquote type in registry', () => {
+ const content = '> Quote text\n\nFollowing paragraph';
+ let registry = processNewContent(INITIAL_REGISTRY, content);
+
+ // Blockquote is finalized as a block when followed by other content
+ const blockquoteBlock = registry.blocks.find((b) => b.type === 'blockquote');
+ expect(blockquoteBlock).toBeDefined();
+ expect(blockquoteBlock!.type).toBe('blockquote');
+ });
+});
+
+describe('Custom Blockquote Renderer - Props Extraction', () => {
+ it('should extract children that would be passed to custom renderer', () => {
+ const ast = parseBlockContent('> Quote with **bold**');
+ const blockquoteNode = findBlockquoteNode(ast!);
+
+ // Props: children would be the rendered ReactNodes
+ expect(blockquoteNode!.children.length).toBeGreaterThan(0);
+ });
+});
+
+// ============================================================================
+// TABLE RENDERER TESTS
+// ============================================================================
+
+describe('Custom Table Renderer - Table Detection', () => {
+ describe('Basic table parsing', () => {
+ it('should parse simple table', () => {
+ const ast = parseBlockContent(`| A | B |
+| --- | --- |
+| 1 | 2 |`);
+ expect(ast).toBeDefined();
+
+ const tableNode = findTableNode(ast!);
+ expect(tableNode).not.toBeNull();
+ });
+
+ it('should extract table headers', () => {
+ const ast = parseBlockContent(`| Header 1 | Header 2 |
+| --- | --- |
+| Cell 1 | Cell 2 |`);
+ const tableNode = findTableNode(ast!);
+
+ expect(tableNode!.children.length).toBeGreaterThan(0);
+ const headerRow = tableNode!.children[0];
+ expect(headerRow.children.length).toBe(2);
+ });
+
+ it('should extract table body rows', () => {
+ const ast = parseBlockContent(`| A | B |
+| --- | --- |
+| 1 | 2 |
+| 3 | 4 |`);
+ const tableNode = findTableNode(ast!);
+
+ // First row is header, rest are body
+ expect(tableNode!.children.length).toBe(3);
+ });
+
+ it('should parse table with alignment', () => {
+ const ast = parseBlockContent(`| Left | Center | Right |
+| :--- | :---: | ---: |
+| L | C | R |`);
+ const tableNode = findTableNode(ast!);
+
+ expect(tableNode!.align).toEqual(['left', 'center', 'right']);
+ });
+
+ it('should handle table without alignment specified', () => {
+ const ast = parseBlockContent(`| A | B |
+| --- | --- |
+| 1 | 2 |`);
+ const tableNode = findTableNode(ast!);
+
+ expect(tableNode!.align).toEqual([null, null]);
+ });
+ });
+});
+
+describe('Custom Table Renderer - Streaming', () => {
+ it('should handle partial table streaming', () => {
+ const stages = [
+ '| A |',
+ '| A | B |',
+ '| A | B |\n| --- |',
+ '| A | B |\n| --- | --- |',
+ '| A | B |\n| --- | --- |\n| 1 |',
+ '| A | B |\n| --- | --- |\n| 1 | 2 |',
+ ];
+
+ for (const input of stages) {
+ const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, input);
+ const fixedContent = fixIncompleteMarkdown(input, tagState);
+ // Should not throw
+ const ast = parseBlockContent(fixedContent);
+ expect(ast).toBeDefined();
+ }
+ });
+});
+
+describe('Custom Table Renderer - Edge Cases', () => {
+ it('should handle table with empty cells', () => {
+ const ast = parseBlockContent(`| A | B |
+| --- | --- |
+| | 2 |`);
+ const tableNode = findTableNode(ast!);
+
+ expect(tableNode).not.toBeNull();
+ });
+
+ it('should handle table with formatting in cells', () => {
+ const ast = parseBlockContent(`| **Bold** | *Italic* |
+| --- | --- |
+| \`code\` | [link](url) |`);
+ const tableNode = findTableNode(ast!);
+
+ expect(tableNode).not.toBeNull();
+ });
+
+ it('should correctly identify table in AST', () => {
+ const content = `| A | B |
+| --- | --- |
+| 1 | 2 |
+
+Following paragraph`;
+ let registry = processNewContent(INITIAL_REGISTRY, content);
+
+ // Table is parsed correctly in AST, even if block.type is 'paragraph'
+ // (The splitter doesn't distinguish table blocks, but AST parsing does)
+ const blockWithTable = registry.blocks.find((b) => {
+ if (b.ast) {
+ return findTableNode(b.ast) !== null;
+ }
+ return false;
+ });
+ expect(blockWithTable).toBeDefined();
+ expect(findTableNode(blockWithTable!.ast!)).not.toBeNull();
+ });
+});
+
+describe('Custom Table Renderer - Props Extraction', () => {
+ it('should extract props that would be passed to custom renderer', () => {
+ const ast = parseBlockContent(`| Header 1 | Header 2 |
+| :--- | ---: |
+| Cell 1 | Cell 2 |
+| Cell 3 | Cell 4 |`);
+ const tableNode = findTableNode(ast!);
+
+ // Props extraction for TableRendererProps
+ const headerRow = tableNode!.children[0];
+ const bodyRows = tableNode!.children.slice(1);
+ const alignments = tableNode!.align;
+
+ // Headers
+ expect(headerRow.children.length).toBe(2);
+
+ // Body rows
+ expect(bodyRows.length).toBe(2);
+
+ // Alignments
+ expect(alignments).toEqual(['left', 'right']);
+ });
+
+ it('should handle varying column counts correctly', () => {
+ const ast = parseBlockContent(`| A | B | C | D |
+| --- | --- | --- | --- |
+| 1 | 2 | 3 | 4 |`);
+ const tableNode = findTableNode(ast!);
+
+ expect(tableNode!.children[0].children.length).toBe(4);
+ });
+});
+
+// ============================================================================
+// HEADING RENDERER TESTS
+// ============================================================================
+
+describe('Custom Heading Renderer - Heading Detection', () => {
+ describe('Basic heading parsing', () => {
+ it('should parse all heading levels', () => {
+ const headings = [
+ { input: '# H1', level: 1 },
+ { input: '## H2', level: 2 },
+ { input: '### H3', level: 3 },
+ { input: '#### H4', level: 4 },
+ { input: '##### H5', level: 5 },
+ { input: '###### H6', level: 6 },
+ ];
+
+ for (const { input, level } of headings) {
+ const ast = parseBlockContent(input);
+ const headingNode = findHeadingNode(ast!);
+ expect(headingNode).not.toBeNull();
+ expect(headingNode!.depth).toBe(level);
+ }
+ });
+
+ it('should extract heading text content', () => {
+ const ast = parseBlockContent('# Hello World');
+ const headingNode = findHeadingNode(ast!);
+
+ const textContent = getTextContent(headingNode!);
+ expect(textContent).toBe('Hello World');
+ });
+
+ it('should handle heading with formatting', () => {
+ const ast = parseBlockContent('## This is **bold** heading');
+ const headingNode = findHeadingNode(ast!);
+
+ expect(headingNode).not.toBeNull();
+ expect(headingNode!.children.length).toBeGreaterThan(0);
+ });
+
+ it('should handle heading with inline code', () => {
+ const ast = parseBlockContent('### Code: `const x = 1`');
+ const headingNode = findHeadingNode(ast!);
+
+ expect(headingNode).not.toBeNull();
+ });
+ });
+});
+
+describe('Custom Heading Renderer - Streaming', () => {
+ it('should handle partial heading streaming', () => {
+ const stages = [
+ '#',
+ '# H',
+ '# He',
+ '# Hel',
+ '# Hello',
+ ];
+
+ for (const input of stages) {
+ const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, input);
+ const fixedContent = fixIncompleteMarkdown(input, tagState);
+ const ast = parseBlockContent(fixedContent);
+ expect(ast).toBeDefined();
+ }
+ });
+
+ it('should track heading content evolution', () => {
+ const contentHistory: string[] = [];
+ const streamChunks = [
+ '# He',
+ '# Hello',
+ '# Hello World',
+ ];
+
+ for (const chunk of streamChunks) {
+ const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, chunk);
+ const fixedContent = fixIncompleteMarkdown(chunk, tagState);
+ const ast = parseBlockContent(fixedContent);
+
+ const headingNode = findHeadingNode(ast!);
+ if (headingNode) {
+ contentHistory.push(getTextContent(headingNode));
+ }
+ }
+
+ expect(contentHistory.length).toBe(3);
+ expect(contentHistory[2]).toBe('Hello World');
+ });
+
+ it('should maintain heading level throughout streaming', () => {
+ const levelHistory: number[] = [];
+ const streamChunks = [
+ '## He',
+ '## Hello',
+ '## Hello World',
+ ];
+
+ for (const chunk of streamChunks) {
+ const tagState = updateTagState(INITIAL_INCOMPLETE_STATE, chunk);
+ const fixedContent = fixIncompleteMarkdown(chunk, tagState);
+ const ast = parseBlockContent(fixedContent);
+
+ const headingNode = findHeadingNode(ast!);
+ if (headingNode) {
+ levelHistory.push(headingNode.depth);
+ }
+ }
+
+ // Level should remain consistent
+ expect(levelHistory.every((l) => l === 2)).toBe(true);
+ });
+});
+
+describe('Custom Heading Renderer - Edge Cases', () => {
+ it('should handle multiple headings in content', () => {
+ // Add trailing content to finalize all blocks
+ const content = `# First
+
+Some text
+
+## Second
+
+More text
+
+### Third
+
+End text`;
+ // Use processNewContent to get multiple blocks
+ let registry = processNewContent(INITIAL_REGISTRY, content);
+
+ // Find all headings across all blocks
+ const headings: Heading[] = [];
+ for (const block of registry.blocks) {
+ if (block.ast) {
+ headings.push(...findAllHeadingNodes(block.ast));
+ }
+ }
+
+ expect(headings.length).toBe(3);
+ expect(headings[0].depth).toBe(1);
+ expect(headings[1].depth).toBe(2);
+ expect(headings[2].depth).toBe(3);
+ });
+
+ it('should correctly identify heading type in registry', () => {
+ const content = '## Introduction\n\nFollowing paragraph';
+ let registry = processNewContent(INITIAL_REGISTRY, content);
+
+ // Heading is finalized as a block when followed by other content
+ const headingBlock = registry.blocks.find((b) => b.type === 'heading');
+ expect(headingBlock).toBeDefined();
+ expect(headingBlock!.type).toBe('heading');
+ });
+
+ it('should not confuse hash symbols in other contexts', () => {
+ // Hash in code should not be detected as heading
+ const codeContent = '```\n# This is a comment\n```';
+ let registry = processNewContent(INITIAL_REGISTRY, codeContent);
+
+ const codeBlock = registry.blocks.find((b) => b.type === 'codeBlock');
+ expect(codeBlock).toBeDefined();
+ });
+});
+
+describe('Custom Heading Renderer - Props Extraction', () => {
+ it('should extract props that would be passed to custom renderer', () => {
+ const ast = parseBlockContent('### Heading Text');
+ const headingNode = findHeadingNode(ast!);
+
+ // Props for HeadingRendererProps
+ const extractedProps = {
+ level: headingNode!.depth as 1 | 2 | 3 | 4 | 5 | 6,
+ // children would be the rendered ReactNodes from headingNode.children
+ };
+
+ expect(extractedProps.level).toBe(3);
+ expect(headingNode!.children.length).toBeGreaterThan(0);
+ });
+
+ it('should correctly type heading levels', () => {
+ for (let level = 1; level <= 6; level++) {
+ const hashes = '#'.repeat(level);
+ const ast = parseBlockContent(`${hashes} Heading`);
+ const headingNode = findHeadingNode(ast!);
+
+ expect(headingNode!.depth).toBe(level);
+ // Level should be in valid range
+ expect(headingNode!.depth).toBeGreaterThanOrEqual(1);
+ expect(headingNode!.depth).toBeLessThanOrEqual(6);
+ }
+ });
+});
diff --git a/packages/streamdown-rn/src/core/types.ts b/packages/streamdown-rn/src/core/types.ts
index d2904ca..79f60cb 100644
--- a/packages/streamdown-rn/src/core/types.ts
+++ b/packages/streamdown-rn/src/core/types.ts
@@ -188,6 +188,168 @@ export interface ValidationResult {
errors: string[];
}
+// ============================================================================
+// Custom Renderers
+// ============================================================================
+
+/**
+ * Props passed to a custom code block renderer.
+ * Allows consumers to provide their own code block component.
+ */
+export interface CodeBlockRendererProps {
+ /** The code content (trailing newlines trimmed) */
+ code: string;
+ /** The language identifier (e.g., "typescript", "python", or "text" if none) */
+ language: string;
+ /** Current theme configuration for styling consistency */
+ theme: ThemeConfig;
+ /** React key for list rendering */
+ key?: string | number;
+}
+
+/**
+ * A custom renderer function for code blocks.
+ * Return a React node to render your custom code block.
+ */
+export type CodeBlockRenderer = (props: CodeBlockRendererProps) => ReactNode;
+
+/**
+ * Props passed to a custom image renderer.
+ */
+export interface ImageRendererProps {
+ /** Image source URL (already sanitized) */
+ src: string;
+ /** Alt text for accessibility */
+ alt?: string;
+ /** Title attribute */
+ title?: string;
+ /** Current theme configuration */
+ theme: ThemeConfig;
+ /** React key for list rendering */
+ key?: string | number;
+}
+
+/**
+ * A custom renderer function for images.
+ */
+export type ImageRenderer = (props: ImageRendererProps) => ReactNode;
+
+/**
+ * Props passed to a custom link renderer.
+ */
+export interface LinkRendererProps {
+ /** Link URL (already sanitized) */
+ href: string;
+ /** Title attribute */
+ title?: string;
+ /** Rendered link content (text or nested elements) */
+ children: ReactNode;
+ /** Current theme configuration */
+ theme: ThemeConfig;
+ /** React key for list rendering */
+ key?: string | number;
+}
+
+/**
+ * A custom renderer function for links.
+ */
+export type LinkRenderer = (props: LinkRendererProps) => ReactNode;
+
+/**
+ * Props passed to a custom blockquote renderer.
+ */
+export interface BlockquoteRendererProps {
+ /** Rendered blockquote content */
+ children: ReactNode;
+ /** Current theme configuration */
+ theme: ThemeConfig;
+ /** React key for list rendering */
+ key?: string | number;
+}
+
+/**
+ * A custom renderer function for blockquotes.
+ */
+export type BlockquoteRenderer = (props: BlockquoteRendererProps) => ReactNode;
+
+/**
+ * Props passed to a custom table renderer.
+ */
+export interface TableRendererProps {
+ /** Header cell contents (as rendered ReactNodes) */
+ headers: ReactNode[];
+ /** Body rows - array of cells (as rendered ReactNodes) */
+ rows: ReactNode[][];
+ /** Column alignments from GFM table syntax */
+ alignments: ('left' | 'center' | 'right' | null)[];
+ /** Current theme configuration */
+ theme: ThemeConfig;
+ /** React key for list rendering */
+ key?: string | number;
+}
+
+/**
+ * A custom renderer function for tables.
+ */
+export type TableRenderer = (props: TableRendererProps) => ReactNode;
+
+/**
+ * Props passed to a custom heading renderer.
+ */
+export interface HeadingRendererProps {
+ /** Heading level (1-6) */
+ level: 1 | 2 | 3 | 4 | 5 | 6;
+ /** Rendered heading content */
+ children: ReactNode;
+ /** Current theme configuration */
+ theme: ThemeConfig;
+ /** React key for list rendering */
+ key?: string | number;
+}
+
+/**
+ * A custom renderer function for headings.
+ */
+export type HeadingRenderer = (props: HeadingRendererProps) => ReactNode;
+
+/**
+ * Registry of custom renderers to override built-in rendering.
+ * Each renderer is optional — if not provided, the default renderer is used.
+ *
+ * @example
+ * ```tsx
+ *
(
+ *
+ * ),
+ * image: ({ src, alt, theme }) => (
+ *
+ * ),
+ * link: ({ href, children, theme }) => (
+ * {children}
+ * ),
+ * }}
+ * >
+ * {content}
+ *
+ * ```
+ */
+export interface CustomRenderers {
+ /** Custom code block renderer (```code```) */
+ codeBlock?: CodeBlockRenderer;
+ /** Custom image renderer () */
+ image?: ImageRenderer;
+ /** Custom link renderer ([text](href)) */
+ link?: LinkRenderer;
+ /** Custom blockquote renderer (> quote) */
+ blockquote?: BlockquoteRenderer;
+ /** Custom table renderer (GFM tables) */
+ table?: TableRenderer;
+ /** Custom heading renderer (# heading) */
+ heading?: HeadingRenderer;
+}
+
// ============================================================================
// Theme Configuration
// ============================================================================
@@ -298,7 +460,7 @@ export interface StreamdownRNProps {
style?: object;
/** Error callback for component failures */
onError?: (error: Error, componentName?: string) => void;
- /**
+ /**
* Debug callback — called on every content update.
* Use for observability, debugging, or testing.
* Only enable in development to avoid performance overhead.
@@ -311,6 +473,11 @@ export interface StreamdownRNProps {
* transition from skeleton to final state.
*/
isComplete?: boolean;
+ /**
+ * Custom renderers to override built-in block rendering.
+ * Use this to provide your own code block component, etc.
+ */
+ renderers?: CustomRenderers;
}
/**
diff --git a/packages/streamdown-rn/src/index.ts b/packages/streamdown-rn/src/index.ts
index 5158b10..1ac5b8d 100644
--- a/packages/streamdown-rn/src/index.ts
+++ b/packages/streamdown-rn/src/index.ts
@@ -42,11 +42,30 @@ export {
export type {
// Component props
StreamdownRNProps,
-
+
// Component injection (for custom component registries)
ComponentDefinition,
ComponentRegistry,
-
+
+ // Custom renderers (for overriding built-in rendering)
+ CustomRenderers,
+ CodeBlockRenderer,
+ CodeBlockRendererProps,
+ ImageRenderer,
+ ImageRendererProps,
+ LinkRenderer,
+ LinkRendererProps,
+ BlockquoteRenderer,
+ BlockquoteRendererProps,
+ TableRenderer,
+ TableRendererProps,
+ HeadingRenderer,
+ HeadingRendererProps,
+
+ // Theme (for custom renderer styling)
+ ThemeConfig,
+ ThemeColors,
+
// Debug/Observability
DebugSnapshot,
} from './core/types';
diff --git a/packages/streamdown-rn/src/renderers/ASTRenderer.tsx b/packages/streamdown-rn/src/renderers/ASTRenderer.tsx
index dc6b4be..bbe0435 100644
--- a/packages/streamdown-rn/src/renderers/ASTRenderer.tsx
+++ b/packages/streamdown-rn/src/renderers/ASTRenderer.tsx
@@ -15,7 +15,7 @@ import React, { ReactNode, useState, useEffect } from 'react';
import { Text, View, ScrollView, Image, Platform } from 'react-native';
import SyntaxHighlighter from 'react-native-syntax-highlighter';
import type { Content, Parent, Table as TableNode, Code as CodeNode, List as ListNode, Image as ImageNode, Link as LinkNode } from 'mdast';
-import type { ThemeConfig, ComponentRegistry, StableBlock } from '../core/types';
+import type { ThemeConfig, ComponentRegistry, StableBlock, CustomRenderers } from '../core/types';
import { getTextStyles, getBlockStyles } from '../themes';
import { extractComponentData, type ComponentData } from '../core/componentParser';
import { sanitizeURL } from '../core/sanitize';
@@ -95,6 +95,8 @@ export interface ASTRendererProps {
componentRegistry?: ComponentRegistry;
/** Whether this is streaming (for components) */
isStreaming?: boolean;
+ /** Custom renderers to override built-in rendering */
+ renderers?: CustomRenderers;
}
/**
@@ -107,8 +109,9 @@ export const ASTRenderer: React.FC
= ({
theme,
componentRegistry,
isStreaming = false,
+ renderers,
}) => {
- return <>{renderNode(node, theme, componentRegistry, isStreaming)}>;
+ return <>{renderNode(node, theme, componentRegistry, isStreaming, renderers)}>;
};
// ============================================================================
@@ -123,57 +126,136 @@ function renderNode(
theme: ThemeConfig,
componentRegistry?: ComponentRegistry,
isStreaming = false,
+ renderers?: CustomRenderers,
key?: string | number
): ReactNode {
const styles = getTextStyles(theme);
const blockStyles = getBlockStyles(theme);
-
+
switch (node.type) {
// ========================================================================
// Block-level nodes
// ========================================================================
-
+
case 'paragraph':
return (
- {renderChildren(node, theme, componentRegistry, isStreaming)}
+ {renderChildren(node, theme, componentRegistry, isStreaming, renderers)}
);
-
+
case 'heading':
+ // Check for custom heading renderer
+ if (renderers?.heading) {
+ return (
+
+ {renderers.heading({
+ level: node.depth as 1 | 2 | 3 | 4 | 5 | 6,
+ children: renderChildren(node, theme, componentRegistry, isStreaming, renderers),
+ theme,
+ })}
+
+ );
+ }
const headingStyle = styles[`heading${node.depth}` as keyof typeof styles];
return (
- {renderChildren(node, theme, componentRegistry, isStreaming)}
+ {renderChildren(node, theme, componentRegistry, isStreaming, renderers)}
);
-
+
case 'code':
+ // Check for custom code block renderer
+ if (renderers?.codeBlock) {
+ const codeNode = node as CodeNode;
+ const code = codeNode.value.replace(/\n+$/, '');
+ return (
+
+ {renderers.codeBlock({
+ code,
+ language: codeNode.lang || 'text',
+ theme,
+ })}
+
+ );
+ }
return renderCodeBlock(node as CodeNode, theme, key);
case 'blockquote':
- return renderBlockquote(node, theme, componentRegistry, isStreaming, key);
-
+ // Check for custom blockquote renderer
+ if (renderers?.blockquote) {
+ return (
+
+ {renderers.blockquote({
+ children: renderChildren(node, theme, componentRegistry, isStreaming, renderers),
+ theme,
+ })}
+
+ );
+ }
+ return renderBlockquote(node, theme, componentRegistry, isStreaming, renderers, key);
+
case 'list':
- return renderList(node as ListNode, theme, componentRegistry, isStreaming, key);
-
+ return renderList(node as ListNode, theme, componentRegistry, isStreaming, renderers, key);
+
case 'listItem':
return (
•
- {renderChildren(node, theme, componentRegistry, isStreaming)}
+ {renderChildren(node, theme, componentRegistry, isStreaming, renderers)}
);
-
+
case 'thematicBreak':
return (
);
-
- case 'table':
- return renderTable(node as TableNode, theme, componentRegistry, isStreaming, key);
+
+ case 'table': {
+ const tableNode = node as TableNode;
+
+ // Check for custom table renderer
+ if (renderers?.table) {
+ const tableRows = tableNode.children;
+ if (tableRows.length === 0) return null;
+
+ const headerRow = tableRows[0];
+ const bodyRows = tableRows.slice(1);
+
+ // Render header cells as ReactNode[]
+ const headers = headerRow.children.map((cell, cellIndex) =>
+ cell.children.map((child, childIndex) =>
+ renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, `h-${cellIndex}-${childIndex}`)
+ )
+ );
+
+ // Render body rows as ReactNode[][]
+ const rows = bodyRows.map((row, rowIndex) =>
+ row.children.map((cell, cellIndex) =>
+ cell.children.map((child, childIndex) =>
+ renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, `r-${rowIndex}-${cellIndex}-${childIndex}`)
+ )
+ )
+ );
+
+ // Get alignments from table node (GFM feature)
+ const alignments = tableNode.align || [];
+
+ return (
+
+ {renderers.table({
+ headers,
+ rows,
+ alignments,
+ theme,
+ })}
+
+ );
+ }
+ return renderTable(tableNode, theme, componentRegistry, isStreaming, renderers, key);
+ }
case 'html':
// Render HTML as plain text (React Native doesn't support HTML)
@@ -190,29 +272,29 @@ function renderNode(
case 'text':
// Check if text contains inline component syntax
if (node.value.includes('[{c:')) {
- return renderTextWithComponents(node.value, theme, componentRegistry, isStreaming, key);
+ return renderTextWithComponents(node.value, theme, componentRegistry, isStreaming, renderers, key);
}
return node.value;
-
+
case 'strong':
return (
- {renderChildren(node, theme, componentRegistry, isStreaming)}
+ {renderChildren(node, theme, componentRegistry, isStreaming, renderers)}
);
-
+
case 'emphasis':
return (
- {renderChildren(node, theme, componentRegistry, isStreaming)}
+ {renderChildren(node, theme, componentRegistry, isStreaming, renderers)}
);
-
+
case 'delete':
// GFM strikethrough
return (
- {renderChildren(node, theme, componentRegistry, isStreaming)}
+ {renderChildren(node, theme, componentRegistry, isStreaming, renderers)}
);
@@ -227,29 +309,61 @@ function renderNode(
// Sanitize URL to prevent XSS via javascript: or data: protocols
const linkNode = node as LinkNode;
const safeUrl = sanitizeURL(linkNode.url);
-
+
// If URL is dangerous, render children as plain text without link styling
if (!safeUrl) {
return (
- {renderChildren(node, theme, componentRegistry, isStreaming)}
+ {renderChildren(node, theme, componentRegistry, isStreaming, renderers)}
);
}
-
+
+ // Check for custom link renderer
+ if (renderers?.link) {
+ return (
+
+ {renderers.link({
+ href: safeUrl,
+ title: linkNode.title ?? undefined,
+ children: renderChildren(node, theme, componentRegistry, isStreaming, renderers),
+ theme,
+ })}
+
+ );
+ }
+
return (
- {renderChildren(node, theme, componentRegistry, isStreaming)}
+ {renderChildren(node, theme, componentRegistry, isStreaming, renderers)}
);
}
- case 'image':
- return renderImage(node as ImageNode, theme, key);
+ case 'image': {
+ const imageNode = node as ImageNode;
+ const safeImageUrl = sanitizeURL(imageNode.url);
+
+ // Check for custom image renderer
+ if (renderers?.image) {
+ if (!safeImageUrl) return null;
+ return (
+
+ {renderers.image({
+ src: safeImageUrl,
+ alt: imageNode.alt ?? undefined,
+ title: imageNode.title ?? undefined,
+ theme,
+ })}
+
+ );
+ }
+ return renderImage(imageNode, theme, key);
+ }
case 'break':
return '\n';
@@ -290,14 +404,15 @@ function renderChildren(
node: Parent,
theme: ThemeConfig,
componentRegistry?: ComponentRegistry,
- isStreaming = false
+ isStreaming = false,
+ renderers?: CustomRenderers
): ReactNode {
if (!('children' in node) || !node.children) {
return null;
}
-
+
return node.children.map((child, index) =>
- renderNode(child as Content, theme, componentRegistry, isStreaming, index)
+ renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, index)
);
}
@@ -370,11 +485,12 @@ function renderList(
theme: ThemeConfig,
componentRegistry?: ComponentRegistry,
isStreaming = false,
+ renderers?: CustomRenderers,
key?: string | number
): ReactNode {
const styles = getTextStyles(theme);
const ordered = node.ordered ?? false;
-
+
return (
{node.children.map((item, index) => (
@@ -384,7 +500,7 @@ function renderList(
{item.children.map((child, childIndex) =>
- renderListItemChild(child as Content, theme, componentRegistry, isStreaming, childIndex)
+ renderListItemChild(child as Content, theme, componentRegistry, isStreaming, renderers, childIndex)
)}
@@ -402,30 +518,31 @@ function renderListItemChild(
theme: ThemeConfig,
componentRegistry?: ComponentRegistry,
isStreaming = false,
+ renderers?: CustomRenderers,
key?: string | number
): ReactNode {
const styles = getTextStyles(theme);
-
+
// For paragraphs inside list items, render without margin
if (node.type === 'paragraph') {
return (
- {renderChildren(node, theme, componentRegistry, isStreaming)}
+ {renderChildren(node, theme, componentRegistry, isStreaming, renderers)}
);
}
-
+
// For nested lists, render with reduced margin
if (node.type === 'list') {
return (
- {renderList(node as ListNode, theme, componentRegistry, isStreaming)}
+ {renderList(node as ListNode, theme, componentRegistry, isStreaming, renderers)}
);
}
-
+
// For other types, use normal rendering
- return renderNode(node, theme, componentRegistry, isStreaming, key);
+ return renderNode(node, theme, componentRegistry, isStreaming, renderers, key);
}
/**
@@ -437,11 +554,12 @@ function renderBlockquote(
theme: ThemeConfig,
componentRegistry?: ComponentRegistry,
isStreaming = false,
+ renderers?: CustomRenderers,
key?: string | number
): ReactNode {
const styles = getTextStyles(theme);
const blockStyles = getBlockStyles(theme);
-
+
return (
{node.children?.map((child, index) => {
@@ -449,12 +567,12 @@ function renderBlockquote(
if (child.type === 'paragraph') {
return (
- {renderChildren(child, theme, componentRegistry, isStreaming)}
+ {renderChildren(child, theme, componentRegistry, isStreaming, renderers)}
);
}
// For other types, use normal rendering
- return renderNode(child, theme, componentRegistry, isStreaming, index);
+ return renderNode(child, theme, componentRegistry, isStreaming, renderers, index);
})}
);
@@ -468,22 +586,23 @@ function renderTable(
theme: ThemeConfig,
componentRegistry?: ComponentRegistry,
isStreaming = false,
+ renderers?: CustomRenderers,
key?: string | number
): ReactNode {
const styles = getTextStyles(theme);
const rows = node.children;
-
+
if (rows.length === 0) return null;
-
+
const headerRow = rows[0];
const bodyRows = rows.slice(1);
-
+
return (
{/* Header */}
-
{cell.children.map((child, childIndex) =>
- renderNode(child as Content, theme, componentRegistry, isStreaming, childIndex)
+ renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, childIndex)
)}
))}
-
+
{/* Body */}
{bodyRows.map((row, rowIndex) => (
-
{cell.children.map((child, childIndex) =>
- renderNode(child as Content, theme, componentRegistry, isStreaming, childIndex)
+ renderNode(child as Content, theme, componentRegistry, isStreaming, renderers, childIndex)
)}
@@ -623,20 +742,21 @@ function renderTextWithComponents(
theme: ThemeConfig,
componentRegistry?: ComponentRegistry,
isStreaming = false,
+ renderers?: CustomRenderers,
key?: string | number
): ReactNode {
// Look for inline components
const componentMatch = text.match(/\[\{c:\s*"([^"]+)"\s*,\s*p:\s*(\{[\s\S]*?\})\s*\}\]/);
-
+
if (!componentMatch) {
return text;
}
-
+
const before = text.slice(0, componentMatch.index);
const after = text.slice(componentMatch.index! + componentMatch[0].length);
-
+
const { name, props } = extractComponentData(componentMatch[0]);
-
+
if (!componentRegistry) {
return (
<>
@@ -646,7 +766,7 @@ function renderTextWithComponents(
>
);
}
-
+
const componentDef = componentRegistry.get(name);
if (!componentDef) {
return (
@@ -657,14 +777,14 @@ function renderTextWithComponents(
>
);
}
-
+
const Component = componentDef.component;
-
+
return (
<>
{before}
- {renderTextWithComponents(after, theme, componentRegistry, isStreaming, `${key}-after`)}
+ {renderTextWithComponents(after, theme, componentRegistry, isStreaming, renderers, `${key}-after`)}
>
);
}
@@ -831,7 +951,8 @@ export function renderAST(
nodes: Content[],
theme: ThemeConfig,
componentRegistry?: ComponentRegistry,
- isStreaming = false
+ isStreaming = false,
+ renderers?: CustomRenderers
): ReactNode {
- return nodes.map((node, index) => renderNode(node, theme, componentRegistry, isStreaming, index));
+ return nodes.map((node, index) => renderNode(node, theme, componentRegistry, isStreaming, renderers, index));
}
diff --git a/packages/streamdown-rn/src/renderers/ActiveBlock.tsx b/packages/streamdown-rn/src/renderers/ActiveBlock.tsx
index 37bebf1..315aa46 100644
--- a/packages/streamdown-rn/src/renderers/ActiveBlock.tsx
+++ b/packages/streamdown-rn/src/renderers/ActiveBlock.tsx
@@ -10,7 +10,7 @@
*/
import React from 'react';
-import type { ActiveBlock as ActiveBlockType, ThemeConfig, ComponentRegistry, IncompleteTagState } from '../core/types';
+import type { ActiveBlock as ActiveBlockType, ThemeConfig, ComponentRegistry, IncompleteTagState, CustomRenderers } from '../core/types';
import { fixIncompleteMarkdown } from '../core/incomplete';
import { parseBlockContent } from '../core/parser';
import { ASTRenderer, ComponentBlock, extractComponentData } from './ASTRenderer';
@@ -20,6 +20,7 @@ interface ActiveBlockProps {
tagState: IncompleteTagState;
theme: ThemeConfig;
componentRegistry?: ComponentRegistry;
+ renderers?: CustomRenderers;
}
/**
@@ -33,12 +34,13 @@ export const ActiveBlock: React.FC = ({
tagState,
theme,
componentRegistry,
+ renderers,
}) => {
// No active block — nothing to render
if (!block || !block.content.trim()) {
return null;
}
-
+
// Special handling for component blocks (don't use remark)
if (block.type === 'component') {
const { name, props } = extractComponentData(block.content);
@@ -52,13 +54,13 @@ export const ActiveBlock: React.FC = ({
/>
);
}
-
+
// Fix incomplete markdown for format-as-you-type UX
const fixedContent = fixIncompleteMarkdown(block.content, tagState);
-
+
// Parse with remark
const ast = parseBlockContent(fixedContent);
-
+
// Render from AST
if (ast) {
return (
@@ -67,10 +69,11 @@ export const ActiveBlock: React.FC = ({
theme={theme}
componentRegistry={componentRegistry}
isStreaming={true}
+ renderers={renderers}
/>
);
}
-
+
// Fallback if parsing fails (shouldn't happen)
return null;
};
diff --git a/packages/streamdown-rn/src/renderers/StableBlock.tsx b/packages/streamdown-rn/src/renderers/StableBlock.tsx
index 517496f..10f979d 100644
--- a/packages/streamdown-rn/src/renderers/StableBlock.tsx
+++ b/packages/streamdown-rn/src/renderers/StableBlock.tsx
@@ -6,13 +6,14 @@
*/
import React from 'react';
-import type { StableBlock as StableBlockType, ThemeConfig, ComponentRegistry } from '../core/types';
+import type { StableBlock as StableBlockType, ThemeConfig, ComponentRegistry, CustomRenderers } from '../core/types';
import { ASTRenderer, ComponentBlock } from './ASTRenderer';
interface StableBlockProps {
block: StableBlockType;
theme: ThemeConfig;
componentRegistry?: ComponentRegistry;
+ renderers?: CustomRenderers;
}
/**
@@ -22,7 +23,7 @@ interface StableBlockProps {
* The block prop is immutable — once finalized, content never changes.
*/
export const StableBlock: React.FC = React.memo(
- ({ block, theme, componentRegistry }) => {
+ ({ block, theme, componentRegistry, renderers }) => {
// Component blocks don't have AST (custom syntax, not markdown)
if (block.type === 'component') {
return (
@@ -33,7 +34,7 @@ export const StableBlock: React.FC = React.memo(
/>
);
}
-
+
// Render from cached AST
if (block.ast) {
return (
@@ -41,16 +42,24 @@ export const StableBlock: React.FC = React.memo(
node={block.ast}
theme={theme}
componentRegistry={componentRegistry}
+ renderers={renderers}
/>
);
}
-
+
// Fallback if no AST (shouldn't happen for stable blocks)
console.warn('StableBlock has no AST:', block.type, block.id);
return null;
},
- // Only re-render if the block's content hash changes (which shouldn't happen for stable blocks)
- (prev, next) => prev.block.contentHash === next.block.contentHash
+ // Re-render if content hash OR any renderer changes
+ (prev, next) =>
+ prev.block.contentHash === next.block.contentHash &&
+ prev.renderers?.codeBlock === next.renderers?.codeBlock &&
+ prev.renderers?.image === next.renderers?.image &&
+ prev.renderers?.link === next.renderers?.link &&
+ prev.renderers?.blockquote === next.renderers?.blockquote &&
+ prev.renderers?.table === next.renderers?.table &&
+ prev.renderers?.heading === next.renderers?.heading
);
StableBlock.displayName = 'StableBlock';