authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 17:49:42-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 18:52:07-07:00
log3cea7fc85ec4c44b04362ea0c757c88625c58b5b
tree8d9b139ddd76f823aacc4fe633a744317eec6c2e
parentb7690cda634c38b30367307cc546917adbda1064
signaturebadge-check Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat: partial table recognition


11 files changed, 588 insertions(+), 459 deletions(-)

deno.json+2-2
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1{1{
2 "name": "@clo/react-markdown",2 "name": "@clo/react-markdown",
3 "version": "1.0.0",3 "version": "1.1.0",
4 "exports": {4 "exports": {
5 ".": "./src/Markdown.ts",5 ".": "./src/Markdown.ts",
6 "./Predict": "./src/Predict.ts"6 "./Predict": "./src/Predict.ts"
7 },7 },
8 "publish": {8 "publish": {
9 "include": ["README.md", "src/**/*.ts", "tests/**/*.ts", "tests/**/*.tsx"]9 "include": ["readme.md", "readme.changes.md", "src/**/*.ts", "tests/**/*.ts", "tests/**/*.tsx"]
10 }10 }
11}11}
example/markdown-demo.ts+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import remarkGfm from "remark-gfm";1import remarkGfm from "remark-gfm";
2import remarkParse from "remark-parse";2import remarkParse from "remark-parse";
3import { type PluggableList, unified } from "unified";3import { type PluggableList, unified } from "unified";
4import projectReadme from "../README.md?raw";4import projectReadme from "../readme.md?raw";
55
6export const initialMarkdown = projectReadme;6export const initialMarkdown = projectReadme;
7export const processor = unified().use(remarkParse).use(remarkGfm);7export const processor = unified().use(remarkParse).use(remarkGfm);
package.json+1
...@@ -36,6 +36,7 @@...@@ -36,6 +36,7 @@
36 "happy-dom": "^20.8.4",36 "happy-dom": "^20.8.4",
37 "oxfmt": "^0.40.0",37 "oxfmt": "^0.40.0",
38 "oxlint": "^1.55.0",38 "oxlint": "^1.55.0",
39 "oxlint-tsgolint": "^0.17.1",
39 "react-dom": "^19.2.4",40 "react-dom": "^19.2.4",
40 "react-markdown": "^10.1.0",41 "react-markdown": "^10.1.0",
41 "react-scan": "^0.5.3",42 "react-scan": "^0.5.3",
pnpm-lock.yaml+34-32
...@@ -68,7 +68,10 @@ importers:...@@ -68,7 +68,10 @@ importers:
68 version: 0.40.068 version: 0.40.0
69 oxlint:69 oxlint:
70 specifier: ^1.55.070 specifier: ^1.55.0
71 version: 1.56.0(oxlint-tsgolint@0.17.0)71 version: 1.56.0(oxlint-tsgolint@0.17.1)
72 oxlint-tsgolint:
73 specifier: ^0.17.1
74 version: 0.17.1
72 react-dom:75 react-dom:
73 specifier: ^19.2.476 specifier: ^19.2.4
74 version: 19.2.4(react@19.2.4)77 version: 19.2.4(react@19.2.4)
...@@ -504,33 +507,33 @@ packages:...@@ -504,33 +507,33 @@ packages:
504 cpu: [x64]507 cpu: [x64]
505 os: [win32]508 os: [win32]
506509
507 '@oxlint-tsgolint/darwin-arm64@0.17.0':510 '@oxlint-tsgolint/darwin-arm64@0.17.1':
508 resolution: {integrity: sha512-z3XwCDuOAKgk7bO4y5tyH8Zogwr51G56R0XGKC3tlAbrAq8DecoxAd3qhRZqWBMG2Gzl5bWU3Ghu7lrxuLPzYw==}511 resolution: {integrity: sha512-JNWNwyvSDcUQSBlQRl10XrCeNcN66TMvDw3gIDQeop5SNa1F7wFhsEx4zitYb7fGHwGh9095tsNttmuCaNXCbw==}
509 cpu: [arm64]512 cpu: [arm64]
510 os: [darwin]513 os: [darwin]
511514
512 '@oxlint-tsgolint/darwin-x64@0.17.0':515 '@oxlint-tsgolint/darwin-x64@0.17.1':
513 resolution: {integrity: sha512-TZgVXy0MtI8nt0MYiceuZhHPwHcwlIZ/YwzFTAKrgdHiTvVzFbqHVdXi5wbZfT/o1nHGw9fbGWPlb6qKZ4uZ9Q==}516 resolution: {integrity: sha512-SluNf6CW88pgGPqQUGC5GoK5qESWo2ct1PRDbza3vbf9SK2npx3igvylGQIgE9qYYOcjgnVdLOJ0+q0gItgUmQ==}
514 cpu: [x64]517 cpu: [x64]
515 os: [darwin]518 os: [darwin]
516519
517 '@oxlint-tsgolint/linux-arm64@0.17.0':520 '@oxlint-tsgolint/linux-arm64@0.17.1':
518 resolution: {integrity: sha512-IDfhFl/Y8bjidCvAP6QAxVyBsl78TmfCHlfjtEv2XtJXgYmIwzv6muO18XMp74SZ2qAyD4y2n2dUedrmghGHeA==}521 resolution: {integrity: sha512-BJxQ7/cdo2dNdGIBs2PIR6BaPA7cPfe+r1HE/uY+K7g2ygip+0LHB3GUO9GaNDZuWpsnDyjLYYowEGrVK8dokA==}
519 cpu: [arm64]522 cpu: [arm64]
520 os: [linux]523 os: [linux]
521524
522 '@oxlint-tsgolint/linux-x64@0.17.0':525 '@oxlint-tsgolint/linux-x64@0.17.1':
523 resolution: {integrity: sha512-Bgdgqx/m8EnfjmmlRLEeYy9Yhdt1GdFrMr5mTu/NyLRGkB1C9VLAikdxB7U9QambAGTAmjMbHNFDFk8Vx69Huw==}526 resolution: {integrity: sha512-s6UjmuaJbZ4zz/wJKdEw/s5mc0t41rgwxQJCSHPuzMumMK6ylrB7nydhDf8ObTtzhTIZdAS/2S/uayJmDcGbxw==}
524 cpu: [x64]527 cpu: [x64]
525 os: [linux]528 os: [linux]
526529
527 '@oxlint-tsgolint/win32-arm64@0.17.0':530 '@oxlint-tsgolint/win32-arm64@0.17.1':
528 resolution: {integrity: sha512-dO6wyKMDqFWh1vwr+zNZS7/ovlfGgl4S3P1LDy4CKjP6V6NGtdmEwWkWax8j/I8RzGZdfXKnoUfb/qhVg5bx0w==}531 resolution: {integrity: sha512-EO/Oj0ixHX+UQdu9hM7YUzibZI888MvPUo/DF8lSxFBt4JNEt8qGkwJEbCYjB/1LhUNmPHzSw2Tr9dCFVfW9nw==}
529 cpu: [arm64]532 cpu: [arm64]
530 os: [win32]533 os: [win32]
531534
532 '@oxlint-tsgolint/win32-x64@0.17.0':535 '@oxlint-tsgolint/win32-x64@0.17.1':
533 resolution: {integrity: sha512-lPGYFp3yX2nh6hLTpIuMnJbZnt3Df42VkoA/fSkMYi2a/LXdDytQGpgZOrb5j47TICARd34RauKm0P3OA4Oxbw==}536 resolution: {integrity: sha512-jhv7XktAJ1sMRSb//yDYTauFSZ06H81i2SLEBPaSUKxSKoPMK8p1ACUJlnmwZX2MgapRLEj1Ml22B6+HiM2YIA==}
534 cpu: [x64]537 cpu: [x64]
535 os: [win32]538 os: [win32]
536539
...@@ -1809,8 +1812,8 @@ packages:...@@ -1809,8 +1812,8 @@ packages:
1809 engines: {node: ^20.19.0 || >=22.12.0}1812 engines: {node: ^20.19.0 || >=22.12.0}
1810 hasBin: true1813 hasBin: true
18111814
1812 oxlint-tsgolint@0.17.0:1815 oxlint-tsgolint@0.17.1:
1813 resolution: {integrity: sha512-TdrKhDZCgEYqONFo/j+KvGan7/k3tP5Ouz88wCqpOvJtI2QmcLfGsm1fcMvDnTik48Jj6z83IJBqlkmK9DnY1A==}1816 resolution: {integrity: sha512-gJc7hb1ZQFbWjRDYpu1XG+5IRdr1S/Jz/W2ohcpaqIXuDmHU0ujGiM0x05J0nIfwMF3HOEcANi/+j6T0Uecdpg==}
1814 hasBin: true1817 hasBin: true
18151818
1816 oxlint@1.56.0:1819 oxlint@1.56.0:
...@@ -2546,22 +2549,22 @@ snapshots:...@@ -2546,22 +2549,22 @@ snapshots:
2546 '@oxfmt/binding-win32-x64-msvc@0.40.0':2549 '@oxfmt/binding-win32-x64-msvc@0.40.0':
2547 optional: true2550 optional: true
25482551
2549 '@oxlint-tsgolint/darwin-arm64@0.17.0':2552 '@oxlint-tsgolint/darwin-arm64@0.17.1':
2550 optional: true2553 optional: true
25512554
2552 '@oxlint-tsgolint/darwin-x64@0.17.0':2555 '@oxlint-tsgolint/darwin-x64@0.17.1':
2553 optional: true2556 optional: true
25542557
2555 '@oxlint-tsgolint/linux-arm64@0.17.0':2558 '@oxlint-tsgolint/linux-arm64@0.17.1':
2556 optional: true2559 optional: true
25572560
2558 '@oxlint-tsgolint/linux-x64@0.17.0':2561 '@oxlint-tsgolint/linux-x64@0.17.1':
2559 optional: true2562 optional: true
25602563
2561 '@oxlint-tsgolint/win32-arm64@0.17.0':2564 '@oxlint-tsgolint/win32-arm64@0.17.1':
2562 optional: true2565 optional: true
25632566
2564 '@oxlint-tsgolint/win32-x64@0.17.0':2567 '@oxlint-tsgolint/win32-x64@0.17.1':
2565 optional: true2568 optional: true
25662569
2567 '@oxlint/binding-android-arm-eabi@1.56.0':2570 '@oxlint/binding-android-arm-eabi@1.56.0':
...@@ -4010,17 +4013,16 @@ snapshots:...@@ -4010,17 +4013,16 @@ snapshots:
4010 '@oxfmt/binding-win32-ia32-msvc': 0.40.04013 '@oxfmt/binding-win32-ia32-msvc': 0.40.0
4011 '@oxfmt/binding-win32-x64-msvc': 0.40.04014 '@oxfmt/binding-win32-x64-msvc': 0.40.0
40124015
4013 oxlint-tsgolint@0.17.0:4016 oxlint-tsgolint@0.17.1:
4014 optionalDependencies:4017 optionalDependencies:
4015 '@oxlint-tsgolint/darwin-arm64': 0.17.04018 '@oxlint-tsgolint/darwin-arm64': 0.17.1
4016 '@oxlint-tsgolint/darwin-x64': 0.17.04019 '@oxlint-tsgolint/darwin-x64': 0.17.1
4017 '@oxlint-tsgolint/linux-arm64': 0.17.04020 '@oxlint-tsgolint/linux-arm64': 0.17.1
4018 '@oxlint-tsgolint/linux-x64': 0.17.04021 '@oxlint-tsgolint/linux-x64': 0.17.1
4019 '@oxlint-tsgolint/win32-arm64': 0.17.04022 '@oxlint-tsgolint/win32-arm64': 0.17.1
4020 '@oxlint-tsgolint/win32-x64': 0.17.04023 '@oxlint-tsgolint/win32-x64': 0.17.1
4021 optional: true4024
40224025 oxlint@1.56.0(oxlint-tsgolint@0.17.1):
4023 oxlint@1.56.0(oxlint-tsgolint@0.17.0):
4024 optionalDependencies:4026 optionalDependencies:
4025 '@oxlint/binding-android-arm-eabi': 1.56.04027 '@oxlint/binding-android-arm-eabi': 1.56.0
4026 '@oxlint/binding-android-arm64': 1.56.04028 '@oxlint/binding-android-arm64': 1.56.0
...@@ -4041,7 +4043,7 @@ snapshots:...@@ -4041,7 +4043,7 @@ snapshots:
4041 '@oxlint/binding-win32-arm64-msvc': 1.56.04043 '@oxlint/binding-win32-arm64-msvc': 1.56.0
4042 '@oxlint/binding-win32-ia32-msvc': 1.56.04044 '@oxlint/binding-win32-ia32-msvc': 1.56.0
4043 '@oxlint/binding-win32-x64-msvc': 1.56.04045 '@oxlint/binding-win32-x64-msvc': 1.56.0
4044 oxlint-tsgolint: 0.17.04046 oxlint-tsgolint: 0.17.1
40454047
4046 package-manager-detector@1.6.0: {}4048 package-manager-detector@1.6.0: {}
40474049
readme.md+1-1
...@@ -30,7 +30,7 @@ to Streamdown:...@@ -30,7 +30,7 @@ to Streamdown:
30- **Headless UI**: No built in styles or components, bring your own CSS to blend30- **Headless UI**: No built in styles or components, bring your own CSS to blend
31 your markdown with your existing theme. `@clo/react-markdown` simply takes in31 your markdown with your existing theme. `@clo/react-markdown` simply takes in
32 your existing `unified` pipeline and works off of that.32 your existing `unified` pipeline and works off of that.
33- **Easy to Audit**: Only ~1000 lines of modern TypeScript. 4 total files.33- **Easy to Audit**: Only ~1000 lines of modern TypeScript. 4 source files.
3434
35[Streamdown]: https://streamdown.ai35[Streamdown]: https://streamdown.ai
36[unified]: https://unifiedjs.com/36[unified]: https://unifiedjs.com/
src/Predict.ts+100-15
...@@ -4,8 +4,8 @@...@@ -4,8 +4,8 @@
4 * specifically triggers a fast parsing path.4 * specifically triggers a fast parsing path.
5 */5 */
6export class Predict {6export class Predict {
7 /** @internal Read-only state indicating the stable string */
7 #stable = "";8 #stable = "";
8
9 /** Incrementally reparses the tail. */9 /** Incrementally reparses the tail. */
10 update(text: string): string {10 update(text: string): string {
11 if (!text.startsWith(this.#stable)) this.#stable = "";11 if (!text.startsWith(this.#stable)) this.#stable = "";
...@@ -266,23 +266,35 @@ function renderTail(tail: string, state: TailState) {...@@ -266,23 +266,35 @@ function renderTail(tail: string, state: TailState) {
266 if (state.pendingHtml !== null)266 if (state.pendingHtml !== null)
267 tail = tail.slice(0, findPendingHtmlCutoff(tail, state.pendingHtml));267 tail = tail.slice(0, findPendingHtmlCutoff(tail, state.pendingHtml));
268 const link = state.links.at(-1);268 const link = state.links.at(-1);
269 if (link) return renderOpenLink(tail, link);269 if (link) {
270 return maybePredictTable(renderOpenLink(tail, link));
271 }
270 const nestedClosers = renderNestedClosers(state);272 const nestedClosers = renderNestedClosers(state);
271 if (nestedClosers) return appendCloser(tail, nestedClosers);273 if (nestedClosers) {
272 const open = findLastOpen(state);274 return maybePredictTable(appendCloser(tail, nestedClosers));
273 if (!open) return state.pendingDelim ? tail.slice(0, state.pendingDelim.start) : tail;
274 if (open.kind === "delim") {
275 if (!hasContentAfter(tail, open.start, open.token.length)) return tail.slice(0, open.start);
276 return appendCloserBeforeTrailingInlineWhitespace(tail, open.token);
277 }275 }
278 if (!hasContentAfter(tail, open.start, open.token.length)) {276 const open = findLastOpen(state);
279 return open.kind === "fence" ? tail : tail.slice(0, open.start);277 if (!open) {
278 return maybePredictTable(state.pendingDelim ? tail.slice(0, state.pendingDelim.start) : tail);
279 } else if (open.kind === "delim") {
280 return maybePredictTable(
281 hasContentAfter(tail, open.start, open.token.length)
282 ? appendCloserBeforeTrailingInlineWhitespace(tail, open.token)
283 : tail.slice(0, open.start),
284 );
285 } else if (!hasContentAfter(tail, open.start, open.token.length)) {
286 return maybePredictTable(open.kind === "fence" ? tail : tail.slice(0, open.start));
287 } else if (open.kind === "fence") {
288 return maybePredictTable(tail);
289 } else if (open.kind === "code") {
290 return maybePredictTable(appendCloser(tail, open.token));
291 } else if (open.token === "$$") {
292 return maybePredictTable(appendCloser(tail, (tail.endsWith("\n") ? "" : "\n") + "$$"));
293 } else if (/\s/.test(tail[tail.length - 1] ?? "")) {
294 return maybePredictTable(tail);
295 } else {
296 return maybePredictTable(appendCloser(tail, "$"));
280 }297 }
281 if (open.kind === "fence") return tail;
282 if (open.kind === "code") return appendCloser(tail, open.token);
283 if (open.token === "$$") return appendCloser(tail, (tail.endsWith("\n") ? "" : "\n") + "$$");
284 if (/\s/.test(tail[tail.length - 1] ?? "")) return tail;
285 return appendCloser(tail, "$");
286}298}
287299
288function renderOpenLink(tail: string, state: LinkState) {300function renderOpenLink(tail: string, state: LinkState) {
...@@ -338,6 +350,51 @@ function isCompleteHtmlTag(tail: string, start: number, end: number) {...@@ -338,6 +350,51 @@ function isCompleteHtmlTag(tail: string, start: number, end: number) {
338 return true;350 return true;
339}351}
340352
353function maybePredictTable(text: string) {
354 const lastBlankLine = text.lastIndexOf("\n\n");
355 const blockStart = lastBlankLine === -1 ? 0 : lastBlankLine + 2;
356 const before = text.slice(0, blockStart);
357 const block = text.slice(blockStart);
358 const newline = block.indexOf("\n");
359 const header = newline === -1 ? block : block.slice(0, newline);
360 const indent = header.match(/^( *)\|/)?.[1];
361 if (indent === undefined) return text;
362 if (countPipes(header) < 2 && !hasTableHeaderContent(header, indent)) return before;
363 const fullHeader = header.trimEnd().endsWith("|")
364 ? header
365 : appendCloserBeforeTrailingInlineWhitespace(header, " |");
366 const pipeCount = countPipes(fullHeader);
367 const columns =
368 pipeCount < 2 ? 0 : fullHeader.trimEnd().endsWith("|") ? pipeCount - 1 : pipeCount;
369 if (columns === 0) return text;
370 const rest = newline === -1 ? "" : block.slice(newline + 1);
371 const fullDelimiter = renderTableDelimiter(
372 indent,
373 Array.from({ length: columns }, () => "-"),
374 );
375 if (rest.length === 0) return before + fullHeader + "\n" + fullDelimiter;
376 const delimiterEnd = rest.indexOf("\n");
377 const delimiter = delimiterEnd === -1 ? rest : rest.slice(0, delimiterEnd);
378 const afterDelimiter = delimiterEnd === -1 ? "" : rest.slice(delimiterEnd);
379 if (isCompleteTableDelimiter(delimiter, indent, columns)) return text;
380 if (delimiter.startsWith(indent + "|") && /^[ |:\-\t]*$/.test(delimiter.slice(indent.length))) {
381 const cells = parseTableDelimiterCells(delimiter, indent).map((cell) => {
382 const trimmed = cell.trim();
383 if (trimmed.length === 0) return "-";
384 let hyphenCount = 0;
385 for (let i = 0; i < trimmed.length; i++) if (trimmed.charCodeAt(i) === 0x2d) hyphenCount++;
386 return (
387 (trimmed.startsWith(":") ? ":" : "") +
388 "-".repeat(Math.max(1, hyphenCount)) +
389 (trimmed.length > 1 && trimmed.endsWith(":") ? ":" : "")
390 );
391 });
392 while (cells.length < columns) cells.push("-");
393 return before + fullHeader + "\n" + renderTableDelimiter(indent, cells) + afterDelimiter;
394 }
395 return before + fullHeader + "\n" + fullDelimiter + "\n" + rest;
396}
397
341function appendCloser(tail: string, closer: string) {398function appendCloser(tail: string, closer: string) {
342 return tail + closer.slice(overlapLength(tail, closer));399 return tail + closer.slice(overlapLength(tail, closer));
343}400}
...@@ -363,6 +420,34 @@ function findLastOpen(state: TailState) {...@@ -363,6 +420,34 @@ function findLastOpen(state: TailState) {
363 return state.exclusive;420 return state.exclusive;
364}421}
365422
423function countPipes(text: string) {
424 let count = 0;
425 for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 0x7c) count++;
426 return count;
427}
428
429function hasTableHeaderContent(header: string, indent: string) {
430 return header.slice(indent.length + 1).trim().length > 0;
431}
432
433function renderTableDelimiter(indent: string, cells: string[]) {
434 return indent + "|" + cells.map((cell) => ` ${cell} |`).join("");
435}
436
437function parseTableDelimiterCells(line: string, indent: string) {
438 const cells = line.slice(indent.length + 1).split("|");
439 if (line.trimEnd().endsWith("|")) cells.pop();
440 return cells;
441}
442
443function isCompleteTableDelimiter(line: string, indent: string, columns: number) {
444 if (!line.startsWith(indent)) return false;
445 const trimmed = line.slice(indent.length).trim();
446 if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false;
447 const cells = parseTableDelimiterCells(trimmed, "").map((cell) => cell.trim());
448 return cells.length === columns && cells.every((cell) => /^:?-+:?$/.test(cell));
449}
450
366function runLengthAt(text: string, start: number) {451function runLengthAt(text: string, start: number) {
367 let end = start + 1;452 let end = start + 1;
368 while (end < text.length && text[end] === text[start]) end += 1;453 while (end < text.length && text[end] === text[start]) end += 1;
tests/Markdown.memoization.test.tsx deleted-404
...@@ -1,404 +0,0 @@
1import { cleanup, render, screen } from "@testing-library/react";
2import projectReadme from "../README.md?raw";
3import type { Components } from "rehype-react";
4import type { ComponentPropsWithoutRef, JSX } from "react";
5import { useRef } from "react";
6import remarkGfm from "remark-gfm";
7import remarkParse from "remark-parse";
8import { unified } from "unified";
9import { afterEach, expect, it, vi } from "vitest";
10import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts";
11import { type BaseProcessor, Memoizer } from "../src/Memoizer.ts";
12
13type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & {
14 node?: unknown;
15};
16
17const renderOptions = { reactStrictMode: false } as const;
18const strictRenderOptions = { reactStrictMode: true } as const;
19const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor;
20
21afterEach(() => {
22 cleanup();
23 vi.restoreAllMocks();
24});
25
26function omitNode<Props extends { node?: unknown }>(props: Props): Omit<Props, "node"> {
27 const { node, ...rest } = props;
28 void node;
29 return rest;
30}
31
32it("skips Markdown rerenders for semantically equal props, then wakes back up when content changes", () => {
33 const updateSpy = vi.spyOn(Memoizer.prototype, "update");
34 const Paragraph = vi.fn(function Paragraph(props: MarkdownProps<"p">) {
35 return <p data-testid="paragraph" {...omitNode(props)} />;
36 });
37 const components = { p: Paragraph } satisfies Partial<Components>;
38
39 const { rerender } = render(<Markdown content="hello" components={components} />, renderOptions);
40
41 expect(updateSpy).toHaveBeenCalledTimes(1);
42 expect(Paragraph).toHaveBeenCalledTimes(1);
43
44 rerender(<Markdown content="hello" components={components} />);
45 rerender(<Markdown content="hello" components={{ p: Paragraph }} />);
46
47 expect(updateSpy).toHaveBeenCalledTimes(1);
48 expect(Paragraph).toHaveBeenCalledTimes(1);
49 expect(screen.getByTestId("paragraph").textContent).toBe("hello");
50
51 rerender(<Markdown content="hello!" components={{ p: Paragraph }} />);
52
53 expect(updateSpy).toHaveBeenCalledTimes(2);
54 expect(Paragraph).toHaveBeenCalledTimes(2);
55 expect(screen.getByTestId("paragraph").textContent).toBe("hello!");
56});
57
58it("keeps an unchanged inline custom renderer asleep when only another block changes", () => {
59 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
60 return <a data-testid="link" {...omitNode(props)} />;
61 });
62 const components = { a: Link } satisfies Partial<Components>;
63
64 const { rerender } = render(
65 <Markdown content={"first block\n\n[stable](https://example.com)"} components={components} />,
66 renderOptions,
67 );
68
69 const link = screen.getByTestId("link");
70 expect(Link).toHaveBeenCalledTimes(1);
71
72 rerender(
73 <Markdown
74 content={"first block changed\n\n[stable](https://example.com)"}
75 components={components}
76 />,
77 );
78 rerender(
79 <Markdown
80 content={"first block changed again\n\n[stable](https://example.com)"}
81 components={components}
82 />,
83 );
84
85 expect(Link).toHaveBeenCalledTimes(1);
86 expect(screen.getByTestId("link")).toBe(link);
87 expect(screen.getByTestId("link").textContent).toBe("stable");
88});
89
90it("does not rerender an unchanged inline custom renderer when only adjacent text changes", () => {
91 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
92 return <a data-testid="link" {...omitNode(props)} />;
93 });
94 const components = { a: Link } satisfies Partial<Components>;
95
96 const { rerender } = render(
97 <Markdown content={"alpha [stable](https://example.com) omega"} components={components} />,
98 renderOptions,
99 );
100
101 const link = screen.getByTestId("link");
102 expect(Link).toHaveBeenCalledTimes(1);
103
104 rerender(
105 <Markdown content={"alpha! [stable](https://example.com) omega"} components={components} />,
106 );
107 expect(screen.getByTestId("link")).toBe(link);
108 expect(Link).toHaveBeenCalledTimes(1);
109
110 rerender(
111 <Markdown content={"alpha! [stable](https://example.com) omega?"} components={components} />,
112 );
113 expect(screen.getByTestId("link")).toBe(link);
114 expect(Link).toHaveBeenCalledTimes(1);
115});
116
117it("does not rerender an unchanged inline custom renderer with formatted children when only adjacent text changes", () => {
118 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
119 return <a data-testid="rich-link" {...omitNode(props)} />;
120 });
121 const components = { a: Link } satisfies Partial<Components>;
122
123 const { rerender } = render(
124 <Markdown content={"alpha [**stable**](https://example.com) omega"} components={components} />,
125 renderOptions,
126 );
127
128 const link = screen.getByTestId("rich-link");
129 expect(Link).toHaveBeenCalledTimes(1);
130
131 rerender(
132 <Markdown content={"alpha! [**stable**](https://example.com) omega"} components={components} />,
133 );
134
135 expect(screen.getByTestId("rich-link")).toBe(link);
136 expect(Link).toHaveBeenCalledTimes(1);
137});
138
139it("keeps an unchanged strong renderer asleep when surrounding paragraph text changes", () => {
140 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
141 return <strong data-testid="strong" {...omitNode(props)} />;
142 });
143 const components = { strong: Strong } satisfies Partial<Components>;
144
145 const { rerender } = render(
146 <Markdown content={"alpha **stable** omega"} components={components} />,
147 renderOptions,
148 );
149
150 const strong = screen.getByTestId("strong");
151 expect(Strong).toHaveBeenCalledTimes(1);
152
153 rerender(<Markdown content={"alpha! **stable** omega"} components={components} />);
154 rerender(<Markdown content={"alpha! **stable** omega?"} components={components} />);
155
156 expect(screen.getByTestId("strong")).toBe(strong);
157 expect(Strong).toHaveBeenCalledTimes(1);
158});
159
160it("keeps an unchanged strong renderer asleep under Strict Mode when surrounding text changes", () => {
161 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
162 return <strong data-testid="strict-strong" {...omitNode(props)} />;
163 });
164 const components = { strong: Strong } satisfies Partial<Components>;
165
166 const { rerender } = render(
167 <Markdown content={"alpha **stable** omega"} components={components} />,
168 strictRenderOptions,
169 );
170
171 const strong = screen.getByTestId("strict-strong");
172 const initialCalls = Strong.mock.calls.length;
173
174 rerender(<Markdown content={"alpha! **stable** omega"} components={components} />);
175 rerender(<Markdown content={"alpha! **stable** omega?"} components={components} />);
176
177 expect(screen.getByTestId("strict-strong")).toBe(strong);
178 expect(Strong.mock.calls.length).toBe(initialCalls);
179});
180
181it("keeps an unchanged list item renderer asleep when a sibling item changes", () => {
182 const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) {
183 return <li data-testid="list-item" {...omitNode(props)} />;
184 });
185 const components = { li: ListItem } satisfies Partial<Components>;
186
187 const { rerender } = render(
188 <Markdown content={"- first\n- stable\n- third"} components={components} />,
189 renderOptions,
190 );
191
192 const stableItem = screen.getByText("stable").closest("li");
193 expect(stableItem).not.toBeNull();
194 expect(ListItem).toHaveBeenCalledTimes(3);
195
196 rerender(<Markdown content={"- first changed\n- stable\n- third"} components={components} />);
197
198 expect(screen.getByText("stable").closest("li")).toBe(stableItem);
199 expect(ListItem).toHaveBeenCalledTimes(4);
200});
201
202it("keeps an unchanged table cell renderer asleep when another cell changes", () => {
203 const TableCell = vi.fn(function TableCell(props: MarkdownProps<"td">) {
204 return <td data-testid="table-cell" {...omitNode(props)} />;
205 });
206 const components = { td: TableCell } satisfies Partial<Components>;
207
208 const { rerender } = render(
209 <Markdown
210 components={components}
211 content={"| left | stable |\n| --- | --- |\n| one | keep |"}
212 processor={gfmProcessor}
213 />,
214 renderOptions,
215 );
216
217 const stableCell = screen.getByText("keep").closest("td");
218 expect(stableCell).not.toBeNull();
219 expect(TableCell).toHaveBeenCalledTimes(2);
220
221 rerender(
222 <Markdown
223 components={components}
224 content={"| left | stable |\n| --- | --- |\n| one! | keep |"}
225 processor={gfmProcessor}
226 />,
227 );
228
229 expect(screen.getByText("keep").closest("td")).toBe(stableCell);
230 expect(TableCell).toHaveBeenCalledTimes(3);
231});
232
233it("does not rerender Markdown through the provider when the effective components stay the same", () => {
234 const updateSpy = vi.spyOn(Memoizer.prototype, "update");
235 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
236 return <a data-testid="provider-link" {...omitNode(props)} />;
237 });
238
239 function WrappedMarkdown({ components }: { components: Partial<Components> }) {
240 return (
241 <MarkdownOptionsProvider components={components}>
242 <Markdown content="[stable](https://example.com)" />
243 </MarkdownOptionsProvider>
244 );
245 }
246
247 const { rerender } = render(<WrappedMarkdown components={{ a: Link }} />, renderOptions);
248
249 expect(updateSpy).toHaveBeenCalledTimes(1);
250 expect(Link).toHaveBeenCalledTimes(1);
251
252 rerender(<WrappedMarkdown components={{ a: Link }} />);
253
254 expect(updateSpy).toHaveBeenCalledTimes(1);
255 expect(Link).toHaveBeenCalledTimes(1);
256 expect(screen.getByTestId("provider-link").textContent).toBe("stable");
257});
258
259it("reprocesses the document when prediction changes", () => {
260 const { container, rerender } = render(
261 <Markdown content="hello *world" predict={false} />,
262 renderOptions,
263 );
264
265 expect(container.querySelector("em")).toBeNull();
266 expect(container.textContent).toBe("hello *world");
267
268 rerender(<Markdown content="hello *world" predict />);
269
270 expect(container.querySelector("em")?.textContent).toBe("world");
271 expect(container.textContent).toBe("hello world");
272});
273
274it.each([
275 {
276 content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"],
277 name: "paragraph merging edits",
278 predict: false,
279 processor: undefined,
280 },
281 {
282 content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]"],
283 name: "reference link definition edits",
284 predict: false,
285 processor: undefined,
286 },
287 {
288 content: [
289 "| A | B |\n| --- | --- |\n| one | two |",
290 "| A | B |\n| --- | --- |\n| one! | two |",
291 "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |",
292 ],
293 name: "gfm table edits",
294 predict: false,
295 processor: gfmProcessor,
296 },
297 {
298 content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"],
299 name: "prediction edits",
300 predict: true,
301 processor: undefined,
302 },
303])("matches a fresh render across $name", ({ content, predict, processor }) => {
304 const first = content[0]!;
305 const { container, rerender } = render(
306 <Markdown content={first} predict={predict} processor={processor} />,
307 renderOptions,
308 );
309
310 expect(container.innerHTML).toBe(renderFreshHtml(first, predict, processor));
311
312 for (const step of content.slice(1)) {
313 rerender(<Markdown content={step} predict={predict} processor={processor} />);
314 expect(container.innerHTML).toBe(renderFreshHtml(step, predict, processor));
315 }
316});
317
318it("keeps a stable block tied to its own DOM node when blocks are inserted or removed above it", () => {
319 function Link(props: MarkdownProps<"a">) {
320 const instanceRef = useRef(Symbol("instance"));
321 return <a data-instance={String(instanceRef.current)} {...omitNode(props)} />;
322 }
323
324 const components = { a: Link } satisfies Partial<Components>;
325 const { rerender } = render(
326 <Markdown content={"before\n\n[stable](https://example.com)"} components={components} />,
327 renderOptions,
328 );
329
330 const stableLink = screen.getByRole("link");
331
332 rerender(
333 <Markdown
334 content={"intro\n\nbefore\n\n[stable](https://example.com)"}
335 components={components}
336 />,
337 );
338 expect(screen.getByRole("link")).toBe(stableLink);
339
340 rerender(<Markdown content={"intro\n\n[stable](https://example.com)"} components={components} />);
341 expect(screen.getByRole("link")).toBe(stableLink);
342});
343
344it("keeps an unchanged list item tied to its DOM node when a sibling item is inserted above it", () => {
345 function ListItem(props: MarkdownProps<"li">) {
346 const instanceRef = useRef(Symbol("instance"));
347 return <li data-instance={String(instanceRef.current)} {...omitNode(props)} />;
348 }
349
350 const components = { li: ListItem } satisfies Partial<Components>;
351 const { rerender } = render(
352 <Markdown content={"- stable\n- tail"} components={components} />,
353 renderOptions,
354 );
355
356 const stableItem = screen.getByText("stable").closest("li");
357 expect(stableItem).not.toBeNull();
358
359 rerender(<Markdown content={"- inserted\n- stable\n- tail"} components={components} />);
360
361 expect(screen.getByText("stable").closest("li")).toBe(stableItem);
362});
363
364it("keeps demo README strong renderers asleep when editing a different block", () => {
365 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
366 return <strong data-testid="readme-strong" {...omitNode(props)} />;
367 });
368 const components = { strong: Strong } satisfies Partial<Components>;
369
370 const original = projectReadme;
371 const edited = projectReadme.replace(
372 "This package exports a React component to render Markdown using the [unified]",
373 "This package exports a React component to render memoized Markdown using the [unified]",
374 );
375
376 const { rerender } = render(
377 <Markdown components={components} content={original} processor={gfmProcessor} />,
378 renderOptions,
379 );
380
381 const stableStrong = screen.getByText("Bring your Existing Pipeline").closest("strong");
382 expect(stableStrong).not.toBeNull();
383
384 const strongCalls = Strong.mock.calls.length;
385
386 rerender(<Markdown components={components} content={edited} processor={gfmProcessor} />);
387
388 expect(screen.getByText("Bring your Existing Pipeline").closest("strong")).toBe(stableStrong);
389 expect(Strong.mock.calls.length).toBe(strongCalls);
390});
391
392function renderFreshHtml(
393 content: string,
394 predict: boolean | undefined,
395 processor: BaseProcessor | undefined,
396) {
397 const { container, unmount } = render(
398 <Markdown content={content} predict={predict} processor={processor} />,
399 renderOptions,
400 );
401 const html = container.innerHTML;
402 unmount();
403 return html;
404}
tests/Markdown.test.tsx created+404
...@@ -0,0 +1,404 @@
1import { cleanup, render, screen } from "@testing-library/react";
2import projectReadme from "../readme.md?raw";
3import type { Components } from "rehype-react";
4import type { ComponentPropsWithoutRef, JSX } from "react";
5import { useRef } from "react";
6import remarkGfm from "remark-gfm";
7import remarkParse from "remark-parse";
8import { unified } from "unified";
9import { afterEach, expect, it, vi } from "vitest";
10import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts";
11import { type BaseProcessor, Memoizer } from "../src/Memoizer.ts";
12
13type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & {
14 node?: unknown;
15};
16
17const renderOptions = { reactStrictMode: false } as const;
18const strictRenderOptions = { reactStrictMode: true } as const;
19const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor;
20
21afterEach(() => {
22 cleanup();
23 vi.restoreAllMocks();
24});
25
26function omitNode<Props extends { node?: unknown }>(props: Props): Omit<Props, "node"> {
27 const { node, ...rest } = props;
28 void node;
29 return rest;
30}
31
32it("skips Markdown rerenders for semantically equal props, then wakes back up when content changes", () => {
33 const updateSpy = vi.spyOn(Memoizer.prototype, "update");
34 const Paragraph = vi.fn(function Paragraph(props: MarkdownProps<"p">) {
35 return <p data-testid="paragraph" {...omitNode(props)} />;
36 });
37 const components = { p: Paragraph } satisfies Partial<Components>;
38
39 const { rerender } = render(<Markdown content="hello" components={components} />, renderOptions);
40
41 expect(updateSpy).toHaveBeenCalledTimes(1);
42 expect(Paragraph).toHaveBeenCalledTimes(1);
43
44 rerender(<Markdown content="hello" components={components} />);
45 rerender(<Markdown content="hello" components={{ p: Paragraph }} />);
46
47 expect(updateSpy).toHaveBeenCalledTimes(1);
48 expect(Paragraph).toHaveBeenCalledTimes(1);
49 expect(screen.getByTestId("paragraph").textContent).toBe("hello");
50
51 rerender(<Markdown content="hello!" components={{ p: Paragraph }} />);
52
53 expect(updateSpy).toHaveBeenCalledTimes(2);
54 expect(Paragraph).toHaveBeenCalledTimes(2);
55 expect(screen.getByTestId("paragraph").textContent).toBe("hello!");
56});
57
58it("keeps an unchanged inline custom renderer asleep when only another block changes", () => {
59 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
60 return <a data-testid="link" {...omitNode(props)} />;
61 });
62 const components = { a: Link } satisfies Partial<Components>;
63
64 const { rerender } = render(
65 <Markdown content={"first block\n\n[stable](https://example.com)"} components={components} />,
66 renderOptions,
67 );
68
69 const link = screen.getByTestId("link");
70 expect(Link).toHaveBeenCalledTimes(1);
71
72 rerender(
73 <Markdown
74 content={"first block changed\n\n[stable](https://example.com)"}
75 components={components}
76 />,
77 );
78 rerender(
79 <Markdown
80 content={"first block changed again\n\n[stable](https://example.com)"}
81 components={components}
82 />,
83 );
84
85 expect(Link).toHaveBeenCalledTimes(1);
86 expect(screen.getByTestId("link")).toBe(link);
87 expect(screen.getByTestId("link").textContent).toBe("stable");
88});
89
90it("does not rerender an unchanged inline custom renderer when only adjacent text changes", () => {
91 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
92 return <a data-testid="link" {...omitNode(props)} />;
93 });
94 const components = { a: Link } satisfies Partial<Components>;
95
96 const { rerender } = render(
97 <Markdown content={"alpha [stable](https://example.com) omega"} components={components} />,
98 renderOptions,
99 );
100
101 const link = screen.getByTestId("link");
102 expect(Link).toHaveBeenCalledTimes(1);
103
104 rerender(
105 <Markdown content={"alpha! [stable](https://example.com) omega"} components={components} />,
106 );
107 expect(screen.getByTestId("link")).toBe(link);
108 expect(Link).toHaveBeenCalledTimes(1);
109
110 rerender(
111 <Markdown content={"alpha! [stable](https://example.com) omega?"} components={components} />,
112 );
113 expect(screen.getByTestId("link")).toBe(link);
114 expect(Link).toHaveBeenCalledTimes(1);
115});
116
117it("does not rerender an unchanged inline custom renderer with formatted children when only adjacent text changes", () => {
118 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
119 return <a data-testid="rich-link" {...omitNode(props)} />;
120 });
121 const components = { a: Link } satisfies Partial<Components>;
122
123 const { rerender } = render(
124 <Markdown content={"alpha [**stable**](https://example.com) omega"} components={components} />,
125 renderOptions,
126 );
127
128 const link = screen.getByTestId("rich-link");
129 expect(Link).toHaveBeenCalledTimes(1);
130
131 rerender(
132 <Markdown content={"alpha! [**stable**](https://example.com) omega"} components={components} />,
133 );
134
135 expect(screen.getByTestId("rich-link")).toBe(link);
136 expect(Link).toHaveBeenCalledTimes(1);
137});
138
139it("keeps an unchanged strong renderer asleep when surrounding paragraph text changes", () => {
140 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
141 return <strong data-testid="strong" {...omitNode(props)} />;
142 });
143 const components = { strong: Strong } satisfies Partial<Components>;
144
145 const { rerender } = render(
146 <Markdown content={"alpha **stable** omega"} components={components} />,
147 renderOptions,
148 );
149
150 const strong = screen.getByTestId("strong");
151 expect(Strong).toHaveBeenCalledTimes(1);
152
153 rerender(<Markdown content={"alpha! **stable** omega"} components={components} />);
154 rerender(<Markdown content={"alpha! **stable** omega?"} components={components} />);
155
156 expect(screen.getByTestId("strong")).toBe(strong);
157 expect(Strong).toHaveBeenCalledTimes(1);
158});
159
160it("keeps an unchanged strong renderer asleep under Strict Mode when surrounding text changes", () => {
161 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
162 return <strong data-testid="strict-strong" {...omitNode(props)} />;
163 });
164 const components = { strong: Strong } satisfies Partial<Components>;
165
166 const { rerender } = render(
167 <Markdown content={"alpha **stable** omega"} components={components} />,
168 strictRenderOptions,
169 );
170
171 const strong = screen.getByTestId("strict-strong");
172 const initialCalls = Strong.mock.calls.length;
173
174 rerender(<Markdown content={"alpha! **stable** omega"} components={components} />);
175 rerender(<Markdown content={"alpha! **stable** omega?"} components={components} />);
176
177 expect(screen.getByTestId("strict-strong")).toBe(strong);
178 expect(Strong.mock.calls.length).toBe(initialCalls);
179});
180
181it("keeps an unchanged list item renderer asleep when a sibling item changes", () => {
182 const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) {
183 return <li data-testid="list-item" {...omitNode(props)} />;
184 });
185 const components = { li: ListItem } satisfies Partial<Components>;
186
187 const { rerender } = render(
188 <Markdown content={"- first\n- stable\n- third"} components={components} />,
189 renderOptions,
190 );
191
192 const stableItem = screen.getByText("stable").closest("li");
193 expect(stableItem).not.toBeNull();
194 expect(ListItem).toHaveBeenCalledTimes(3);
195
196 rerender(<Markdown content={"- first changed\n- stable\n- third"} components={components} />);
197
198 expect(screen.getByText("stable").closest("li")).toBe(stableItem);
199 expect(ListItem).toHaveBeenCalledTimes(4);
200});
201
202it("keeps an unchanged table cell renderer asleep when another cell changes", () => {
203 const TableCell = vi.fn(function TableCell(props: MarkdownProps<"td">) {
204 return <td data-testid="table-cell" {...omitNode(props)} />;
205 });
206 const components = { td: TableCell } satisfies Partial<Components>;
207
208 const { rerender } = render(
209 <Markdown
210 components={components}
211 content={"| left | stable |\n| --- | --- |\n| one | keep |"}
212 processor={gfmProcessor}
213 />,
214 renderOptions,
215 );
216
217 const stableCell = screen.getByText("keep").closest("td");
218 expect(stableCell).not.toBeNull();
219 expect(TableCell).toHaveBeenCalledTimes(2);
220
221 rerender(
222 <Markdown
223 components={components}
224 content={"| left | stable |\n| --- | --- |\n| one! | keep |"}
225 processor={gfmProcessor}
226 />,
227 );
228
229 expect(screen.getByText("keep").closest("td")).toBe(stableCell);
230 expect(TableCell).toHaveBeenCalledTimes(3);
231});
232
233it("does not rerender Markdown through the provider when the effective components stay the same", () => {
234 const updateSpy = vi.spyOn(Memoizer.prototype, "update");
235 const Link = vi.fn(function Link(props: MarkdownProps<"a">) {
236 return <a data-testid="provider-link" {...omitNode(props)} />;
237 });
238
239 function WrappedMarkdown({ components }: { components: Partial<Components> }) {
240 return (
241 <MarkdownOptionsProvider components={components}>
242 <Markdown content="[stable](https://example.com)" />
243 </MarkdownOptionsProvider>
244 );
245 }
246
247 const { rerender } = render(<WrappedMarkdown components={{ a: Link }} />, renderOptions);
248
249 expect(updateSpy).toHaveBeenCalledTimes(1);
250 expect(Link).toHaveBeenCalledTimes(1);
251
252 rerender(<WrappedMarkdown components={{ a: Link }} />);
253
254 expect(updateSpy).toHaveBeenCalledTimes(1);
255 expect(Link).toHaveBeenCalledTimes(1);
256 expect(screen.getByTestId("provider-link").textContent).toBe("stable");
257});
258
259it("reprocesses the document when prediction changes", () => {
260 const { container, rerender } = render(
261 <Markdown content="hello *world" predict={false} />,
262 renderOptions,
263 );
264
265 expect(container.querySelector("em")).toBeNull();
266 expect(container.textContent).toBe("hello *world");
267
268 rerender(<Markdown content="hello *world" predict />);
269
270 expect(container.querySelector("em")?.textContent).toBe("world");
271 expect(container.textContent).toBe("hello world");
272});
273
274it.each([
275 {
276 content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"],
277 name: "paragraph merging edits",
278 predict: false,
279 processor: undefined,
280 },
281 {
282 content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]"],
283 name: "reference link definition edits",
284 predict: false,
285 processor: undefined,
286 },
287 {
288 content: [
289 "| A | B |\n| --- | --- |\n| one | two |",
290 "| A | B |\n| --- | --- |\n| one! | two |",
291 "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |",
292 ],
293 name: "gfm table edits",
294 predict: false,
295 processor: gfmProcessor,
296 },
297 {
298 content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"],
299 name: "prediction edits",
300 predict: true,
301 processor: undefined,
302 },
303])("matches a fresh render across $name", ({ content, predict, processor }) => {
304 const first = content[0]!;
305 const { container, rerender } = render(
306 <Markdown content={first} predict={predict} processor={processor} />,
307 renderOptions,
308 );
309
310 expect(container.innerHTML).toBe(renderFreshHtml(first, predict, processor));
311
312 for (const step of content.slice(1)) {
313 rerender(<Markdown content={step} predict={predict} processor={processor} />);
314 expect(container.innerHTML).toBe(renderFreshHtml(step, predict, processor));
315 }
316});
317
318it("keeps a stable block tied to its own DOM node when blocks are inserted or removed above it", () => {
319 function Link(props: MarkdownProps<"a">) {
320 const instanceRef = useRef(Symbol("instance"));
321 return <a data-instance={String(instanceRef.current)} {...omitNode(props)} />;
322 }
323
324 const components = { a: Link } satisfies Partial<Components>;
325 const { rerender } = render(
326 <Markdown content={"before\n\n[stable](https://example.com)"} components={components} />,
327 renderOptions,
328 );
329
330 const stableLink = screen.getByRole("link");
331
332 rerender(
333 <Markdown
334 content={"intro\n\nbefore\n\n[stable](https://example.com)"}
335 components={components}
336 />,
337 );
338 expect(screen.getByRole("link")).toBe(stableLink);
339
340 rerender(<Markdown content={"intro\n\n[stable](https://example.com)"} components={components} />);
341 expect(screen.getByRole("link")).toBe(stableLink);
342});
343
344it("keeps an unchanged list item tied to its DOM node when a sibling item is inserted above it", () => {
345 function ListItem(props: MarkdownProps<"li">) {
346 const instanceRef = useRef(Symbol("instance"));
347 return <li data-instance={String(instanceRef.current)} {...omitNode(props)} />;
348 }
349
350 const components = { li: ListItem } satisfies Partial<Components>;
351 const { rerender } = render(
352 <Markdown content={"- stable\n- tail"} components={components} />,
353 renderOptions,
354 );
355
356 const stableItem = screen.getByText("stable").closest("li");
357 expect(stableItem).not.toBeNull();
358
359 rerender(<Markdown content={"- inserted\n- stable\n- tail"} components={components} />);
360
361 expect(screen.getByText("stable").closest("li")).toBe(stableItem);
362});
363
364it("keeps demo README strong renderers asleep when editing a different block", () => {
365 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
366 return <strong data-testid="readme-strong" {...omitNode(props)} />;
367 });
368 const components = { strong: Strong } satisfies Partial<Components>;
369
370 const original = projectReadme;
371 const edited = projectReadme.replace(
372 "This package exports a React component to render Markdown using the [unified]",
373 "This package exports a React component to render memoized Markdown using the [unified]",
374 );
375
376 const { rerender } = render(
377 <Markdown components={components} content={original} processor={gfmProcessor} />,
378 renderOptions,
379 );
380
381 const stableStrong = screen.getByText("Bring your Existing Pipeline").closest("strong");
382 expect(stableStrong).not.toBeNull();
383
384 const strongCalls = Strong.mock.calls.length;
385
386 rerender(<Markdown components={components} content={edited} processor={gfmProcessor} />);
387
388 expect(screen.getByText("Bring your Existing Pipeline").closest("strong")).toBe(stableStrong);
389 expect(Strong.mock.calls.length).toBe(strongCalls);
390});
391
392function renderFreshHtml(
393 content: string,
394 predict: boolean | undefined,
395 processor: BaseProcessor | undefined,
396) {
397 const { container, unmount } = render(
398 <Markdown content={content} predict={predict} processor={processor} />,
399 renderOptions,
400 );
401 const html = container.innerHTML;
402 unmount();
403 return html;
404}
tests/Memoizer.test.tsx+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { ReactNode } from "react";1import type { ReactNode } from "react";
2import { Fragment } from "react";2import { Fragment } from "react";
3import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
4import projectReadme from "../README.md?raw";4import projectReadme from "../readme.md?raw";
5import remarkGfm from "remark-gfm";5import remarkGfm from "remark-gfm";
6import remarkParse from "remark-parse";6import remarkParse from "remark-parse";
7import { unified } from "unified";7import { unified } from "unified";
tests/memoizedHastToReact.test.tsx+3-3
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { Element, ElementContent, Properties, Root, RootContent, Text } from "hast";1import type { Element, ElementContent, Properties, Root, RootContent, Text } from "hast";
2import type { Components } from "rehype-react";2import type { Components } from "rehype-react";
3import type { ReactElement, ReactNode } from "react";3import type { ReactElement, ReactNode } from "react";
4import projectReadme from "../README.md?raw";4import projectReadme from "../readme.md?raw";
5import remarkParse from "remark-parse";5import remarkParse from "remark-parse";
6import remarkRehype from "remark-rehype";6import remarkRehype from "remark-rehype";
7import remarkGfm from "remark-gfm";7import remarkGfm from "remark-gfm";
...@@ -225,7 +225,7 @@ it("uses configured components for matching tags", () => {...@@ -225,7 +225,7 @@ it("uses configured components for matching tags", () => {
225 expect(childrenOf(link)).toEqual(["hello"]);225 expect(childrenOf(link)).toEqual(["hello"]);
226});226});
227227
228it("reuses the README list block when an earlier block changes", () => {228it("reuses the readme list block when an earlier block changes", () => {
229 const original = projectReadme;229 const original = projectReadme;
230 const edited = projectReadme.replace(230 const edited = projectReadme.replace(
231 "This package exports a React component to render Markdown using the [unified]",231 "This package exports a React component to render Markdown using the [unified]",
...@@ -252,7 +252,7 @@ it("reuses the README list block when an earlier block changes", () => {...@@ -252,7 +252,7 @@ it("reuses the README list block when an earlier block changes", () => {
252 expect(second.state).toBe(first.state);252 expect(second.state).toBe(first.state);
253});253});
254254
255it("reuses the README list block with a custom strong component when an earlier block changes", () => {255it("reuses the readme list block with a custom strong component when an earlier block changes", () => {
256 function Strong(props: { children?: ReactNode }) {256 function Strong(props: { children?: ReactNode }) {
257 return <strong data-testid="memo-strong">{props.children}</strong>;257 return <strong data-testid="memo-strong">{props.children}</strong>;
258 }258 }
tests/prediction.test.ts+41
...@@ -223,6 +223,47 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => {...@@ -223,6 +223,47 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => {
223 ])("%s", (_name, input, expected) => check(input, expected));223 ])("%s", (_name, input, expected) => check(input, expected));
224 });224 });
225225
226 describe("table prediction", () => {
227 test.each([
228 ["bare pipe is hidden", "|", ""],
229 ["partial header", "| hello", "| hello |\n| - |"],
230 ["single header predicts separator", "| hello |", "| hello |\n| - |"],
231 [
232 "unfinished second header cell closes before separator",
233 "| hello | **world",
234 "| hello | **world** |\n| - | - |",
235 ],
236 ["trailing newline keeps predicted separator", "| hello |\n", "| hello |\n| - |"],
237 ["partial separator line is completed", "| hello |\n|", "| hello |\n| - |"],
238 ["partial hyphen separator is completed", "| hello |\n| -", "| hello |\n| - |"],
239 ["partial left-aligned separator is completed", "| hello |\n| :", "| hello |\n| :- |"],
240 ["partial right-aligned separator is completed", "| hello |\n| -:", "| hello |\n| -: |"],
241 ["partial centered separator is completed", "| hello |\n| :-:", "| hello |\n| :-: |"],
242 ["synthetic separator stays ahead of body rows", "| hello |\n| a", "| hello |\n| - |\n| a"],
243 [
244 "partial multi-column separator is completed",
245 "| hello | world\n| - |",
246 "| hello | world |\n| - | - |",
247 ],
248 ["real separator row stops prediction", "| hello |\n| - |\n| a", "| hello |\n| - |\n| a"],
249 [
250 "complete left-aligned separator stops prediction",
251 "| hello |\n| :- |\n| a",
252 "| hello |\n| :- |\n| a",
253 ],
254 [
255 "complete right-aligned separator stops prediction",
256 "| hello |\n| -: |\n| a",
257 "| hello |\n| -: |\n| a",
258 ],
259 [
260 "complete centered separator stops prediction",
261 "| hello |\n| :-: |\n| a",
262 "| hello |\n| :-: |\n| a",
263 ],
264 ])("%s", (_name, input, expected) => check(input, expected));
265 });
266
226 describe("code span variants", () => {267 describe("code span variants", () => {
227 test.each([268 test.each([
228 ["double backtick code span", "a ``hello world", "a ``hello world``"],269 ["double backtick code span", "a ``hello world", "a ``hello world``"],