# 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-`` layout path | Cannot inject custom React for any leaf node (`enriched-markdown` issue [#54](https://github.com/software-mansion-labs/react-native-enriched-markdown/issues/54), [#232](https://github.com/software-mansion-labs/react-native-enriched-markdown/issues/232), maintainer: "no custom renderers, by design") | | **B — React tree** | Parse to AST → walk → render every token as nested `` / `` | Full custom React rendering for every node | Triggers RN's 10-year-old nested-`` bugs: [#10775](https://github.com/facebook/react-native/issues/10775), [#45925](https://github.com/facebook/react-native/issues/45925), [#6728](https://github.com/facebook/react-native/issues/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](https://docs.expo.dev/guides/dom-components/)) | **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`](https://github.com/software-mansion-labs/react-native-enriched-markdown) | A | v0.5.0 (Apr 2026) | **Selected for prose.** Expo officially recommends it in [Edit rich text](https://docs.expo.dev/guides/editing-richtext/) — A-tier endorsement. Software Mansion (same team as Reanimated / Gesture Handler) | | [`react-native-streamdown`](https://github.com/software-mansion-labs/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`](https://github.com/gmsgowtham/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 `` tree → would trigger nested-text bugs | | [`amilmohd155/react-native-markdown`](https://github.com/amilmohd155/react-native-markdown) | B | v0.8.5 (Jan 2026) | Not adopted. Same nested-`` constraint as `react-native-marked`. 14 ⭐, single maintainer, not production-validated | | [`react-native-markdown-display`](https://www.npmjs.com/package/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`](https://github.com/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`](https://getstream.io/blog/react-native-assistant/) and [assistant-ui](https://www.assistant-ui.com/blog/2026-03-launch-week) 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
→ 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 → path A code → path B (controlled — no CJK mixing) image → 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 `` 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 `` can't be inline in ``; 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](https://marked.js.org/)), 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`](https://github.com/software-mansion-labs/react-native-enriched-markdown/issues/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: ```ts 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-`` 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](https://docs.expo.dev/guides/editing-richtext/) — directly recommends `react-native-enriched-markdown` - [Expo — Using React DOM in Expo native apps](https://docs.expo.dev/guides/dom-components/) — DOM Components trade-offs (path C) ### Library sources (maintainer-authoritative) - [`software-mansion-labs/react-native-enriched-markdown`](https://github.com/software-mansion-labs/react-native-enriched-markdown) — path A primary - [`software-mansion-labs/react-native-streamdown`](https://github.com/software-mansion-labs/react-native-streamdown) — surveyed, not adopted - [`gmsgowtham/react-native-marked`](https://github.com/gmsgowtham/react-native-marked) — path B surveyed - [`amilmohd155/react-native-markdown`](https://github.com/amilmohd155/react-native-markdown) — path B surveyed - [`vercel/streamdown`](https://github.com/vercel/streamdown) — web only, documented to dispel naming collision - [marked.js documentation](https://marked.js.org/) — lexer we use - [Shiki](https://shiki.style/) + [`react-native-shiki-engine`](https://www.npmjs.com/package/react-native-shiki-engine) — code highlighting - [`expo-image`](https://docs.expo.dev/versions/latest/sdk/image/) + [`jobtoday/react-native-image-viewing`](https://github.com/jobtoday/react-native-image-viewing) — image rendering - [md4c](https://github.com/mity/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`](https://github.com/facebook/react-native/issues/10775) — nested-`` border ignored (Nov 2016, locked, no fix) - [`facebook/react-native#45925`](https://github.com/facebook/react-native/issues/45925) — same bug re-filed, still open under New Architecture - [`facebook/react-native#6728`](https://github.com/facebook/react-native/issues/6728) — `margin` / `padding` ignored on nested `` - [`react-native-community/discussions-and-proposals#695`](https://github.com/react-native-community/discussions-and-proposals/issues/695) — official statement on inline-text styling limits ### Reference implementations (same-pattern peers) - [Mattermost mobile — `app/components/markdown/`](https://github.com/mattermost/mattermost-mobile/tree/main/app/components/markdown) — same segment-dispatch pattern, different engines - Stream Chat [`StreamingMessageView`](https://getstream.io/blog/react-native-assistant/) — closed-source, recorded only as evidence that "no public standard exists" - [assistant-ui multi-platform launch](https://www.assistant-ui.com/blog/2026-03-launch-week) — closed-source ### 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.ts` — `useMarkdownStyle()` 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