Major architectural release. Drop-in replacement for react-markdown in streaming AI chat UIs, now with first-class Next.js / RSC support, lazy adapters for Shiki/KaTeX/Mermaid, and a hardened parser that survives adversarial input.
📦 npm · 📚 README · 🛡️ Threat model
Architecture
Single package, multiple sub-paths so consumers tree-shake what they don't use:
stream-md— default React entry, carries"use client"stream-md/core— framework-agnostic, no Reactstream-md/server— RSC-safe parser returning JSON-serializableBlock[]stream-md/next— Next.js helpers:<StreamMD>,<StreamMDServer>,<AssistantMarkdown>(Vercel AI SDK)stream-md/strict— opt-inmicromarkadapter for full CommonMark + GFMstream-md/shiki,stream-md/katex,stream-md/mermaid— lazy adaptersstream-md/plugins— publicBlockPlugin+InlinePluginAPI
Streaming correctness
- Speculative inline closure: unclosed
**borenders as bold now withdata-tentative="true", so when**arrives there's nothing to repaint - Fixed: premature heading close, table-on-separator-row only, list nesting via indent stack, setext headings, indented code blocks, hard breaks, code-fence info-string attributes, CommonMark left/right flank rules for
*/_, balanced-paren link URLs, smallest-run inline code per spec, autolinks<https://...>/<user@host>, prefix-diff resilience (auto-reset on non-prefix replace) - Headline guarantee: streaming-equivalence property test — for any document, char-by-char streaming produces the same final AST as atomic parsing (verified by fast-check)
Security
- URL sanitizer rejects
javascript:,vbscript:, and unsafedata:schemes by default - Control-char-smuggled schemes (e.g.
java\nscript:) blocked - Hard caps: 1 MB document length, 4-level inline recursion depth
- External links get
target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer" - Tables use class-based alignment — works under strict CSP (no
'unsafe-inline'styles) - See SECURITY.md for the threat model
Performance
- Active code blocks render plain
<code>while streaming; highlighter runs once on close - Block memoization actually fires now (
overridesand plugin arrays are identity-stabilized) useSyncExternalStorereplaces side-effectinguseMemo(StrictMode-safe, RSC-safe)- Parsed table/list cached on
block.meta.parsedonce on close — no re-parse per render - Bundle: 9.77 kB gz default, 6.51 kB gz core, 2.21 kB gz server
Highlighter
Set-based keyword/builtin lookup- Member-access keyword suppression (
obj.returnno longer highlights) - Real C/C++ tables (no longer aliased to Java)
- JSX/TSX tag detection
- Multi-line strings: Python
"""...""", JS template literals, JSDoc
Themes
Catppuccin (Mocha + Latte), Tokyo Night (+ Storm), GitHub (Dark + Light), Solarized (Dark + Light).
Tooling
- Vitest + jsdom + RTL + fast-check (137 tests)
- ESLint flat config + Prettier
- size-limit budgets enforced in CI
- GitHub Actions CI on Node 18/20/22
- Changesets release workflow
Migration from react-markdown
```diff
- import ReactMarkdown from 'react-markdown';
- import { StreamMD } from 'stream-md';
- import 'stream-md/styles.css';
- {text}
```
Install
```bash
npm install stream-md
```