1import type { Element, ElementContent, Properties, Root, RootContent, Text } from "hast";
2import type { Components } from "rehype-react";
3import type { ReactElement, ReactNode } from "react";
4import projectReadme from "../readme.md?raw";
5import remarkParse from "remark-parse";
6import remarkRehype from "remark-rehype";
7import remarkGfm from "remark-gfm";
8import { unified } from "unified";
9import type { Position } from "unist";
10import { expect, it } from "vitest";
11import { memoizedHastToReact, type RenderState } from "../src/hast.ts";
12
13const markdownProcessor = unified().use(remarkParse).use(remarkRehype);
14const gfmMarkdownProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype);
15type TestElementProps = Record<string, unknown> & {
16 children?: ReactNode;
17 className?: string;
18 href?: string;
19 style?: Record<string, unknown>;
20};
21
22it("bails out before rereading children when given the same parent node object", () => {
23 const children = [element("p", [text("hello")])];
24 let childReads = 0;
25 const tree = { type: "root" } as Root;
26
27 Object.defineProperty(tree, "children", {
28 enumerable: true,
29 get() {
30 childReads += 1;
31 return children;
32 },
33 });
34
35 const first = renderTree(tree);
36 expect(childReads).toBeGreaterThan(0);
37
38 childReads = 0;
39 const second = renderTree(tree, first.state);
40
41 expect(childReads).toBe(0);
42 expect(second.react).toBe(first.react);
43 expect(second.state).toBe(first.state);
44});
45
46it("reuses the whole rendered tree when the AST is structurally equal", () => {
47 const firstTree = root(element("p", [text("hello")], {}, position(1)));
48 const first = renderTree(firstTree);
49
50 const secondTree = root(element("p", [text("hello", position(20))], {}, position(10)));
51 const second = renderTree(secondTree, first.state);
52
53 expect(second.react).toBe(first.react);
54 expect(second.state).toBe(first.state);
55});
56
57it("reuses an unchanged nested element when adjacent text changes", () => {
58 const first = renderTree(
59 root(element("p", [text("alpha "), element("strong", [text("stable")]), text(" omega")])),
60 );
61
62 const firstParagraph = childAt(first.react, 0);
63 const firstStrong = childAt(firstParagraph, 1);
64
65 const second = renderTree(
66 root(element("p", [text("alpha! "), element("strong", [text("stable")]), text(" omega")])),
67 first.state,
68 );
69
70 const secondParagraph = childAt(second.react, 0);
71 const secondStrong = childAt(secondParagraph, 1);
72
73 expect(second.react).not.toBe(first.react);
74 expect(secondParagraph).not.toBe(firstParagraph);
75 expect(secondStrong).toBe(firstStrong);
76});
77
78it("reuses an unchanged sibling branch when another block changes", () => {
79 const first = renderTree(
80 root(element("p", [text("before")]), element("p", [element("em", [text("stable")])])),
81 );
82
83 const firstStableBranch = childAt(first.react, 1);
84 const firstEmphasis = childAt(firstStableBranch, 0);
85
86 const second = renderTree(
87 root(element("p", [text("after")]), element("p", [element("em", [text("stable")])])),
88 first.state,
89 );
90
91 const secondStableBranch = childAt(second.react, 1);
92 const secondEmphasis = childAt(secondStableBranch, 0);
93
94 expect(secondStableBranch).toBe(firstStableBranch);
95 expect(secondEmphasis).toBe(firstEmphasis);
96});
97
98it("rebuilds a parent when its own props change but preserves equal descendants", () => {
99 const first = renderTree(
100 root(element("a", [element("strong", [text("child")])], { href: "https://one.example" })),
101 );
102
103 const firstLink = childAt(first.react, 0);
104 const firstStrong = childAt(firstLink, 0);
105
106 const second = renderTree(
107 root(element("a", [element("strong", [text("child")])], { href: "https://two.example" })),
108 first.state,
109 );
110
111 const secondLink = childAt(second.react, 0);
112 const secondStrong = childAt(secondLink, 0);
113
114 expect(secondLink).not.toBe(firstLink);
115 expect(secondStrong).toBe(firstStrong);
116 expect(asElement(secondLink).props.href).toBe("https://two.example");
117});
118
119it("parses markdown links with href in properties, not data, and rebuilds when href changes", () => {
120 const firstTree = parseMarkdown("[a](b)");
121 const firstParagraphNode = getElement(firstTree.children[0]);
122 const firstLinkNode = getElement(firstParagraphNode.children[0]);
123
124 expect(firstLinkNode.properties).toMatchObject({ href: "b" });
125 expect(firstLinkNode.data).toBeUndefined();
126
127 const first = renderTree(firstTree);
128 const firstParagraph = childAt(first.react, 0);
129 const firstLink = childAt(firstParagraph, 0);
130
131 const secondTree = parseMarkdown("[a](c)");
132 const second = renderTree(secondTree, first.state);
133 const secondParagraph = childAt(second.react, 0);
134 const secondLink = childAt(secondParagraph, 0);
135
136 expect(asElement(firstLink).props.href).toBe("b");
137 expect(asElement(secondLink).props.href).toBe("c");
138 expect(secondLink).not.toBe(firstLink);
139});
140
141it("reuses deep descendants through rebuilt ancestors", () => {
142 const first = renderTree(
143 root(
144 element("div", [text("prefix"), element("section", [element("strong", [text("stable")])])]),
145 ),
146 );
147
148 const firstDiv = childAt(first.react, 0);
149 const firstSection = childAt(firstDiv, 1);
150 const firstStrong = childAt(firstSection, 0);
151
152 const second = renderTree(
153 root(
154 element("div", [text("prefix!"), element("section", [element("strong", [text("stable")])])]),
155 ),
156 first.state,
157 );
158
159 const secondDiv = childAt(second.react, 0);
160 const secondSection = childAt(secondDiv, 1);
161 const secondStrong = childAt(secondSection, 0);
162
163 expect(secondDiv).not.toBe(firstDiv);
164 expect(secondSection).toBe(firstSection);
165 expect(secondStrong).toBe(firstStrong);
166});
167
168it("reuses an equal subtree when a sibling of another tag is inserted above it", () => {
169 const first = renderTree(
170 root(element("span", [text("first")]), element("span", [text("stable")])),
171 );
172
173 const firstStable = childAt(first.react, 1);
174 expect(asElement(firstStable).key).toBe("span-1");
175
176 const second = renderTree(
177 root(element("em", [text("first")]), element("span", [text("stable")])),
178 first.state,
179 );
180
181 const secondStable = childAt(second.react, 1);
182
183 expect(asElement(secondStable).key).toBe("span-1");
184 expect(secondStable).toBe(firstStable);
185});
186
187it("preserves an unchanged list item when a sibling item is inserted above it", () => {
188 const first = renderTree(
189 root(element("ul", [element("li", [text("stable")]), element("li", [text("tail")])])),
190 );
191 const firstList = childAt(first.react, 0);
192 const firstStable = childAt(firstList, 0);
193
194 const second = renderTree(
195 root(
196 element("ul", [
197 element("li", [text("inserted")]),
198 element("li", [text("stable")]),
199 element("li", [text("tail")]),
200 ]),
201 ),
202 first.state,
203 );
204 const secondList = childAt(second.react, 0);
205 const secondStable = childAt(secondList, 1);
206
207 expect(secondStable).toBe(firstStable);
208});
209
210it("uses configured components for matching tags", () => {
211 function Link(props: { href?: string; children?: ReactNode }) {
212 return <span data-href={props.href}>{props.children}</span>;
213 }
214
215 const rendered = renderTree(
216 root(element("a", [text("hello")], { href: "https://example.com" })),
217 null,
218 { a: Link },
219 );
220
221 const link = childAt(rendered.react, 0);
222
223 expect(asElement(link).type).toBe(Link);
224 expect(asElement(link).props.href).toBe("https://example.com");
225 expect(childrenOf(link)).toEqual(["hello"]);
226});
227
228it("reuses the readme list block when an earlier block changes", () => {
229 const original = projectReadme;
230 const edited = projectReadme.replace(
231 "This package exports a React component to render Markdown using the [unified]",
232 "This package exports a React component to render memoized Markdown using the [unified]",
233 );
234
235 const originalTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(original));
236 const editedTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(edited));
237 const originalList = getElement(
238 originalTree.children.find(
239 (child: RootContent) => child.type === "element" && child.tagName === "ul",
240 ),
241 );
242 const editedList = getElement(
243 editedTree.children.find(
244 (child: RootContent) => child.type === "element" && child.tagName === "ul",
245 ),
246 );
247
248 const first = renderTree(root(originalList));
249 const second = renderTree(root(editedList), first.state);
250
251 expect(second.react).toBe(first.react);
252 expect(second.state).toBe(first.state);
253});
254
255it("reuses the readme list block with a custom strong component when an earlier block changes", () => {
256 function Strong(props: { children?: ReactNode }) {
257 return <strong data-testid="memo-strong">{props.children}</strong>;
258 }
259
260 const original = projectReadme;
261 const edited = projectReadme.replace(
262 "This package exports a React component to render Markdown using the [unified]",
263 "This package exports a React component to render memoized Markdown using the [unified]",
264 );
265
266 const originalTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(original));
267 const editedTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(edited));
268 const originalList = getElement(
269 originalTree.children.find(
270 (child: RootContent) => child.type === "element" && child.tagName === "ul",
271 ),
272 );
273 const editedList = getElement(
274 editedTree.children.find(
275 (child: RootContent) => child.type === "element" && child.tagName === "ul",
276 ),
277 );
278
279 const first = renderTree(root(originalList), null, { strong: Strong });
280 const second = renderTree(root(editedList), first.state, { strong: Strong });
281
282 expect(second.react).toBe(first.react);
283 expect(second.state).toBe(first.state);
284});
285
286it("reuses equal custom-component subtrees when siblings change", () => {
287 function Link(_props: { href?: string; children?: ReactNode }) {
288 return null;
289 }
290
291 const components = { a: Link } satisfies Partial<Components>;
292 const first = renderTree(
293 root(
294 element("p", [text("before")]),
295 element("a", [text("stable")], { href: "https://example.com" }),
296 ),
297 null,
298 components,
299 );
300
301 const firstLink = childAt(first.react, 1);
302 const second = renderTree(
303 root(
304 element("p", [text("after")]),
305 element("a", [text("stable")], { href: "https://example.com" }),
306 ),
307 first.state,
308 components,
309 );
310
311 const secondLink = childAt(second.react, 1);
312
313 expect(secondLink).toBe(firstLink);
314 expect(asElement(secondLink).type).toBe(Link);
315});
316
317it("filters whitespace-only text children in table elements like the upstream renderer", () => {
318 const rendered = renderTree(
319 root(
320 element("table", [
321 text("\n"),
322 element("tr", [text("\n"), element("td", [text("cell")]), text("\n")]),
323 text("\n"),
324 ]),
325 ),
326 );
327
328 const table = childAt(rendered.react, 0);
329 const row = childAt(table, 0);
330
331 expect(childrenOf(table)).toHaveLength(1);
332 expect(childrenOf(row)).toHaveLength(1);
333 expect(asElement(childAt(row, 0)).type).toBe("td");
334});
335
336it("uses upstream prop conversion when building JSX shells", () => {
337 const rendered = renderTree(
338 root(
339 element("div", [], {
340 className: ["alpha", "beta"],
341 style: "text-align:center;color:red",
342 }),
343 ),
344 );
345
346 const div = childAt(rendered.react, 0);
347
348 expect(asElement(div).props.className).toBe("alpha beta");
349 expect(asElement(div).props.style).toEqual({
350 color: "red",
351 textAlign: "center",
352 });
353});
354
355it("keeps converted shell props stable across child-only updates", () => {
356 const first = renderTree(
357 root(
358 element("div", [text("before")], {
359 className: ["alpha", "beta"],
360 style: "text-align:center;color:red",
361 }),
362 ),
363 );
364
365 const firstDiv = childAt(first.react, 0);
366
367 const second = renderTree(
368 root(
369 element("div", [text("after")], {
370 className: ["alpha", "beta"],
371 style: "text-align:center;color:red",
372 }),
373 ),
374 first.state,
375 );
376
377 const secondDiv = childAt(second.react, 0);
378
379 expect(secondDiv).not.toBe(firstDiv);
380 expect(asElement(secondDiv).props.className).toBe("alpha beta");
381 expect(asElement(secondDiv).props.style).toEqual({
382 color: "red",
383 textAlign: "center",
384 });
385});
386
387function renderTree(
388 tree: Root,
389 state: RenderState | null = null,
390 components: Partial<Components> = {},
391) {
392 return memoizedHastToReact(tree, state, components);
393}
394
395function root(...children: RootContent[]): Root {
396 return { children, type: "root" };
397}
398
399function element(
400 tagName: string,
401 children: ElementContent[] = [],
402 properties: Properties = {},
403 pos?: Position,
404): Element {
405 return {
406 children,
407 position: pos,
408 properties,
409 tagName,
410 type: "element",
411 };
412}
413
414function text(value: string, pos?: Position): Text {
415 return {
416 position: pos,
417 type: "text",
418 value,
419 };
420}
421
422function position(line: number): Position {
423 return {
424 end: { column: 2, line, offset: line * 10 + 1 },
425 start: { column: 1, line, offset: line * 10 },
426 };
427}
428
429function parseMarkdown(markdown: string) {
430 return markdownProcessor.runSync(markdownProcessor.parse(markdown)) as Root;
431}
432
433function getElement(node: RootContent | ElementContent | undefined) {
434 if (!node) throw new TypeError("Expected element, got `undefined`");
435 if (node.type !== "element") throw new TypeError(`Expected element, got \`${node.type}\``);
436 return node;
437}
438
439function childAt(node: ReactNode, index: number) {
440 const child = childrenOf(node)[index];
441 if (child === undefined) throw new RangeError(`Missing child at index ${index}`);
442 return child;
443}
444
445function childrenOf(node: ReactNode) {
446 const children = asElement(node).props.children;
447 if (children === undefined) return [];
448 return Array.isArray(children) ? children : [children];
449}
450
451function asElement(node: ReactNode): ReactElement<TestElementProps> {
452 if (!node || typeof node !== "object" || !("props" in node)) {
453 throw new TypeError("Expected a React element");
454 }
455 return node as ReactElement<TestElementProps>;
456}