From 5f937e9fbcf25ac5a81c8e4f9fa3b628197c9325 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 19 Mar 2026 03:04:11 -0700 Subject: [PATCH] fix: re-use parsing pass --- README.md | 5 +- package.json | 22 +++---- pnpm-lock.yaml | 77 +++++++---------------- src/Memoizer.ts | 145 +++++++++++++++++++++++--------------------- tests/index.test.ts | 34 +++++++++++ 5 files changed, 144 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index ad9645a578faf85284f331a3003f0aba80746a3b..1386c75860fadff1e7ccc91127df29073843d7fe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # `@clo/react-markdown` -This package exports a React component to render markdown using the [unified] -ecosystem's markdown tools ([remark], [rehype]). The exported `` +This package exports a React component to render Markdown using the [unified] +ecosystem's Markdown tools (remark, rehype). The exported `` component is extremely memoized, making it suitable for streaming situations such as LLM chat interfaces. @@ -32,7 +32,6 @@ with their library: [Streamdown]: https://streamdown.ai [unified]: https://unifiedjs.com/ -[remark]: https://remark.js.org/ ## Getting Started diff --git a/package.json b/package.json index 4adcb23979aac04ba242155e89b6feeaa3f9f007..a15095e80a2537471aa61c127bd0ed753e68d343 100644 --- a/package.json +++ b/package.json @@ -18,16 +18,7 @@ "prepublishOnly": "vp run build" }, "dependencies": { - "@clo/lib": "jsr:^3.0.0", - "@types/unist": "^3.0.3", - "mdast-util-from-markdown": "^2.0.3", - "rehype-react": "^8.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "remark-stringify": "^11.0.0", - "unified": "^11.0.5" + "@clo/lib": "jsr:^3.0.0" }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", @@ -35,11 +26,16 @@ "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/unist": "^3.0.3", "@typescript/native-preview": "7.0.0-dev.20260318.1", "@vitejs/plugin-react": "^6.0.1", "react": "^19.2.4", "react-dom": "^19.2.4", "react-scan": "^0.5.3", + "rehype-react": "^8.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "streamdown": "^2.5.0", "tailwindcss": "^4.2.2", "typescript": "^5.9.3", @@ -47,7 +43,11 @@ "vite-plus": "^0.1.11" }, "peerDependencies": { - "react": "*" + "react": "*", + "rehype-react": "^8.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0" }, "packageManager": "pnpm@10.26.1", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32e6388e64ba0fcc4f9ad95e847fff7572d61a36..e48967cfe7245802a98eea4d00cc304b0b7c747e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,36 +15,6 @@ importers: '@clo/lib': specifier: jsr:^3.0.0 version: '@jsr/clo__lib@3.0.0' - '@types/unist': - specifier: ^3.0.3 - version: 3.0.3 - mdast-util-from-markdown: - specifier: ^2.0.3 - version: 2.0.3 - rehype-react: - specifier: ^8.0.0 - version: 8.0.0 - rehype-stringify: - specifier: ^10.0.1 - version: 10.0.1 - remark-gfm: - specifier: ^4.0.1 - version: 4.0.1 - remark-parse: - specifier: ^11.0.0 - version: 11.0.0 - remark-rehype: - specifier: ^11.1.2 - version: 11.1.2 - remark-stringify: - specifier: ^11.0.0 - version: 11.0.0 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - unified: - specifier: ^11.0.5 - version: 11.0.5 devDependencies: '@tailwindcss/typography': specifier: ^0.5.19 @@ -61,6 +31,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/unist': + specifier: ^3.0.3 + version: 3.0.3 '@typescript/native-preview': specifier: 7.0.0-dev.20260318.1 version: 7.0.0-dev.20260318.1 @@ -76,12 +49,30 @@ importers: react-scan: specifier: ^0.5.3 version: 0.5.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + rehype-react: + specifier: ^8.0.0 + version: 8.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + remark-rehype: + specifier: ^11.1.2 + version: 11.1.2 streamdown: specifier: ^2.5.0 version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwindcss: specifier: ^4.2.2 version: 4.2.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + unified: + specifier: ^11.0.5 + version: 11.0.5 vite-plus: specifier: ^0.1.11 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: hast-util-sanitize@5.0.2: resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} - hast-util-to-html@9.0.5: - resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -2005,9 +1993,6 @@ packages: rehype-sanitize@6.0.0: resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} - rehype-stringify@10.0.1: - resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} - remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3546,20 +3531,6 @@ snapshots: '@ungap/structured-clone': 1.3.0 unist-util-position: 5.0.0 - hast-util-to-html@9.0.5: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.4 - zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -4286,12 +4257,6 @@ snapshots: '@types/hast': 3.0.4 hast-util-sanitize: 5.0.2 - rehype-stringify@10.0.1: - dependencies: - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - unified: 11.0.5 - remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 diff --git a/src/Memoizer.ts b/src/Memoizer.ts index 28c64701f2b572033a1989e06dfaa297f61f6cb3..3ceef7ee3e460b87883b1a2b219a8bc86ed21a1f 100644 --- a/src/Memoizer.ts +++ b/src/Memoizer.ts @@ -1,40 +1,14 @@ import { ASSERT, UNWRAP } from "@clo/lib/assert.ts"; -import { fromMarkdown } from "mdast-util-from-markdown"; import { indexOfDiff } from "./strings.ts"; import { memo, type JSX, type Key, type ReactNode } from "react"; import rehypeReact, { type Components } from "rehype-react"; import remarkRehype from "remark-rehype"; import { jsx, jsxs, Fragment } from "react/jsx-runtime"; import { unified, type Processor } from "unified"; -import type { Node } from "unist"; +import type { Node, Parent, Literal } from "unist"; import remarkParse from "remark-parse"; export const defaultProcessor = unified().use(remarkParse); -const extractBlockOptions = { - extensions: [ - { - disable: { - null: [ - "attention", - "autolink", - "characterEscape", - "characterReference", - "codeText", - "hardBreakEscape", - "htmlText", - "labelStartImage", - "labelStartLink", - "labelEnd", - ], - }, - }, - ], -}; - -function extractBlockPositions(source: string, offset: number) { - const tree = fromMarkdown(source, extractBlockOptions); - return tree.children.map((child) => UNWRAP(UNWRAP(child.position).start.offset) + offset); -} export type BaseProcessor = Processor< Node, @@ -43,27 +17,13 @@ export type BaseProcessor = Processor< undefined, undefined | JSX.Element >; -export type ReactProcessor = Processor; - -function componentsAreEqual(left: Partial, right: Partial) { - const leftRecord = left as Record; - const rightRecord = right as Record; - const leftKeys = Object.keys(leftRecord); - if (leftKeys.length !== Object.keys(rightRecord).length) return false; - for (const key of leftKeys) { - if (leftRecord[key] !== rightRecord[key]) return false; - } - return true; -} - -function setReactKey(element: JSX.Element, key: Key) { - const { ...props } = element.props; - return jsx(element.type, props, key); -} +type AstProcessor = Processor; +type ReactCompiler = Processor; export class Memoizer { #baseProcessor: BaseProcessor = defaultProcessor; - #processor: ReactProcessor | null = null; + #astProcessor: AstProcessor | null = null; + #compiler: ReactCompiler | null = null; #previousComponents: Partial = {}; #previous: string = ""; #blockPositions: number[] = []; @@ -74,6 +34,7 @@ export class Memoizer { reset() { this.#previous = ""; this.#blockPositions = []; + this.#blockSources = []; this.#reactNodes = []; } @@ -82,19 +43,22 @@ export class Memoizer { components: Partial, ) { if ( - this.#processor !== null && + this.#astProcessor !== null && this.#baseProcessor === processor && componentsAreEqual(this.#previousComponents, components) ) return; this.#baseProcessor = processor; - if (!processor.attachers.some((plugin) => plugin[0] === rehypeReact)) { + if (!processor.attachers.some((plugin) => plugin[0] === remarkRehype)) { processor = processor(); - if (!processor.attachers.some((plugin) => plugin[0] === remarkRehype)) { - processor.use(remarkRehype); - } - processor.use(rehypeReact, { + processor.use(remarkRehype); + processor.freeze(); + } + + this.#astProcessor = processor as unknown as AstProcessor; + this.#compiler = unified() + .use(rehypeReact, { jsx, jsxs, Fragment, @@ -104,13 +68,9 @@ export class Memoizer { components: Object.fromEntries( Object.entries(components).map(([k, v]) => [k, typeof v === "function" ? memo(v) : v]), ), - }); - processor.freeze(); - } else { - // TODO: troll and force memoize all components - } + }) + .freeze() as ReactCompiler; this.#previousComponents = components; - this.#processor = processor as ReactProcessor; this.reset(); } @@ -130,12 +90,10 @@ export class Memoizer { if (blockIndexToReplace === -1) blockIndexToReplace = 0; // Compute and replace changed block regions. This behavior relys on the - // fact that this block extraction process can be isolated across blocks. - // For the basic set of syntax extensions, this is true, for example an - // unclosed code fence is parsed as a code fence. Anything that isn't - // compatible will break very loudly since `Memoizer` never re-joins the - // blocks for rendering. - const newBlocks = extractBlockPositions(content.slice(preservedLength), preservedLength); + // fact that blocks generally do not affect each other. + const processor = UNWRAP(this.#astProcessor); + const parsedTree = processor.parse(content.slice(preservedLength)); + const newBlocks = parsedTree.children.map((child) => UNWRAP(UNWRAP(child.position).start.offset) + preservedLength); blockPositions.splice( blockIndexToReplace, blockPositions.length - blockIndexToReplace, @@ -143,17 +101,26 @@ export class Memoizer { ); this.#previous = content; - // For all touched blocks (newBlocks), find which ones have actually changed - // their contents and run them through the full pipeline. - const processor = UNWRAP(this.#processor); + // For all touched blocks (newBlocks), transform their ASTs. This is done in + // a group to preserve shared contexts, such as reference links. + // TODO: need more effort for reference links without reparsing too much code. const blockSources = this.#blockSources; + const compiler = UNWRAP(this.#compiler); + const renderedBlocks = extractRenderedBlocks( + processor.runSync(parsedTree) as Parent, + newBlocks, + preservedLength, + ); + let reactNodes: ReactNode[] | null = null; - // If items are removed, the array must be resliced. if (this.#reactNodes.length !== blockPositions.length) { + // If items are removed, the array must be resliced. reactNodes = this.#reactNodes.slice(0, blockPositions.length); blockSources.length = blockPositions.length; } for (let i = blockIndexToReplace, len = blockPositions.length; i < len; i += 1) { + // The decision on if to recreate the React element is derived simply from if the + // souce code changes. TODO: perform a light AST-diff? use a mix of approaches? const source = content .slice(UNWRAP(blockPositions[i]), blockPositions[i + 1] ?? content.length) .replace(/\s+$/, ""); @@ -164,8 +131,11 @@ export class Memoizer { } blockSources[i] = source; - // Render the block using all plugins. - let { result } = processor.processSync(source); + // Render the block using the transformed subtree. + let result = compiler.stringify({ + type: "root", + children: renderedBlocks[i - blockIndexToReplace] ?? [], + } as Parent); // Do a little trolling and unwrap the fragment const key = String("m" + i); @@ -186,3 +156,40 @@ export class Memoizer { return this.#reactNodes; } } + +function componentsAreEqual(left: Partial, right: Partial) { + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + if (leftKeys.length !== Object.keys(rightRecord).length) return false; + for (const key of leftKeys) { + if (leftRecord[key] !== rightRecord[key]) return false; + } + return true; +} + +function setReactKey(element: JSX.Element, key: Key) { + const { ...props } = element.props; + return jsx(element.type, props, key); +} + +function extractRenderedBlocks(tree: Parent, blockPositions: number[], offset: number) { + const blocks = blockPositions.map(() => [] as Node[]); + + let blockIndex = 0; + for (const child of tree.children) { + const startOffset = child.position?.start.offset; + if (startOffset !== undefined) { + for (; blockIndex + 1 < blockPositions.length; blockIndex += 1) { + if (startOffset + offset < blockPositions[blockIndex + 1]!) break; + } + blocks[blockIndex]!.push(child); + continue; + } + + if (child.type === "text" && /^\s*$/.test(String((child as Literal).value))) continue; + blocks[blockIndex]!.push(child); + } + + return blocks; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 502cbec01b4f9ad50e049258a6bae9474ff53ce2..48ce17c619eac81e68e866403ebceb43e218c9d0 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,9 +1,13 @@ import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; import { expect, test } from "vite-plus/test"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; import { Markdown } from "../src/mod.ts"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const gfmProcessor = unified().use(remarkParse).use(remarkGfm); function getParagraphs(container: HTMLElement) { return Array.from(container.querySelectorAll("p")); @@ -74,3 +78,33 @@ test("Markdown reuses rendered nodes across rerenders and preserves them when a container.remove(); } }); + +test("Markdown can render reference definitions and footnotes when they are in the same changed suffix", async () => { + const container = document.createElement("div"); + document.body.append(container); + + const root = createRoot(container); + const content = "[ref][a] and note[^1]\n\n[a]: https://example.com\n[^1]: hi there"; + + try { + await act(async () => { + root.render(createElement(Markdown, { content, processor: gfmProcessor })); + }); + + const link = container.querySelector("a[href='https://example.com']"); + const footnoteRef = container.querySelector("[data-footnote-ref]"); + const footnotes = container.querySelector("section[data-footnotes]"); + const bodyParagraph = container.querySelector("p"); + + expect(bodyParagraph?.textContent).toBe("ref and note1"); + expect(link?.textContent).toBe("ref"); + expect(footnoteRef?.textContent).toBe("1"); + expect(footnotes?.textContent).toContain("hi there"); + } finally { + await act(async () => { + root.unmount(); + }); + + container.remove(); + } +}); -- 2.54.0