| author | |
| committer | |
| log | 3cea7fc85ec4c44b04362ea0c757c88625c58b5b |
| tree | 8d9b139ddd76f823aacc4fe633a744317eec6c2e |
| parent | b7690cda634c38b30367307cc546917adbda1064 |
| signature |
11 files changed, 588 insertions(+), 459 deletions(-)
deno.json+2-2| ... | ... | @@ -1,11 +1,11 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "@clo/react-markdown", |
| 3 | "version": "1.0.0", | |
| 3 | "version": "1.1.0", | |
| 4 | 4 | "exports": { |
| 5 | 5 | ".": "./src/Markdown.ts", |
| 6 | 6 | "./Predict": "./src/Predict.ts" |
| 7 | 7 | }, |
| 8 | 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 | 1 | import remarkGfm from "remark-gfm"; |
| 2 | 2 | import remarkParse from "remark-parse"; |
| 3 | 3 | import { type PluggableList, unified } from "unified"; |
| 4 | import projectReadme from "../README.md?raw"; | |
| 4 | import projectReadme from "../readme.md?raw"; | |
| 5 | 5 | |
| 6 | 6 | export const initialMarkdown = projectReadme; |
| 7 | 7 | export const processor = unified().use(remarkParse).use(remarkGfm); |
package.json+1| ... | ... | @@ -36,6 +36,7 @@ |
| 36 | 36 | "happy-dom": "^20.8.4", |
| 37 | 37 | "oxfmt": "^0.40.0", |
| 38 | 38 | "oxlint": "^1.55.0", |
| 39 | "oxlint-tsgolint": "^0.17.1", | |
| 39 | 40 | "react-dom": "^19.2.4", |
| 40 | 41 | "react-markdown": "^10.1.0", |
| 41 | 42 | "react-scan": "^0.5.3", |
pnpm-lock.yaml+34-32| ... | ... | @@ -68,7 +68,10 @@ importers: |
| 68 | 68 | version: 0.40.0 |
| 69 | 69 | oxlint: |
| 70 | 70 | 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 | 75 | react-dom: |
| 73 | 76 | specifier: ^19.2.4 |
| 74 | 77 | version: 19.2.4(react@19.2.4) |
| ... | ... | @@ -504,33 +507,33 @@ packages: |
| 504 | 507 | cpu: [x64] |
| 505 | 508 | os: [win32] |
| 506 | 509 | |
| 507 | '@oxlint-tsgolint/darwin-arm64@0.17.0': | |
| 508 | resolution: {integrity: sha512-z3XwCDuOAKgk7bO4y5tyH8Zogwr51G56R0XGKC3tlAbrAq8DecoxAd3qhRZqWBMG2Gzl5bWU3Ghu7lrxuLPzYw==} | |
| 510 | '@oxlint-tsgolint/darwin-arm64@0.17.1': | |
| 511 | resolution: {integrity: sha512-JNWNwyvSDcUQSBlQRl10XrCeNcN66TMvDw3gIDQeop5SNa1F7wFhsEx4zitYb7fGHwGh9095tsNttmuCaNXCbw==} | |
| 509 | 512 | cpu: [arm64] |
| 510 | 513 | os: [darwin] |
| 511 | 514 | |
| 512 | '@oxlint-tsgolint/darwin-x64@0.17.0': | |
| 513 | resolution: {integrity: sha512-TZgVXy0MtI8nt0MYiceuZhHPwHcwlIZ/YwzFTAKrgdHiTvVzFbqHVdXi5wbZfT/o1nHGw9fbGWPlb6qKZ4uZ9Q==} | |
| 515 | '@oxlint-tsgolint/darwin-x64@0.17.1': | |
| 516 | resolution: {integrity: sha512-SluNf6CW88pgGPqQUGC5GoK5qESWo2ct1PRDbza3vbf9SK2npx3igvylGQIgE9qYYOcjgnVdLOJ0+q0gItgUmQ==} | |
| 514 | 517 | cpu: [x64] |
| 515 | 518 | os: [darwin] |
| 516 | 519 | |
| 517 | '@oxlint-tsgolint/linux-arm64@0.17.0': | |
| 518 | resolution: {integrity: sha512-IDfhFl/Y8bjidCvAP6QAxVyBsl78TmfCHlfjtEv2XtJXgYmIwzv6muO18XMp74SZ2qAyD4y2n2dUedrmghGHeA==} | |
| 520 | '@oxlint-tsgolint/linux-arm64@0.17.1': | |
| 521 | resolution: {integrity: sha512-BJxQ7/cdo2dNdGIBs2PIR6BaPA7cPfe+r1HE/uY+K7g2ygip+0LHB3GUO9GaNDZuWpsnDyjLYYowEGrVK8dokA==} | |
| 519 | 522 | cpu: [arm64] |
| 520 | 523 | os: [linux] |
| 521 | 524 | |
| 522 | '@oxlint-tsgolint/linux-x64@0.17.0': | |
| 523 | resolution: {integrity: sha512-Bgdgqx/m8EnfjmmlRLEeYy9Yhdt1GdFrMr5mTu/NyLRGkB1C9VLAikdxB7U9QambAGTAmjMbHNFDFk8Vx69Huw==} | |
| 525 | '@oxlint-tsgolint/linux-x64@0.17.1': | |
| 526 | resolution: {integrity: sha512-s6UjmuaJbZ4zz/wJKdEw/s5mc0t41rgwxQJCSHPuzMumMK6ylrB7nydhDf8ObTtzhTIZdAS/2S/uayJmDcGbxw==} | |
| 524 | 527 | cpu: [x64] |
| 525 | 528 | os: [linux] |
| 526 | 529 | |
| 527 | '@oxlint-tsgolint/win32-arm64@0.17.0': | |
| 528 | resolution: {integrity: sha512-dO6wyKMDqFWh1vwr+zNZS7/ovlfGgl4S3P1LDy4CKjP6V6NGtdmEwWkWax8j/I8RzGZdfXKnoUfb/qhVg5bx0w==} | |
| 530 | '@oxlint-tsgolint/win32-arm64@0.17.1': | |
| 531 | resolution: {integrity: sha512-EO/Oj0ixHX+UQdu9hM7YUzibZI888MvPUo/DF8lSxFBt4JNEt8qGkwJEbCYjB/1LhUNmPHzSw2Tr9dCFVfW9nw==} | |
| 529 | 532 | cpu: [arm64] |
| 530 | 533 | os: [win32] |
| 531 | 534 | |
| 532 | '@oxlint-tsgolint/win32-x64@0.17.0': | |
| 533 | resolution: {integrity: sha512-lPGYFp3yX2nh6hLTpIuMnJbZnt3Df42VkoA/fSkMYi2a/LXdDytQGpgZOrb5j47TICARd34RauKm0P3OA4Oxbw==} | |
| 535 | '@oxlint-tsgolint/win32-x64@0.17.1': | |
| 536 | resolution: {integrity: sha512-jhv7XktAJ1sMRSb//yDYTauFSZ06H81i2SLEBPaSUKxSKoPMK8p1ACUJlnmwZX2MgapRLEj1Ml22B6+HiM2YIA==} | |
| 534 | 537 | cpu: [x64] |
| 535 | 538 | os: [win32] |
| 536 | 539 | |
| ... | ... | @@ -1809,8 +1812,8 @@ packages: |
| 1809 | 1812 | engines: {node: ^20.19.0 || >=22.12.0} |
| 1810 | 1813 | hasBin: true |
| 1811 | 1814 | |
| 1812 | oxlint-tsgolint@0.17.0: | |
| 1813 | resolution: {integrity: sha512-TdrKhDZCgEYqONFo/j+KvGan7/k3tP5Ouz88wCqpOvJtI2QmcLfGsm1fcMvDnTik48Jj6z83IJBqlkmK9DnY1A==} | |
| 1815 | oxlint-tsgolint@0.17.1: | |
| 1816 | resolution: {integrity: sha512-gJc7hb1ZQFbWjRDYpu1XG+5IRdr1S/Jz/W2ohcpaqIXuDmHU0ujGiM0x05J0nIfwMF3HOEcANi/+j6T0Uecdpg==} | |
| 1814 | 1817 | hasBin: true |
| 1815 | 1818 | |
| 1816 | 1819 | oxlint@1.56.0: |
| ... | ... | @@ -2546,22 +2549,22 @@ snapshots: |
| 2546 | 2549 | '@oxfmt/binding-win32-x64-msvc@0.40.0': |
| 2547 | 2550 | optional: true |
| 2548 | 2551 | |
| 2549 | '@oxlint-tsgolint/darwin-arm64@0.17.0': | |
| 2552 | '@oxlint-tsgolint/darwin-arm64@0.17.1': | |
| 2550 | 2553 | optional: true |
| 2551 | 2554 | |
| 2552 | '@oxlint-tsgolint/darwin-x64@0.17.0': | |
| 2555 | '@oxlint-tsgolint/darwin-x64@0.17.1': | |
| 2553 | 2556 | optional: true |
| 2554 | 2557 | |
| 2555 | '@oxlint-tsgolint/linux-arm64@0.17.0': | |
| 2558 | '@oxlint-tsgolint/linux-arm64@0.17.1': | |
| 2556 | 2559 | optional: true |
| 2557 | 2560 | |
| 2558 | '@oxlint-tsgolint/linux-x64@0.17.0': | |
| 2561 | '@oxlint-tsgolint/linux-x64@0.17.1': | |
| 2559 | 2562 | optional: true |
| 2560 | 2563 | |
| 2561 | '@oxlint-tsgolint/win32-arm64@0.17.0': | |
| 2564 | '@oxlint-tsgolint/win32-arm64@0.17.1': | |
| 2562 | 2565 | optional: true |
| 2563 | 2566 | |
| 2564 | '@oxlint-tsgolint/win32-x64@0.17.0': | |
| 2567 | '@oxlint-tsgolint/win32-x64@0.17.1': | |
| 2565 | 2568 | optional: true |
| 2566 | 2569 | |
| 2567 | 2570 | '@oxlint/binding-android-arm-eabi@1.56.0': |
| ... | ... | @@ -4010,17 +4013,16 @@ snapshots: |
| 4010 | 4013 | '@oxfmt/binding-win32-ia32-msvc': 0.40.0 |
| 4011 | 4014 | '@oxfmt/binding-win32-x64-msvc': 0.40.0 |
| 4012 | 4015 | |
| 4013 | oxlint-tsgolint@0.17.0: | |
| 4016 | oxlint-tsgolint@0.17.1: | |
| 4014 | 4017 | optionalDependencies: |
| 4015 | '@oxlint-tsgolint/darwin-arm64': 0.17.0 | |
| 4016 | '@oxlint-tsgolint/darwin-x64': 0.17.0 | |
| 4017 | '@oxlint-tsgolint/linux-arm64': 0.17.0 | |
| 4018 | '@oxlint-tsgolint/linux-x64': 0.17.0 | |
| 4019 | '@oxlint-tsgolint/win32-arm64': 0.17.0 | |
| 4020 | '@oxlint-tsgolint/win32-x64': 0.17.0 | |
| 4021 | optional: true | |
| 4022 | ||
| 4023 | oxlint@1.56.0(oxlint-tsgolint@0.17.0): | |
| 4018 | '@oxlint-tsgolint/darwin-arm64': 0.17.1 | |
| 4019 | '@oxlint-tsgolint/darwin-x64': 0.17.1 | |
| 4020 | '@oxlint-tsgolint/linux-arm64': 0.17.1 | |
| 4021 | '@oxlint-tsgolint/linux-x64': 0.17.1 | |
| 4022 | '@oxlint-tsgolint/win32-arm64': 0.17.1 | |
| 4023 | '@oxlint-tsgolint/win32-x64': 0.17.1 | |
| 4024 | ||
| 4025 | oxlint@1.56.0(oxlint-tsgolint@0.17.1): | |
| 4024 | 4026 | optionalDependencies: |
| 4025 | 4027 | '@oxlint/binding-android-arm-eabi': 1.56.0 |
| 4026 | 4028 | '@oxlint/binding-android-arm64': 1.56.0 |
| ... | ... | @@ -4041,7 +4043,7 @@ snapshots: |
| 4041 | 4043 | '@oxlint/binding-win32-arm64-msvc': 1.56.0 |
| 4042 | 4044 | '@oxlint/binding-win32-ia32-msvc': 1.56.0 |
| 4043 | 4045 | '@oxlint/binding-win32-x64-msvc': 1.56.0 |
| 4044 | oxlint-tsgolint: 0.17.0 | |
| 4046 | oxlint-tsgolint: 0.17.1 | |
| 4045 | 4047 | |
| 4046 | 4048 | package-manager-detector@1.6.0: {} |
| 4047 | 4049 |
readme.md+1-1| ... | ... | @@ -30,7 +30,7 @@ to Streamdown: |
| 30 | 30 | - **Headless UI**: No built in styles or components, bring your own CSS to blend |
| 31 | 31 | your markdown with your existing theme. `@clo/react-markdown` simply takes in |
| 32 | 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. | |
| 34 | 34 | |
| 35 | 35 | [Streamdown]: https://streamdown.ai |
| 36 | 36 | [unified]: https://unifiedjs.com/ |
src/Predict.ts+100-15| ... | ... | @@ -4,8 +4,8 @@ |
| 4 | 4 | * specifically triggers a fast parsing path. |
| 5 | 5 | */ |
| 6 | 6 | export class Predict { |
| 7 | /** @internal Read-only state indicating the stable string */ | |
| 7 | 8 | #stable = ""; |
| 8 | ||
| 9 | 9 | /** Incrementally reparses the tail. */ |
| 10 | 10 | update(text: string): string { |
| 11 | 11 | if (!text.startsWith(this.#stable)) this.#stable = ""; |
| ... | ... | @@ -266,23 +266,35 @@ function renderTail(tail: string, state: TailState) { |
| 266 | 266 | if (state.pendingHtml !== null) |
| 267 | 267 | tail = tail.slice(0, findPendingHtmlCutoff(tail, state.pendingHtml)); |
| 268 | 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 | 272 | const nestedClosers = renderNestedClosers(state); |
| 271 | if (nestedClosers) return appendCloser(tail, nestedClosers); | |
| 272 | const open = findLastOpen(state); | |
| 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); | |
| 273 | if (nestedClosers) { | |
| 274 | return maybePredictTable(appendCloser(tail, nestedClosers)); | |
| 277 | 275 | } |
| 278 | if (!hasContentAfter(tail, open.start, open.token.length)) { | |
| 279 | return open.kind === "fence" ? tail : tail.slice(0, open.start); | |
| 276 | const open = findLastOpen(state); | |
| 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 | } |
| 287 | 299 | |
| 288 | 300 | function renderOpenLink(tail: string, state: LinkState) { |
| ... | ... | @@ -338,6 +350,51 @@ function isCompleteHtmlTag(tail: string, start: number, end: number) { |
| 338 | 350 | return true; |
| 339 | 351 | } |
| 340 | 352 | |
| 353 | function 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 | ||
| 341 | 398 | function appendCloser(tail: string, closer: string) { |
| 342 | 399 | return tail + closer.slice(overlapLength(tail, closer)); |
| 343 | 400 | } |
| ... | ... | @@ -363,6 +420,34 @@ function findLastOpen(state: TailState) { |
| 363 | 420 | return state.exclusive; |
| 364 | 421 | } |
| 365 | 422 | |
| 423 | function 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 | ||
| 429 | function hasTableHeaderContent(header: string, indent: string) { | |
| 430 | return header.slice(indent.length + 1).trim().length > 0; | |
| 431 | } | |
| 432 | ||
| 433 | function renderTableDelimiter(indent: string, cells: string[]) { | |
| 434 | return indent + "|" + cells.map((cell) => ` ${cell} |`).join(""); | |
| 435 | } | |
| 436 | ||
| 437 | function 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 | ||
| 443 | function 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 | ||
| 366 | 451 | function runLengthAt(text: string, start: number) { |
| 367 | 452 | let end = start + 1; |
| 368 | 453 | while (end < text.length && text[end] === text[start]) end += 1; |
tests/Markdown.memoization.test.tsx deleted-404| ... | ... | @@ -1,404 +0,0 @@ |
| 1 | import { cleanup, render, screen } from "@testing-library/react"; | |
| 2 | import projectReadme from "../README.md?raw"; | |
| 3 | import type { Components } from "rehype-react"; | |
| 4 | import type { ComponentPropsWithoutRef, JSX } from "react"; | |
| 5 | import { useRef } from "react"; | |
| 6 | import remarkGfm from "remark-gfm"; | |
| 7 | import remarkParse from "remark-parse"; | |
| 8 | import { unified } from "unified"; | |
| 9 | import { afterEach, expect, it, vi } from "vitest"; | |
| 10 | import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts"; | |
| 11 | import { type BaseProcessor, Memoizer } from "../src/Memoizer.ts"; | |
| 12 | ||
| 13 | type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & { | |
| 14 | node?: unknown; | |
| 15 | }; | |
| 16 | ||
| 17 | const renderOptions = { reactStrictMode: false } as const; | |
| 18 | const strictRenderOptions = { reactStrictMode: true } as const; | |
| 19 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; | |
| 20 | ||
| 21 | afterEach(() => { | |
| 22 | cleanup(); | |
| 23 | vi.restoreAllMocks(); | |
| 24 | }); | |
| 25 | ||
| 26 | function omitNode<Props extends { node?: unknown }>(props: Props): Omit<Props, "node"> { | |
| 27 | const { node, ...rest } = props; | |
| 28 | void node; | |
| 29 | return rest; | |
| 30 | } | |
| 31 | ||
| 32 | it("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 | ||
| 58 | it("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 | ||
| 90 | it("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 | ||
| 117 | it("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 | ||
| 139 | it("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 | ||
| 160 | it("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 | ||
| 181 | it("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 | ||
| 202 | it("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 | ||
| 233 | it("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 | ||
| 259 | it("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 | ||
| 274 | it.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 | ||
| 318 | it("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 | ||
| 344 | it("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 | ||
| 364 | it("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 | ||
| 392 | function 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 @@ |
| 1 | import { cleanup, render, screen } from "@testing-library/react"; | |
| 2 | import projectReadme from "../readme.md?raw"; | |
| 3 | import type { Components } from "rehype-react"; | |
| 4 | import type { ComponentPropsWithoutRef, JSX } from "react"; | |
| 5 | import { useRef } from "react"; | |
| 6 | import remarkGfm from "remark-gfm"; | |
| 7 | import remarkParse from "remark-parse"; | |
| 8 | import { unified } from "unified"; | |
| 9 | import { afterEach, expect, it, vi } from "vitest"; | |
| 10 | import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts"; | |
| 11 | import { type BaseProcessor, Memoizer } from "../src/Memoizer.ts"; | |
| 12 | ||
| 13 | type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & { | |
| 14 | node?: unknown; | |
| 15 | }; | |
| 16 | ||
| 17 | const renderOptions = { reactStrictMode: false } as const; | |
| 18 | const strictRenderOptions = { reactStrictMode: true } as const; | |
| 19 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; | |
| 20 | ||
| 21 | afterEach(() => { | |
| 22 | cleanup(); | |
| 23 | vi.restoreAllMocks(); | |
| 24 | }); | |
| 25 | ||
| 26 | function omitNode<Props extends { node?: unknown }>(props: Props): Omit<Props, "node"> { | |
| 27 | const { node, ...rest } = props; | |
| 28 | void node; | |
| 29 | return rest; | |
| 30 | } | |
| 31 | ||
| 32 | it("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 | ||
| 58 | it("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 | ||
| 90 | it("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 | ||
| 117 | it("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 | ||
| 139 | it("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 | ||
| 160 | it("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 | ||
| 181 | it("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 | ||
| 202 | it("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 | ||
| 233 | it("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 | ||
| 259 | it("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 | ||
| 274 | it.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 | ||
| 318 | it("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 | ||
| 344 | it("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 | ||
| 364 | it("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 | ||
| 392 | function 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 | 1 | import type { ReactNode } from "react"; |
| 2 | 2 | import { Fragment } from "react"; |
| 3 | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import projectReadme from "../README.md?raw"; | |
| 4 | import projectReadme from "../readme.md?raw"; | |
| 5 | 5 | import remarkGfm from "remark-gfm"; |
| 6 | 6 | import remarkParse from "remark-parse"; |
| 7 | 7 | import { unified } from "unified"; |
tests/memoizedHastToReact.test.tsx+3-3| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import type { Element, ElementContent, Properties, Root, RootContent, Text } from "hast"; |
| 2 | 2 | import type { Components } from "rehype-react"; |
| 3 | 3 | import type { ReactElement, ReactNode } from "react"; |
| 4 | import projectReadme from "../README.md?raw"; | |
| 4 | import projectReadme from "../readme.md?raw"; | |
| 5 | 5 | import remarkParse from "remark-parse"; |
| 6 | 6 | import remarkRehype from "remark-rehype"; |
| 7 | 7 | import remarkGfm from "remark-gfm"; |
| ... | ... | @@ -225,7 +225,7 @@ it("uses configured components for matching tags", () => { |
| 225 | 225 | expect(childrenOf(link)).toEqual(["hello"]); |
| 226 | 226 | }); |
| 227 | 227 | |
| 228 | it("reuses the README list block when an earlier block changes", () => { | |
| 228 | it("reuses the readme list block when an earlier block changes", () => { | |
| 229 | 229 | const original = projectReadme; |
| 230 | 230 | const edited = projectReadme.replace( |
| 231 | 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 | 252 | expect(second.state).toBe(first.state); |
| 253 | 253 | }); |
| 254 | 254 | |
| 255 | it("reuses the README list block with a custom strong component when an earlier block changes", () => { | |
| 255 | it("reuses the readme list block with a custom strong component when an earlier block changes", () => { | |
| 256 | 256 | function Strong(props: { children?: ReactNode }) { |
| 257 | 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 | 223 | ])("%s", (_name, input, expected) => check(input, expected)); |
| 224 | 224 | }); |
| 225 | 225 | |
| 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 | 267 | describe("code span variants", () => { |
| 227 | 268 | test.each([ |
| 228 | 269 | ["double backtick code span", "a ``hello world", "a ``hello world``"], |