Files
multica/apps/mobile/docs/markdown-rendering-adr.md
Naiyuan Qing 383dd23c36 docs(mobile): markdown rendering ADR + selectable carve-out
Formalises the rendering decision (Path B — react-native-markdown-display +
Shiki + custom renderers) into a one-page ADR with A-tier source citations,
keeping the longer research log alongside it.

Adds a `selectable` opt-out to `CodeBlock` and `Markdown` so timeline
comments can disable RN's UIKit selection magnifier when an outer Pressable
already owns the long-press gesture, while issue descriptions and chat
messages keep the default selectable behaviour for copy-to-clipboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:50:12 +08:00

16 KiB

ADR — Markdown rendering on apps/mobile/

Status: Accepted Date: 2026-05-19 Supersedes: nothing (formalises what markdown-renderer-research.md was already documenting in research form)

This is the durable architecture-decision record for how the mobile app renders markdown. markdown-renderer-research.md continues to hold the detailed history and incident log; this file is the one-page answer to "what are we using and why" with A-tier sources.


Context — what the RN ecosystem actually offers (2026-05)

There are exactly three rendering paths available for markdown in React Native today. Each has hard, library-independent constraints rooted in the RN platform itself:

Path How it renders Strengths Hard limits
A — Native md4c parses → iOS NSAttributedString / Android Spannable Fastest, doesn't touch RN's nested-<Text> layout path Cannot inject custom React for any leaf node (enriched-markdown issue #54, #232, maintainer: "no custom renderers, by design")
B — React tree Parse to AST → walk → render every token as nested <Text> / <View> Full custom React rendering for every node Triggers RN's 10-year-old nested-<Text> bugs: #10775, #45925, #6728borderWidth / padding / margin are not NSAttributedString attributes and either silently drop or force-break inline runs. CJK paragraphs amplify the symptom via UAX #14 / Kinsoku.
C — WebView Web markdown lib (e.g. react-markdown) inside react-native-webview / Expo DOM Components Identical to web output Slower startup (no Hermes bytecode), async JSON-only bridge, native UI cannot embed inside the WebView, scroll & keyboard UX divergent. Expo's own docs acknowledge these are public trade-offs (dom-components.mdx)

Fact: as of 2026-05, no single library satisfies path A + path B simultaneously — i.e. native rendering performance AND custom React component for arbitrary leaf nodes. This is an ecosystem-level constraint, not a Multica problem.

Concrete library survey (2026-05)

Library Path Last release Verdict for Multica
react-native-enriched-markdown A v0.5.0 (Apr 2026) Selected for prose. Expo officially recommends it in Edit rich text — A-tier endorsement. Software Mansion (same team as Reanimated / Gesture Handler)
react-native-streamdown A + worklets active 2026 Not adopted. Built on enriched-markdown, optimised for AI streaming. Web/desktop don't use a streaming-specific renderer either, mobile streaming isn't currently a top product pain
react-native-marked B v8.1.0 (2026-05-14) Not adopted. v7 removed CustomToken, v8 added "React component embedding" but no token-level customisation. Pure <Text> tree → would trigger nested-text bugs
amilmohd155/react-native-markdown B v0.8.5 (Jan 2026) Not adopted. Same nested-<Text> constraint as react-native-marked. 14 , single maintainer, not production-validated
react-native-markdown-display B ~2 years stale Maintainer publicly recommends migrating away
Expo DOM Components (web react-markdown inside WebView) C Expo SDK 53+ stable Not adopted as primary. Reserved as escape hatch for future LaTeX / Mermaid / very wide tables
vercel/streamdown n/a active 2026 Not applicable — name collides with SM's RN library but Vercel's streamdown is web-only (Next.js, AI SDK). Documented here only to dispel confusion

Closed-source production references like Stream Chat's StreamingMessageView and assistant-ui do not publish their RN markdown implementation. There is no public industry standard.


Decision

Multica mobile uses a segment-based hybrid renderer that dispatches each markdown token type to the renderer that doesn't trip on that token's specific platform trap.

content (string)
  ↓ preprocessMobileMarkdown
     legacy mention shortcode  →  [@name](mention://type/id)
     legacy file-card lines    →  [📎 name](url)
     HTML <br>                 →  CommonMark "  \n" hard break
  ↓ splitMarkdown                 (marked@18 lexer; we use the AST only)
     code fence          → { type:'code',  lang, code }
     paragraph w/ image  → image promoted to block, text rejoins prose
     everything else     → { type:'prose', content: token.raw }
  ↓ render per-segment
     prose  → <EnrichedMarkdownText>   path A
     code   → <CodeBlock>              path B (controlled — no CJK mixing)
     image  → <MarkdownImage>          path B (controlled — single element)

Each per-segment routing decision avoids the failure mode of the path it chose:

Segment Routing Trap avoided
Prose (paragraphs, headings, lists, quotes, tables, inline code, links, strong/em) enriched-markdown (path A) Would otherwise need React tree → CJK + inline-code chip = 5-7 line breakage per chip (the 2026-05-09 incident)
Fenced code block own CodeBlock with one <Text> per line, token spans nested but content is code only — no CJK paragraphs to trigger UAX #14. Shiki for highlighting Native rendering can't expose Shiki tokens; React tree of code-only doesn't trigger the CJK amplification of the nested-text bug
Image expo-image wrapped in Pressable for lightbox dispatch. One element, no nested-text mixing RN <Image> can't be inline in <Text>; lightbox needs Pressable not addressable inside attributed string
(future) LaTeX / Mermaid not yet — when needed, separate component running Expo DOM Components path C is the only one that gets these for free, but the WebView penalty isn't worth paying for prose

Marked@18 is used as a lexer only

marked.lexer(input) produces a token list. We never feed marked's HTML output to anything. marked is a 10-year-old, A-tier-maintained CommonMark/GFM lexer (marked.js docs), and running it as a pure JS function on every markdown body is cheap.

This is necessary because enriched-markdown's internal md4c AST is not exposed — we'd have no way to find segment boundaries otherwise.

Theming

Colors flow from the RNR design system:

  • global.css defines CSS variables under :root (light) and .dark:root (dark)
  • lib/theme.ts mirrors these as pre-resolved hsl(...) strings (CSS variable syntax doesn't work in RN imperative style objects)
  • lib/use-color-scheme.ts is the single source of truth for the current scheme, persisted in expo-secure-store

For prose (path A, must use imperative style object — enriched is native md4c, no className support), useMarkdownStyle() derives the full style object from THEME[scheme]. For non-prose (paths B controlled), all container styling uses NativeWind className like the rest of RNR.

enriched-markdown's hidden-default trap (documented for posterity)

enriched-markdown's normalizeMarkdownStyle.js carries a frozen table of ~30 hardcoded light-mode color defaults. Fields not explicitly overridden in useMarkdownStyle() use those hardcoded values and disappear (or render garishly) in dark mode. Every color field must be explicitly mapped to a THEME[scheme] token. When upgrading enriched-markdown (v0.6+), re-audit normalizeMarkdownStyle.js for newly-added color fields — they will also ship light-mode defaults.


Consequences

What we get

  • Native attributed-string performance for the 95% case (prose)
  • Web-parity syntax highlighting (Shiki, same themes as web)
  • Image lightbox with native expo-image caching
  • Full GFM support via enriched-markdown's flavor="github"
  • Light / dark mode that follows lib/use-color-scheme
  • Expo's own A-tier recommendation as our prose engine

What we pay

  • Three rendering paths to maintain instead of one
  • Theme integration: every enriched color field must be explicitly mapped; hidden-default trap re-emerges on every enriched upgrade
  • Code blocks nested in a list item stay with the enriched prose stream (don't get Shiki) — top-level code is the >95% case, acceptable
  • LaTeX / Mermaid not currently supported

Known limitations and mitigations

Inline code chip top-heavy padding — visible as ~13pt empty space above vs ~3pt below glyphs in chips inside CJK paragraphs (seen in #MUL-2397 and #MUL-2395 dark screenshots, 2026-05-19).

  • Root cause: enriched-markdown applies hardcoded internal padding to inline code that cannot be turned off via markdownStyle.code. The CodeStyle schema does not expose padding* / baselineOffset / lineHeight knobs.
  • Not an RN/iOS platform issue: Discord, Slack, Telegram, Mattermost mobile all render inline code with background + monospace and do not show this asymmetry — confirming the artifact is library-specific.
  • Upstream tracking: software-mansion-labs/react-native-enriched-markdown#255 (filed 2026-04-20 by @xindixu, maintainer unresponsive as of 2026-05-19).
  • Failed mitigation (reverted): reducing MD_LINE.body from 24 to 20 shrinks absolute padding but does not change the asymmetry ratio — net negative (cost CJK leading, didn't fix the chip). See markdown-renderer-research.md decision log 2026-05-19.

Mitigation applied (2026-05-19) — inline code rendered WITHOUT a background:

code: {
  color: t.brand,
  backgroundColor: "transparent",
  borderColor: "transparent",
  fontFamily: MONO_FONT,  // Menlo on iOS, monospace on Android
},
  • backgroundColor: "transparent" — enriched still paints the padding rectangle internally, but it's invisible, so the top-heavy artifact disappears. Glyph baselines are unaffected (baseline is a font-metric property, not a background-painting property).
  • fontFamily: MONO_FONT — enriched's native default for code is '' (inherit from paragraph), so without this override mobile inline code would lose its only visual identity once the chip is removed.
  • color: t.brand — secondary identification tint, distinguishes inline code from regular prose alongside the monospace.
  • Visual trade-off: mobile no longer matches web/desktop chip style. Inline code on mobile reads as "tinted monospace span". Acceptable given that the alternative is the top-heavy chip artifact.
  • Revisit when: upstream issue #255 ships a padding control. At that point switch back to a tinted-background chip for cross-platform parity.

Why we did NOT fork the library or rewrite the prose layer to a React-tree renderer:

  • Forking enriched-markdown means maintaining a native-code (ObjC/Swift
    • Kotlin) patch indefinitely; the ROI for one styling fix is poor.
  • Rewriting the prose layer to a React-tree renderer (e.g. react-native-marked) would re-introduce the RN nested-<Text> platform bugs documented above — same root cause as the 2026-05-09 inline-code CJK line-breakage incident.

What's explicitly out of scope

  • Replacing the whole stack with a single library: every alternative surveyed above either drops path A (perf) or drops custom React (lightbox / syntax highlight). No path forward there until the ecosystem ships a library that satisfies both.
  • Migrating chat to streamdown: web/desktop have no streaming-specific renderer either; mobile parity demands the same. Reconsider only if AI-chat streaming becomes a top user complaint.

When to revisit this ADR

  • enriched-markdown ships custom React leaf-node rendering (currently not on roadmap — roadmap addresses EnrichedMarkdownTextInput, the editor, not the renderer)
  • A new library appears that satisfies path A + path B simultaneously
  • Expo SDK ships a first-party markdown renderer (currently doesn't)
  • The product team commits to LaTeX / Mermaid as core features — Expo DOM Components becomes the right answer for that surface

Sources (A-tier only)

Official documentation

Library sources (maintainer-authoritative)

Platform constraint sources (the "why we can't just use path B everywhere")

Reference implementations (same-pattern peers)

In-repo cross-references

  • apps/mobile/lib/markdown/markdown.tsx — entry point
  • apps/mobile/lib/markdown/split-markdown.ts — segment splitter
  • apps/mobile/lib/markdown/markdown-style.tsuseMarkdownStyle() theme bridge
  • apps/mobile/lib/markdown/code-block.tsx — Shiki-powered code segment
  • apps/mobile/lib/markdown/markdown-image.tsx — lightbox-aware image segment
  • apps/mobile/docs/markdown-renderer-research.md — full incident log and historical context
  • apps/mobile/CLAUDE.md — mobile-wide rules including theme/CSS-variable system