| 1 | # `@clo/react-markdown` |
| 2 | |
| 3 | This package exports a React component to render Markdown using the [unified] |
| 4 | ecosystem's Markdown tools (remark, rehype). The exported `<Markdown />` |
| 5 | component is extremely memoized, making it suitable for streaming situations |
| 6 | such as LLM chat interfaces. |
| 7 | |
| 8 | - **Bring your Existing Pipeline**: The primary option for configuration is |
| 9 | providing a `unified.Processor` with remark and rehype plugins configured. |
| 10 | Additionally, you can override the element renderers with the `components` |
| 11 | attribute. By default, `@clo/react-markdown` applies a bare bones config so |
| 12 | you can get started with just `<Markdown content="hi" />`. Configuration is |
| 13 | done either with a provider, or right at the component level. |
| 14 | - **Handle Partial Markdown**: When the `predict` prop is set, the document |
| 15 | will be treated as an incomplete stream at the end of a document. |
| 16 | `hello **world` will be emitted as `hello <strong>world</strong>`, perfect for |
| 17 | the LLM case. (standalone predictor exported as `@clo/react-markdown/Predict`) |
| 18 | |
| 19 | The motivation for this package is to have an easy to understand version of |
| 20 | [Streamdown]. With me banning all Vercel software at the company I work at, |
| 21 | we needed a simple, trustable solution for this problem. Some differences |
| 22 | to Streamdown: |
| 23 | |
| 24 | - **Better Memoization**: All inline components will preserve their state, even |
| 25 | as adjacent content changes. This is done to preserve remounts for things like |
| 26 | custom `<a>` tags or other components. (For example, if a custom `<a>` fetches |
| 27 | previewing data and provides a hover card, that card won't flicker). |
| 28 | - **Better Prediction**: While optimizing edge cases, the prediction system became |
| 29 | better than the Streamdown one on accident. |
| 30 | - **Headless UI**: No built in styles or components, bring your own CSS to blend |
| 31 | your markdown with your existing theme. `@clo/react-markdown` simply takes in |
| 32 | your existing `unified` pipeline and works off of that. |
| 33 | - **Easy to Audit**: Only ~1000 lines of modern TypeScript. 4 source files. |
| 34 | |
| 35 | [Streamdown]: https://streamdown.ai |
| 36 | [unified]: https://unifiedjs.com/ |
| 37 | |
| 38 | ## Getting Started |
| 39 | |
| 40 | Install off of the [JSR package](https://jsr.io/clo/react-markdown): |
| 41 | |
| 42 | ```sh |
| 43 | npx jsr add @clo/react-markdown |
| 44 | pnpm add jsr:@clo/react-markdown |
| 45 | ``` |
| 46 | |
| 47 | ```tsx |
| 48 | import { Markdown } from "@clo/react-markdown"; |
| 49 | import remarkGfm from "remark-gfm"; |
| 50 | import remarkParse from "remark-parse"; |
| 51 | import { unified } from "unified"; |
| 52 | |
| 53 | // Define any unified processing pipeline. We expect that your |
| 54 | // app already has one of these. @clo/react-markdown works with |
| 55 | // most pipelines, but some important things to note: |
| 56 | // - During parsing, all nodes have to have a source location. |
| 57 | // - The pipeline must output a remark (Markdown) AST. |
| 58 | // - You can also add `remarkRehype` and some rehype plugins. |
| 59 | // - Do not include the `rehypeReact` plugin in here. It is added for you. |
| 60 | const processor = unified().use(remarkParse).use(remarkGfm); |
| 61 | |
| 62 | // Define custom components. (maps to rehypeReact's option) |
| 63 | const components = { |
| 64 | a: MarkdownLink, |
| 65 | code: InlineCode, |
| 66 | }; |
| 67 | |
| 68 | export function HelloWorld() { |
| 69 | return ( |
| 70 | <Markdown |
| 71 | content="hello **world" |
| 72 | // Predict closing tags for streaming use case |
| 73 | predict |
| 74 | // These are optional, and can be set via `MarkdownOptionsProvider` |
| 75 | processor={processor} |
| 76 | components={components} |
| 77 | /> |
| 78 | ); |
| 79 | } |
| 80 | ``` |
| 81 | |
| 82 | If you want to play with the example project, which offers a comparison to |
| 83 | Streamdown and the non-memoized `react-markdown` package. |
| 84 | |
| 85 | ``` |
| 86 | git clone https://git.paperclover.net/clo/react-markdown |
| 87 | cd react-markdown |
| 88 | pnpm demo # auto-install |
| 89 | ``` |
| 90 | |
| 91 | ## Architecture |
| 92 | |
| 93 | The `Markdown` component is built out of a memoizer which uses the following tricks to improve performance: |
| 94 | |
| 95 | - Using proper `React.memo()` calls. Obviously. |
| 96 | - Prediction is implemented in a stateful way that only parses the tail end of |
| 97 | the document, marking how much of the document is stable and where possible |
| 98 | incomplete syntax may live. Since prediction only applies at the end, changing |
| 99 | text midway through can invalidate the whole predictor. |
| 100 | - Separate the parsed document into "blocks", noting the source location of |
| 101 | where each block lives. |
| 102 | - Only start parsing after the first changed character, rounded to the nearest |
| 103 | block. In the append-only stream, this essentially means the last two blocks |
| 104 | are the only things being re-parsed. |
| 105 | - Similarly, run AST transforms only on the changed data. This step has a couple |
| 106 | of slow paths for when reference link definitions are added or edited, since |
| 107 | it means any places that might have used a reference link may now have to |
| 108 | reflect it. |
| 109 | - After all that, a special AST -> React node transform is used that diffs the |
| 110 | new ast with the last ast, reusing React nodes whenever possible. It supports |
| 111 | nested children as well as re-ordering top level blocks. This is what prevents |
| 112 | most rerenders and is the "secret sauce". |
| 113 | |
| 114 | All put together, basically nothing rerenders except what actually changed, and the document is beautiful. |