/** * StreamdownRN - Streaming Markdown Renderer for React Native * * High-performance streaming markdown renderer optimized for AI responses. * Uses block-level stability to minimize re-renders during streaming. * * Architecture: * - Completed blocks are memoized and NEVER re-render * - Only the active (currently streaming) block re-renders on new tokens * - Block boundaries are detected incrementally */ import React, { useRef, useMemo, useEffect } from 'react'; import { View, ViewStyle } from 'react-native'; import type { StreamdownRNProps, BlockRegistry, ThemeConfig, DebugSnapshot, } from './core/types'; import { INITIAL_REGISTRY } from './core/types'; import { processNewContent, finalizeActiveBlock } from './core/splitter'; import { fixIncompleteMarkdown } from './core/incomplete'; import { getTheme } from './themes'; import { StableBlock } from './renderers/StableBlock'; import { ActiveBlock } from './renderers/ActiveBlock'; /** * StreamdownRN Component * * Main entry point for streaming markdown rendering. * * @example * ```tsx * * {streamingMarkdownContent} * * ``` */ export const StreamdownRN: React.FC = React.memo(({ children, componentRegistry, theme = 'dark', style, onError, onDebug, isComplete = false, }) => { // Persistent registry reference — survives across renders const registryRef = useRef(INITIAL_REGISTRY); // Track content for change detection (important for FlashList recycling!) const contentRef = useRef(''); // Debug tracking refs const lastUpdateTimeRef = useRef(performance.now()); const previousContentRef = useRef(''); // Resolve theme configuration const themeConfig = useMemo(() => { return getTheme(theme); }, [theme]); // Process new content incrementally // This is the core optimization: only processes NEW tokens const registry = useMemo(() => { // Handle empty content if (!children || children.trim().length === 0) { registryRef.current = INITIAL_REGISTRY; contentRef.current = ''; return INITIAL_REGISTRY; } try { // CRITICAL FIX: Detect if content has completely changed (not just appended) // This happens when FlashList recycles a component with new data. // If new content doesn't start with the previous content, we must reset. const previousContent = contentRef.current; const isStreamingContinuation = previousContent.length > 0 && children.startsWith(previousContent); if (!isStreamingContinuation && previousContent.length > 0) { // Content has changed completely - reset registry registryRef.current = INITIAL_REGISTRY; } // Update content ref contentRef.current = children; // Process from where we left off (or from beginning if reset) let updated = processNewContent(registryRef.current, children); // When streaming is complete, finalize the active block // This ensures the last block is properly memoized and components // transition from skeleton to final state if (isComplete && updated.activeBlock) { updated = finalizeActiveBlock(updated); } registryRef.current = updated; return updated; } catch (error) { // Report error but don't crash onError?.(error instanceof Error ? error : new Error(String(error))); return registryRef.current; } }, [children, onError, isComplete]); // Emit debug snapshot when content changes (effect to avoid render-time side effects) useEffect(() => { if (!onDebug || !children) return; const now = performance.now(); const deltaMs = now - lastUpdateTimeRef.current; const previousContent = previousContentRef.current; const newChars = children.slice(previousContent.length); // Build debug snapshot const snapshot: DebugSnapshot = { position: registry.cursor, totalLength: children.length, newChars, newCharsCount: newChars.length, registry: { stableBlockCount: registry.blocks.length, stableBlocks: registry.blocks.map(block => ({ id: block.id, type: block.type, contentLength: block.content.length, content: block.content, })), activeBlock: registry.activeBlock ? { type: registry.activeBlock.type, contentLength: registry.activeBlock.content.length, content: registry.activeBlock.content, } : null, tagState: { ...registry.activeTagState }, }, fixedContent: registry.activeBlock ? fixIncompleteMarkdown(registry.activeBlock.content, registry.activeTagState) : null, timestamp: now, deltaMs, }; // Emit snapshot onDebug(snapshot); // Update refs for next iteration lastUpdateTimeRef.current = now; previousContentRef.current = children; }, [children, onDebug, registry]); // Handle empty content if (!children || children.trim().length === 0) { return null; } // Container style const containerStyle: ViewStyle = { flex: 1, ...(style as ViewStyle), }; return ( {/* Stable blocks — memoized, never re-render */} {registry.blocks.map(block => ( ))} {/* Active block — re-renders on each token */} ); }, (prev, next) => { // Custom comparison for memo // Re-render if content, theme, or isComplete changes return ( prev.children === next.children && prev.theme === next.theme && prev.isComplete === next.isComplete ); }); StreamdownRN.displayName = 'StreamdownRN'; export default StreamdownRN;