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>
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, #6728 — borderWidth / 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.cssdefines CSS variables under:root(light) and.dark:root(dark)lib/theme.tsmirrors these as pre-resolvedhsl(...)strings (CSS variable syntax doesn't work in RN imperative style objects)lib/use-color-scheme.tsis the single source of truth for the current scheme, persisted inexpo-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-imagecaching - 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. TheCodeStyleschema does not exposepadding*/baselineOffset/lineHeightknobs. - 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.bodyfrom 24 to 20 shrinks absolute padding but does not change the asymmetry ratio — net negative (cost CJK leading, didn't fix the chip). Seemarkdown-renderer-research.mddecision 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 forcodeis''(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
- Expo — Edit rich text guide — directly recommends
react-native-enriched-markdown - Expo — Using React DOM in Expo native apps — DOM Components trade-offs (path C)
Library sources (maintainer-authoritative)
software-mansion-labs/react-native-enriched-markdown— path A primarysoftware-mansion-labs/react-native-streamdown— surveyed, not adoptedgmsgowtham/react-native-marked— path B surveyedamilmohd155/react-native-markdown— path B surveyedvercel/streamdown— web only, documented to dispel naming collision- marked.js documentation — lexer we use
- Shiki +
react-native-shiki-engine— code highlighting expo-image+jobtoday/react-native-image-viewing— image rendering- md4c — the C library that backs enriched-markdown on native
Platform constraint sources (the "why we can't just use path B everywhere")
facebook/react-native#10775— nested-<Text>border ignored (Nov 2016, locked, no fix)facebook/react-native#45925— same bug re-filed, still open under New Architecturefacebook/react-native#6728—margin/paddingignored on nested<Text>react-native-community/discussions-and-proposals#695— official statement on inline-text styling limits
Reference implementations (same-pattern peers)
- Mattermost mobile —
app/components/markdown/— same segment-dispatch pattern, different engines - Stream Chat
StreamingMessageView— closed-source, recorded only as evidence that "no public standard exists" - assistant-ui multi-platform launch — closed-source
In-repo cross-references
apps/mobile/lib/markdown/markdown.tsx— entry pointapps/mobile/lib/markdown/split-markdown.ts— segment splitterapps/mobile/lib/markdown/markdown-style.ts—useMarkdownStyle()theme bridgeapps/mobile/lib/markdown/code-block.tsx— Shiki-powered code segmentapps/mobile/lib/markdown/markdown-image.tsx— lightbox-aware image segmentapps/mobile/docs/markdown-renderer-research.md— full incident log and historical contextapps/mobile/CLAUDE.md— mobile-wide rules including theme/CSS-variable system