| 1 | import type { ReactNode } from "react"; |
| 2 | import { Fragment } from "react"; |
| 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import projectReadme from "../readme.md?raw"; |
| 5 | import remarkGfm from "remark-gfm"; |
| 6 | import remarkParse from "remark-parse"; |
| 7 | import { unified } from "unified"; |
| 8 | import type { Literal, Node, Parent } from "unist"; |
| 9 | import { describe, expect, it } from "vitest"; |
| 10 | import { |
| 11 | componentsAreEqual, |
| 12 | defaultProcessor, |
| 13 | extractBlocks, |
| 14 | indexOfDiff, |
| 15 | Memoizer, |
| 16 | nodeAffectsDocument, |
| 17 | } from "../src/Memoizer.ts"; |
| 18 | |
| 19 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm); |
| 20 | const 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 | |
| 47 | describe("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 | |
| 138 | describe("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 | |
| 267 | function createMemoizer(processor = defaultProcessor, predict = false) { |
| 268 | const memoizer = new Memoizer(); |
| 269 | memoizer.reconfigure(processor, predict, {}); |
| 270 | return memoizer; |
| 271 | } |
| 272 | |
| 273 | function renderNodes(nodes: readonly ReactNode[]) { |
| 274 | return renderToStaticMarkup(<Fragment>{nodes}</Fragment>); |
| 275 | } |
| 276 | |
| 277 | function 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 | } |