authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-19 03:04:11-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 01:17:10-07:00
log5f937e9fbcf25ac5a81c8e4f9fa3b628197c9325
tree7069f977ca63387a9d0be21a28a9373547f03ce5
parent8e7f246a0e706c2fcdf48be112d652492568b74d
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

fix: re-use parsing pass


5 files changed, 144 insertions(+), 139 deletions(-)

README.md+2-3
......@@ -1,7 +1,7 @@
11# `@clo/react-markdown`
22
3This package exports a React component to render markdown using the [unified]
4ecosystem's markdown tools ([remark], [rehype]). The exported `<Markdown />`
3This package exports a React component to render Markdown using the [unified]
4ecosystem's Markdown tools (remark, rehype). The exported `<Markdown />`
55component is extremely memoized, making it suitable for streaming situations
66such as LLM chat interfaces.
77
......@@ -32,7 +32,6 @@ with their library:
3232
3333[Streamdown]: https://streamdown.ai
3434[unified]: https://unifiedjs.com/
35[remark]: https://remark.js.org/
3635
3736## Getting Started
3837
package.json+11-11
......@@ -18,16 +18,7 @@
1818 "prepublishOnly": "vp run build"
1919 },
2020 "dependencies": {
21 "@clo/lib": "jsr:^3.0.0",
22 "@types/unist": "^3.0.3",
23 "mdast-util-from-markdown": "^2.0.3",
24 "rehype-react": "^8.0.0",
25 "rehype-stringify": "^10.0.1",
26 "remark-gfm": "^4.0.1",
27 "remark-parse": "^11.0.0",
28 "remark-rehype": "^11.1.2",
29 "remark-stringify": "^11.0.0",
30 "unified": "^11.0.5"
21 "@clo/lib": "jsr:^3.0.0"
3122 },
3223 "devDependencies": {
3324 "@tailwindcss/typography": "^0.5.19",
......@@ -35,11 +26,16 @@
3526 "@types/node": "^25.5.0",
3627 "@types/react": "^19.2.14",
3728 "@types/react-dom": "^19.2.3",
29 "@types/unist": "^3.0.3",
3830 "@typescript/native-preview": "7.0.0-dev.20260318.1",
3931 "@vitejs/plugin-react": "^6.0.1",
4032 "react": "^19.2.4",
4133 "react-dom": "^19.2.4",
4234 "react-scan": "^0.5.3",
35 "rehype-react": "^8.0.0",
36 "remark-gfm": "^4.0.1",
37 "remark-parse": "^11.0.0",
38 "remark-rehype": "^11.1.2",
4339 "streamdown": "^2.5.0",
4440 "tailwindcss": "^4.2.2",
4541 "typescript": "^5.9.3",
......@@ -47,7 +43,11 @@
4743 "vite-plus": "^0.1.11"
4844 },
4945 "peerDependencies": {
50 "react": "*"
46 "react": "*",
47 "rehype-react": "^8.0.0",
48 "remark-parse": "^11.0.0",
49 "remark-rehype": "^11.0.0",
50 "unified": "^11.0.0"
5151 },
5252 "packageManager": "pnpm@10.26.1",
5353 "pnpm": {
pnpm-lock.yaml+21-56
......@@ -15,36 +15,6 @@ importers:
1515 '@clo/lib':
1616 specifier: jsr:^3.0.0
1717 version: '@jsr/clo__lib@3.0.0'
18 '@types/unist':
19 specifier: ^3.0.3
20 version: 3.0.3
21 mdast-util-from-markdown:
22 specifier: ^2.0.3
23 version: 2.0.3
24 rehype-react:
25 specifier: ^8.0.0
26 version: 8.0.0
27 rehype-stringify:
28 specifier: ^10.0.1
29 version: 10.0.1
30 remark-gfm:
31 specifier: ^4.0.1
32 version: 4.0.1
33 remark-parse:
34 specifier: ^11.0.0
35 version: 11.0.0
36 remark-rehype:
37 specifier: ^11.1.2
38 version: 11.1.2
39 remark-stringify:
40 specifier: ^11.0.0
41 version: 11.0.0
42 typescript:
43 specifier: ^5.9.3
44 version: 5.9.3
45 unified:
46 specifier: ^11.0.5
47 version: 11.0.5
4818 devDependencies:
4919 '@tailwindcss/typography':
5020 specifier: ^0.5.19
......@@ -61,6 +31,9 @@ importers:
6131 '@types/react-dom':
6232 specifier: ^19.2.3
6333 version: 19.2.3(@types/react@19.2.14)
34 '@types/unist':
35 specifier: ^3.0.3
36 version: 3.0.3
6437 '@typescript/native-preview':
6538 specifier: 7.0.0-dev.20260318.1
6639 version: 7.0.0-dev.20260318.1
......@@ -76,12 +49,30 @@ importers:
7649 react-scan:
7750 specifier: ^0.5.3
7851 version: 0.5.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
52 rehype-react:
53 specifier: ^8.0.0
54 version: 8.0.0
55 remark-gfm:
56 specifier: ^4.0.1
57 version: 4.0.1
58 remark-parse:
59 specifier: ^11.0.0
60 version: 11.0.0
61 remark-rehype:
62 specifier: ^11.1.2
63 version: 11.1.2
7964 streamdown:
8065 specifier: ^2.5.0
8166 version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
8267 tailwindcss:
8368 specifier: ^4.2.2
8469 version: 4.2.2
70 typescript:
71 specifier: ^5.9.3
72 version: 5.9.3
73 unified:
74 specifier: ^11.0.5
75 version: 11.0.5
8576 vite-plus:
8677 specifier: ^0.1.11
8778 version: 0.1.12(@types/node@25.5.0)(happy-dom@20.8.4)(jiti@2.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@25.5.0)(jiti@2.6.1)(yaml@2.8.2))(yaml@2.8.2)
......@@ -1557,9 +1548,6 @@ packages:
15571548 hast-util-sanitize@5.0.2:
15581549 resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
15591550
1560 hast-util-to-html@9.0.5:
1561 resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
1562
15631551 hast-util-to-jsx-runtime@2.3.6:
15641552 resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
15651553
......@@ -2005,9 +1993,6 @@ packages:
20051993 rehype-sanitize@6.0.0:
20061994 resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
20071995
2008 rehype-stringify@10.0.1:
2009 resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
2010
20111996 remark-gfm@4.0.1:
20121997 resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
20131998
......@@ -3546,20 +3531,6 @@ snapshots:
35463531 '@ungap/structured-clone': 1.3.0
35473532 unist-util-position: 5.0.0
35483533
3549 hast-util-to-html@9.0.5:
3550 dependencies:
3551 '@types/hast': 3.0.4
3552 '@types/unist': 3.0.3
3553 ccount: 2.0.1
3554 comma-separated-tokens: 2.0.3
3555 hast-util-whitespace: 3.0.0
3556 html-void-elements: 3.0.0
3557 mdast-util-to-hast: 13.2.1
3558 property-information: 7.1.0
3559 space-separated-tokens: 2.0.2
3560 stringify-entities: 4.0.4
3561 zwitch: 2.0.4
3562
35633534 hast-util-to-jsx-runtime@2.3.6:
35643535 dependencies:
35653536 '@types/estree': 1.0.8
......@@ -4286,12 +4257,6 @@ snapshots:
42864257 '@types/hast': 3.0.4
42874258 hast-util-sanitize: 5.0.2
42884259
4289 rehype-stringify@10.0.1:
4290 dependencies:
4291 '@types/hast': 3.0.4
4292 hast-util-to-html: 9.0.5
4293 unified: 11.0.5
4294
42954260 remark-gfm@4.0.1:
42964261 dependencies:
42974262 '@types/mdast': 4.0.4
src/Memoizer.ts+76-69
......@@ -1,40 +1,14 @@
11import { ASSERT, UNWRAP } from "@clo/lib/assert.ts";
2import { fromMarkdown } from "mdast-util-from-markdown";
32import { indexOfDiff } from "./strings.ts";
43import { memo, type JSX, type Key, type ReactNode } from "react";
54import rehypeReact, { type Components } from "rehype-react";
65import remarkRehype from "remark-rehype";
76import { jsx, jsxs, Fragment } from "react/jsx-runtime";
87import { unified, type Processor } from "unified";
9import type { Node } from "unist";
8import type { Node, Parent, Literal } from "unist";
109import remarkParse from "remark-parse";
1110
1211export const defaultProcessor = unified().use(remarkParse);
13const extractBlockOptions = {
14 extensions: [
15 {
16 disable: {
17 null: [
18 "attention",
19 "autolink",
20 "characterEscape",
21 "characterReference",
22 "codeText",
23 "hardBreakEscape",
24 "htmlText",
25 "labelStartImage",
26 "labelStartLink",
27 "labelEnd",
28 ],
29 },
30 },
31 ],
32};
33
34function extractBlockPositions(source: string, offset: number) {
35 const tree = fromMarkdown(source, extractBlockOptions);
36 return tree.children.map((child) => UNWRAP(UNWRAP(child.position).start.offset) + offset);
37}
3812
3913export type BaseProcessor = Processor<
4014 Node,
......@@ -43,27 +17,13 @@ export type BaseProcessor = Processor<
4317 undefined,
4418 undefined | JSX.Element
4519>;
46export type ReactProcessor = Processor<Node, undefined, undefined, undefined, JSX.Element>;
47
48function componentsAreEqual(left: Partial<Components>, right: Partial<Components>) {
49 const leftRecord = left as Record<string, unknown>;
50 const rightRecord = right as Record<string, unknown>;
51 const leftKeys = Object.keys(leftRecord);
52 if (leftKeys.length !== Object.keys(rightRecord).length) return false;
53 for (const key of leftKeys) {
54 if (leftRecord[key] !== rightRecord[key]) return false;
55 }
56 return true;
57}
58
59function setReactKey(element: JSX.Element, key: Key) {
60 const { ...props } = element.props;
61 return jsx(element.type, props, key);
62}
20type AstProcessor = Processor<Parent, undefined, undefined, Node, Node>;
21type ReactCompiler = Processor<Parent, undefined, undefined, Node, JSX.Element>;
6322
6423export class Memoizer {
6524 #baseProcessor: BaseProcessor = defaultProcessor;
66 #processor: ReactProcessor | null = null;
25 #astProcessor: AstProcessor | null = null;
26 #compiler: ReactCompiler | null = null;
6727 #previousComponents: Partial<Components> = {};
6828 #previous: string = "";
6929 #blockPositions: number[] = [];
......@@ -74,6 +34,7 @@ export class Memoizer {
7434 reset() {
7535 this.#previous = "";
7636 this.#blockPositions = [];
37 this.#blockSources = [];
7738 this.#reactNodes = [];
7839 }
7940
......@@ -82,19 +43,22 @@ export class Memoizer {
8243 components: Partial<Components>,
8344 ) {
8445 if (
85 this.#processor !== null &&
46 this.#astProcessor !== null &&
8647 this.#baseProcessor === processor &&
8748 componentsAreEqual(this.#previousComponents, components)
8849 )
8950 return;
9051
9152 this.#baseProcessor = processor;
92 if (!processor.attachers.some((plugin) => plugin[0] === rehypeReact)) {
53 if (!processor.attachers.some((plugin) => plugin[0] === remarkRehype)) {
9354 processor = processor();
94 if (!processor.attachers.some((plugin) => plugin[0] === remarkRehype)) {
95 processor.use(remarkRehype);
96 }
97 processor.use(rehypeReact, {
55 processor.use(remarkRehype);
56 processor.freeze();
57 }
58
59 this.#astProcessor = processor as unknown as AstProcessor;
60 this.#compiler = unified()
61 .use(rehypeReact, {
9862 jsx,
9963 jsxs,
10064 Fragment,
......@@ -104,13 +68,9 @@ export class Memoizer {
10468 components: Object.fromEntries(
10569 Object.entries(components).map(([k, v]) => [k, typeof v === "function" ? memo(v) : v]),
10670 ),
107 });
108 processor.freeze();
109 } else {
110 // TODO: troll and force memoize all components
111 }
71 })
72 .freeze() as ReactCompiler;
11273 this.#previousComponents = components;
113 this.#processor = processor as ReactProcessor;
11474 this.reset();
11575 }
11676
......@@ -130,12 +90,10 @@ export class Memoizer {
13090 if (blockIndexToReplace === -1) blockIndexToReplace = 0;
13191
13292 // Compute and replace changed block regions. This behavior relys on the
133 // fact that this block extraction process can be isolated across blocks.
134 // For the basic set of syntax extensions, this is true, for example an
135 // unclosed code fence is parsed as a code fence. Anything that isn't
136 // compatible will break very loudly since `Memoizer` never re-joins the
137 // blocks for rendering.
138 const newBlocks = extractBlockPositions(content.slice(preservedLength), preservedLength);
93 // fact that blocks generally do not affect each other.
94 const processor = UNWRAP(this.#astProcessor);
95 const parsedTree = processor.parse(content.slice(preservedLength));
96 const newBlocks = parsedTree.children.map((child) => UNWRAP(UNWRAP(child.position).start.offset) + preservedLength);
13997 blockPositions.splice(
14098 blockIndexToReplace,
14199 blockPositions.length - blockIndexToReplace,
......@@ -143,17 +101,26 @@ export class Memoizer {
143101 );
144102 this.#previous = content;
145103
146 // For all touched blocks (newBlocks), find which ones have actually changed
147 // their contents and run them through the full pipeline.
148 const processor = UNWRAP(this.#processor);
104 // For all touched blocks (newBlocks), transform their ASTs. This is done in
105 // a group to preserve shared contexts, such as reference links.
106 // TODO: need more effort for reference links without reparsing too much code.
149107 const blockSources = this.#blockSources;
108 const compiler = UNWRAP(this.#compiler);
109 const renderedBlocks = extractRenderedBlocks(
110 processor.runSync(parsedTree) as Parent,
111 newBlocks,
112 preservedLength,
113 );
114
150115 let reactNodes: ReactNode[] | null = null;
151 // If items are removed, the array must be resliced.
152116 if (this.#reactNodes.length !== blockPositions.length) {
117 // If items are removed, the array must be resliced.
153118 reactNodes = this.#reactNodes.slice(0, blockPositions.length);
154119 blockSources.length = blockPositions.length;
155120 }
156121 for (let i = blockIndexToReplace, len = blockPositions.length; i < len; i += 1) {
122 // The decision on if to recreate the React element is derived simply from if the
123 // souce code changes. TODO: perform a light AST-diff? use a mix of approaches?
157124 const source = content
158125 .slice(UNWRAP(blockPositions[i]), blockPositions[i + 1] ?? content.length)
159126 .replace(/\s+$/, "");
......@@ -164,8 +131,11 @@ export class Memoizer {
164131 }
165132 blockSources[i] = source;
166133
167 // Render the block using all plugins.
168 let { result } = processor.processSync(source);
134 // Render the block using the transformed subtree.
135 let result = compiler.stringify({
136 type: "root",
137 children: renderedBlocks[i - blockIndexToReplace] ?? [],
138 } as Parent);
169139
170140 // Do a little trolling and unwrap the fragment
171141 const key = String("m" + i);
......@@ -186,3 +156,40 @@ export class Memoizer {
186156 return this.#reactNodes;
187157 }
188158}
159
160function componentsAreEqual(left: Partial<Components>, right: Partial<Components>) {
161 const leftRecord = left as Record<string, unknown>;
162 const rightRecord = right as Record<string, unknown>;
163 const leftKeys = Object.keys(leftRecord);
164 if (leftKeys.length !== Object.keys(rightRecord).length) return false;
165 for (const key of leftKeys) {
166 if (leftRecord[key] !== rightRecord[key]) return false;
167 }
168 return true;
169}
170
171function setReactKey(element: JSX.Element, key: Key) {
172 const { ...props } = element.props;
173 return jsx(element.type, props, key);
174}
175
176function extractRenderedBlocks(tree: Parent, blockPositions: number[], offset: number) {
177 const blocks = blockPositions.map(() => [] as Node[]);
178
179 let blockIndex = 0;
180 for (const child of tree.children) {
181 const startOffset = child.position?.start.offset;
182 if (startOffset !== undefined) {
183 for (; blockIndex + 1 < blockPositions.length; blockIndex += 1) {
184 if (startOffset + offset < blockPositions[blockIndex + 1]!) break;
185 }
186 blocks[blockIndex]!.push(child);
187 continue;
188 }
189
190 if (child.type === "text" && /^\s*$/.test(String((child as Literal).value))) continue;
191 blocks[blockIndex]!.push(child);
192 }
193
194 return blocks;
195}
tests/index.test.ts+34
......@@ -1,9 +1,13 @@
11import { act, createElement } from "react";
22import { createRoot } from "react-dom/client";
33import { expect, test } from "vite-plus/test";
4import remarkGfm from "remark-gfm";
5import remarkParse from "remark-parse";
6import { unified } from "unified";
47import { Markdown } from "../src/mod.ts";
58
69Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10const gfmProcessor = unified().use(remarkParse).use(remarkGfm);
711
812function getParagraphs(container: HTMLElement) {
913 return Array.from(container.querySelectorAll("p"));
......@@ -74,3 +78,33 @@ test("Markdown reuses rendered nodes across rerenders and preserves them when a
7478 container.remove();
7579 }
7680});
81
82test("Markdown can render reference definitions and footnotes when they are in the same changed suffix", async () => {
83 const container = document.createElement("div");
84 document.body.append(container);
85
86 const root = createRoot(container);
87 const content = "[ref][a] and note[^1]\n\n[a]: https://example.com\n[^1]: hi there";
88
89 try {
90 await act(async () => {
91 root.render(createElement(Markdown, { content, processor: gfmProcessor }));
92 });
93
94 const link = container.querySelector("a[href='https://example.com']");
95 const footnoteRef = container.querySelector("[data-footnote-ref]");
96 const footnotes = container.querySelector("section[data-footnotes]");
97 const bodyParagraph = container.querySelector("p");
98
99 expect(bodyParagraph?.textContent).toBe("ref and note1");
100 expect(link?.textContent).toBe("ref");
101 expect(footnoteRef?.textContent).toBe("1");
102 expect(footnotes?.textContent).toContain("hi there");
103 } finally {
104 await act(async () => {
105 root.unmount();
106 });
107
108 container.remove();
109 }
110});