261 lines
16 KiB
Markdown
261 lines
16 KiB
Markdown
# 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](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 `<Text>` / `<View>` | Full custom React rendering for every node | Triggers RN's 10-year-old nested-`<Text>` 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 `<Text>` 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-`<Text>` 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 <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](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-`<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](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-`<Text>` 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 `<Text>`
|
|
- [`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
|