1import type { ReactNode } from "react";
2import { Fragment } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import projectReadme from "../readme.md?raw";
5import remarkGfm from "remark-gfm";
6import remarkParse from "remark-parse";
7import { unified } from "unified";
8import type { Literal, Node, Parent } from "unist";
9import { describe, expect, it } from "vitest";
10import {
11 componentsAreEqual,
12 defaultProcessor,
13 extractBlocks,
14 indexOfDiff,
15 Memoizer,
16 nodeAffectsDocument,
17} from "../src/Memoizer.ts";
18
19const gfmProcessor = unified().use(remarkParse).use(remarkGfm);
20const documentStateProcessor = unified()
21 .use(remarkParse)
22 .use(function documentStatePlugin() {
23 return (tree: Parent) => {
24 const paragraphs = tree.children.filter(
25 (node): node is Parent =>
26 node.type === "paragraph" && "children" in node && Array.isArray(node.children),
27 );
28 const suffix = paragraphs
29 .map((paragraph) => paragraphText(paragraph))
30 .findLast((text) => text.startsWith("suffix: "))
31 ?.slice("suffix: ".length);
32 if (!suffix) return tree;
33
34 const target = paragraphs.find(
35 (paragraph) => !paragraphText(paragraph).startsWith("suffix: "),
36 );
37 const firstChild = target?.children[0];
38 if (firstChild?.type === "text") {
39 const textNode = firstChild as Literal;
40 const value = String(textNode.value);
41 textNode.value = `${value} (${suffix})`;
42 }
43 return tree;
44 };
45 });
46
47describe("Memoizer utilities", () => {
48 it("finds the first differing index for edits in the middle, start, and end", () => {
49 expect(indexOfDiff("alpha", "alpha!")).toBe(5);
50 expect(indexOfDiff("!alpha", "alpha")).toBe(0);
51 expect(indexOfDiff("alXha", "alpha")).toBe(2);
52 });
53
54 it("treats semantically equal component maps as equal", () => {
55 const Link = () => null;
56 const Paragraph = () => null;
57
58 expect(componentsAreEqual({ a: Link, p: Paragraph }, { p: Paragraph, a: Link })).toBe(true);
59 expect(componentsAreEqual({ a: Link }, { a: Link, p: Paragraph })).toBe(false);
60 expect(componentsAreEqual({ a: Link }, { a: Paragraph })).toBe(false);
61 });
62
63 it("detects document-wide nodes even when nested", () => {
64 expect(
65 nodeAffectsDocument({
66 children: [
67 {
68 children: [
69 {
70 children: [],
71 type: "paragraph",
72 } as Parent,
73 {
74 identifier: "ref",
75 label: "ref",
76 title: null,
77 type: "definition",
78 url: "https://example.com",
79 } as Node,
80 ],
81 type: "container",
82 } as Parent,
83 ],
84 type: "root",
85 } as Parent),
86 ).toBe(true);
87
88 expect(
89 nodeAffectsDocument({
90 children: [
91 {
92 children: [],
93 type: "paragraph",
94 } as Parent,
95 ],
96 type: "root",
97 } as Parent),
98 ).toBe(false);
99 });
100
101 it("groups transformed nodes into block buckets and ignores pure whitespace text", () => {
102 const blocks = extractBlocks(
103 {
104 children: [
105 {
106 children: [],
107 position: {
108 end: { column: 6, line: 1, offset: 5 },
109 start: { column: 1, line: 1, offset: 0 },
110 },
111 type: "paragraph",
112 } as Node,
113 {
114 type: "text",
115 value: "\n\n",
116 } as Literal,
117 {
118 children: [],
119 position: {
120 end: { column: 5, line: 3, offset: 14 },
121 start: { column: 1, line: 3, offset: 10 },
122 },
123 type: "paragraph",
124 } as Node,
125 ],
126 type: "root",
127 } as Parent,
128 [0, 10],
129 0,
130 );
131
132 expect(blocks).toHaveLength(2);
133 expect(blocks[0]).toHaveLength(1);
134 expect(blocks[1]).toHaveLength(1);
135 });
136});
137
138describe("Memoizer incremental rendering", () => {
139 it("returns the same array reference when content does not change", () => {
140 const memoizer = createMemoizer();
141
142 const first = memoizer.update("hello");
143 const second = memoizer.update("hello");
144
145 expect(second).toBe(first);
146 });
147
148 it("reuses unchanged block react nodes when editing a different block", () => {
149 const memoizer = createMemoizer();
150 const first = memoizer.update("alpha\n\nbeta");
151 const second = memoizer.update("alpha!\n\nbeta");
152 const third = memoizer.update("alpha!\n\nbeta!");
153
154 expect(second[1]).toBe(first[1]);
155 expect(third[0]).toBe(second[0]);
156 expect(third[1]).not.toBe(second[1]);
157 });
158
159 it("reprocesses reference-link consumers when a later definition changes", () => {
160 const memoizer = createMemoizer();
161
162 const before = renderNodes(memoizer.update("[ref][id]\n\n[id]: /one"));
163 const after = renderNodes(memoizer.update("[ref][id]\n\n[id]: /two"));
164
165 expect(before).toContain('href="/one"');
166 expect(after).toContain('href="/two"');
167 });
168
169 it("keeps earlier gfm footnotes rendered when a later footnote definition is removed", () => {
170 const incremental = createMemoizer(gfmProcessor);
171
172 void incremental.update("a[^b]\n\n[^b]: bee\n\nc");
173 void incremental.update("a[^b]\n\n[^b]: bee\n\nc[^d]\n\n[^d]: dee");
174 const after = renderNodes(incremental.update("a[^b]\n\n[^b]: bee\n\nc[^d]"));
175
176 const fresh = createMemoizer(gfmProcessor);
177 const expected = renderNodes(fresh.update("a[^b]\n\n[^b]: bee\n\nc[^d]"));
178
179 expect(after).toBe(expected);
180 expect(after).toContain('id="user-content-fn-b"');
181 });
182
183 it.each([
184 {
185 content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"],
186 name: "paragraphs merging and splitting",
187 predict: false,
188 processor: defaultProcessor,
189 },
190 {
191 content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]\n\n[id]: /three"],
192 name: "reference link definitions",
193 predict: false,
194 processor: defaultProcessor,
195 },
196 {
197 content: [
198 "| A | B |\n| --- | --- |\n| one | two |",
199 "| A | B |\n| --- | --- |\n| one! | two |",
200 "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |",
201 ],
202 name: "gfm tables",
203 predict: false,
204 processor: gfmProcessor,
205 },
206 {
207 content: ["- [ ] open\n- [x] done", "- [x] open\n- [x] done", "- [x] open\n- [ ] done"],
208 name: "gfm task lists",
209 predict: false,
210 processor: gfmProcessor,
211 },
212 {
213 content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"],
214 name: "prediction across mid-document edits",
215 predict: true,
216 processor: defaultProcessor,
217 },
218 ])("matches a fresh memoizer for $name", ({ content, predict, processor }) => {
219 const incremental = createMemoizer(processor, predict);
220
221 for (const step of content) {
222 const fresh = createMemoizer(processor, predict);
223
224 expect(renderNodes(incremental.update(step))).toBe(renderNodes(fresh.update(step)));
225 }
226 });
227
228 it("keeps a stable block tied to its own react node when blocks are inserted or removed above it", () => {
229 const memoizer = createMemoizer();
230 const first = memoizer.update("before\n\n[stable](https://example.com)");
231 const second = memoizer.update("intro\n\nbefore\n\n[stable](https://example.com)");
232 const third = memoizer.update("intro\n\n[stable](https://example.com)");
233
234 expect(second[2]).toBe(first[1]);
235 expect(third[1]).toBe(second[2]);
236 });
237
238 it("reuses the README list block react node when editing an earlier paragraph", () => {
239 const memoizer = createMemoizer(gfmProcessor);
240 const original = projectReadme;
241 const edited = projectReadme.replace(
242 "This package exports a React component to render Markdown using the [unified]",
243 "This package exports a React component to render memoized Markdown using the [unified]",
244 );
245
246 const first = memoizer.update(original);
247 const second = memoizer.update(edited);
248
249 expect(second[2]).toBe(first[2]);
250 });
251
252 // TODO: this is a tough problem to cleanly solve without giving up too much memoization.
253 it.fails("re-renders earlier blocks when a later custom-plugin block changes document-wide state", () => {
254 const incremental = createMemoizer(documentStateProcessor);
255 void incremental.update("hello\n\nsuffix: one");
256
257 const after = renderNodes(incremental.update("hello\n\nsuffix: two"));
258
259 const fresh = createMemoizer(documentStateProcessor);
260 const expected = renderNodes(fresh.update("hello\n\nsuffix: two"));
261
262 expect(after).toBe(expected);
263 expect(expected).toContain("hello (two)");
264 });
265});
266
267function createMemoizer(processor = defaultProcessor, predict = false) {
268 const memoizer = new Memoizer();
269 memoizer.reconfigure(processor, predict, {});
270 return memoizer;
271}
272
273function renderNodes(nodes: readonly ReactNode[]) {
274 return renderToStaticMarkup(<Fragment>{nodes}</Fragment>);
275}
276
277function paragraphText(node: Parent) {
278 return node.children
279 .filter((child): child is Literal => child.type === "text")
280 .map((child) => String(child.value))
281 .join("");
282}