Skip to content

Conversation

@edgarpavlovsky
Copy link
Member

@edgarpavlovsky edgarpavlovsky commented Dec 23, 2025

Custom Block-Level Renderers for StreamdownRN

This PR adds a powerful new extensibility feature to streamdown-rn: fully customizable block-level renderers. You can now override how any block element is rendered while preserving all the streaming, memoization, and performance benefits of the library.

The Problem

Previously, if you wanted custom rendering for code blocks (e.g., with a copy button), images (e.g., lazy loading with FastImage), or links (e.g., in-app navigation), you had to fork the library or post-process the output.

The Solution

Pass a renderers prop to override any block-level element:

import { StreamdownRN, type CodeBlockRendererProps } from 'streamdown-rn';

function CustomCodeBlock({ code, language, theme }: CodeBlockRendererProps) {
  return (
    <View>
      <Text>{language}</Text>
      <Pressable onPress={() => Clipboard.setString(code)}>
        <Text>Copy</Text>
      </Pressable>
      <Text style={{ fontFamily: 'monospace' }}>{code}</Text>
    </View>
  );
}

<StreamdownRN renderers={{ codeBlock: CustomCodeBlock }}>
  {streamingMarkdown}
</StreamdownRN>

Supported Elements

Element Props Use Cases
codeBlock code, language, theme Syntax highlighting, copy button, line numbers
image src, alt?, title?, theme Lazy loading, lightbox, CDN transforms
link href, children, title?, theme In-app navigation, external link handling
heading level, children, theme Anchor links, collapsible sections
table headers, rows, alignments, theme Sortable, filterable, responsive tables
blockquote children, theme Callouts, admonitions, tips/warnings

Key Design Decisions

  1. Streaming just works — Custom renderers receive progressively longer content during streaming. No special handling needed.
  2. Memoization preserved — StableBlock memo comparison updated to include renderer references. Stable blocks with unchanged renderers never re-render.
  3. Type-safe props — Each renderer has a dedicated props interface with full JSDoc documentation.
  4. Theme access — All renderers receive the current theme for consistent styling.
  5. Sanitized by default — URLs are pre-sanitized before reaching custom renderers (XSS protection).

Changes

Core (streamdown-rn)

  • types.ts: Added 6 renderer prop interfaces + CustomRenderers registry
  • ASTRenderer.tsx: Check for custom renderers before default rendering
  • StableBlock.tsx: Updated memo comparison to include all renderer references
  • index.ts: Export all new types

Testing

  • Added 53 new tests for custom renderer detection, streaming, and edge cases
  • Total: 271 tests passing

CI

  • Updated workflow to fail on TypeScript warnings (not just errors)

Documentation

  • ARCHITECTURE.md: Full documentation of custom renderer API with examples

Test Plan

  • Unit tests for all 6 renderer types (detection, streaming, props extraction)
  • Manual testing in debugger-ios app with "All" mode toggle
  • Verify default renderers still work when custom not provided
  • TypeScript strict mode passes
  • CI passes

This makes streamdown-rn much more extensible for production apps that need custom UI for markdown elements.

@edgarpavlovsky edgarpavlovsky marked this pull request as ready for review December 23, 2025 01:52
@rossaai
Copy link

rossaai commented Dec 26, 2025

Great! This new feature I was looking for real personalization.

@rossaai
Copy link

rossaai commented Dec 26, 2025

My final recommendation to have a consistency and extensible nodes renders is merging renders and componentRegistry into a single props called components or nodes. You can follow the best practices of react-markdown.

PROPOSE 1:

import type { Content, RootContentMap } from 'mdast';

/**
 * Props for the main StreamdownRN component
 */
export interface StreamdownRNProps {
  components?: {
    [Key in keyof RootContentMap]?: (props: {
      node: Content;
      theme: ThemeConfig;
      isStreaming: boolean;
      key?: string | number;
    }) => React.ReactNode;
    component?: {
      [name as string]: ComponentDefinition & {
        validator?: (props: Record<string, unknown>) => ValidationResult;
      }
    }
  };
  ...
}

PROPOSE 2:

import type { Content, RootContentMap } from "mdast";

/**
 * Props for the main StreamdownRN component
 */
export interface StreamdownRNProps {
  nodes?: {
    [Key in keyof RootContentMap]?: (props: {
      node: Content;
      theme: ThemeConfig;
      isStreaming: boolean;
      key?: string | number;
    }) => React.ReactNode;
  };
  components?: {
    [name as string]: ComponentDefinition & {
      validator?: (props: Record<string, unknown>) => ValidationResult;
    };
  };
  ...
}

@rossaai
Copy link

rossaai commented Dec 26, 2025

Other recommendation is allow to override all the nodes/elements types and expose all the props as possible, because it will allow to cover all 100% including the outliers situations.

Exposing only codeBlock, image, link, heading, table, blockquote covers the 95% of scenarios but when some power user (like me) want to update a specific component/node will get frustrated.

@rossaai
Copy link

rossaai commented Dec 27, 2025

Here is a situation in our application that our default fontSize is 17, the situation is the ASTRenderer does not allow to override or change the fontSize, lineHeight, fontWeight, and letterSpacing.

Are you considering introducing that props for higher customization in ThemeConfig?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants