diff --git a/deno.json b/deno.json index 51e5196f08a7295214d87bf6a87d3c528639d86a..02fa6e43952875cdbbc56e3c7cf640db63573ca8 100644 --- a/deno.json +++ b/deno.json @@ -1,11 +1,11 @@ { "name": "@clo/react-markdown", - "version": "1.0.0", + "version": "1.1.0", "exports": { ".": "./src/Markdown.ts", "./Predict": "./src/Predict.ts" }, "publish": { - "include": ["README.md", "src/**/*.ts", "tests/**/*.ts", "tests/**/*.tsx"] + "include": ["readme.md", "readme.changes.md", "src/**/*.ts", "tests/**/*.ts", "tests/**/*.tsx"] } } diff --git a/example/markdown-demo.ts b/example/markdown-demo.ts index 747e5cb56da01a01f4bc18e89494d6b8df7b18cd..52b0704f64c7119ad30cb3244734004f3ae31843 100644 --- a/example/markdown-demo.ts +++ b/example/markdown-demo.ts @@ -1,7 +1,7 @@ import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import { type PluggableList, unified } from "unified"; -import projectReadme from "../README.md?raw"; +import projectReadme from "../readme.md?raw"; export const initialMarkdown = projectReadme; export const processor = unified().use(remarkParse).use(remarkGfm); diff --git a/package.json b/package.json index e6737ab9236040db01c2c53e6e9859c5012999e9..2d427cb9b0e1f1f46615c5b9987d45d0afee85fe 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "happy-dom": "^20.8.4", "oxfmt": "^0.40.0", "oxlint": "^1.55.0", + "oxlint-tsgolint": "^0.17.1", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", "react-scan": "^0.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8e612f45a64967e09f299c8204e9af2435e80ab..29771503c2dfc3a53fe1b0f317438adc8452d1c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,7 +68,10 @@ importers: version: 0.40.0 oxlint: specifier: ^1.55.0 - version: 1.56.0(oxlint-tsgolint@0.17.0) + version: 1.56.0(oxlint-tsgolint@0.17.1) + oxlint-tsgolint: + specifier: ^0.17.1 + version: 0.17.1 react-dom: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) @@ -504,33 +507,33 @@ packages: cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.17.0': - resolution: {integrity: sha512-z3XwCDuOAKgk7bO4y5tyH8Zogwr51G56R0XGKC3tlAbrAq8DecoxAd3qhRZqWBMG2Gzl5bWU3Ghu7lrxuLPzYw==} + '@oxlint-tsgolint/darwin-arm64@0.17.1': + resolution: {integrity: sha512-JNWNwyvSDcUQSBlQRl10XrCeNcN66TMvDw3gIDQeop5SNa1F7wFhsEx4zitYb7fGHwGh9095tsNttmuCaNXCbw==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.17.0': - resolution: {integrity: sha512-TZgVXy0MtI8nt0MYiceuZhHPwHcwlIZ/YwzFTAKrgdHiTvVzFbqHVdXi5wbZfT/o1nHGw9fbGWPlb6qKZ4uZ9Q==} + '@oxlint-tsgolint/darwin-x64@0.17.1': + resolution: {integrity: sha512-SluNf6CW88pgGPqQUGC5GoK5qESWo2ct1PRDbza3vbf9SK2npx3igvylGQIgE9qYYOcjgnVdLOJ0+q0gItgUmQ==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.17.0': - resolution: {integrity: sha512-IDfhFl/Y8bjidCvAP6QAxVyBsl78TmfCHlfjtEv2XtJXgYmIwzv6muO18XMp74SZ2qAyD4y2n2dUedrmghGHeA==} + '@oxlint-tsgolint/linux-arm64@0.17.1': + resolution: {integrity: sha512-BJxQ7/cdo2dNdGIBs2PIR6BaPA7cPfe+r1HE/uY+K7g2ygip+0LHB3GUO9GaNDZuWpsnDyjLYYowEGrVK8dokA==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.17.0': - resolution: {integrity: sha512-Bgdgqx/m8EnfjmmlRLEeYy9Yhdt1GdFrMr5mTu/NyLRGkB1C9VLAikdxB7U9QambAGTAmjMbHNFDFk8Vx69Huw==} + '@oxlint-tsgolint/linux-x64@0.17.1': + resolution: {integrity: sha512-s6UjmuaJbZ4zz/wJKdEw/s5mc0t41rgwxQJCSHPuzMumMK6ylrB7nydhDf8ObTtzhTIZdAS/2S/uayJmDcGbxw==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.17.0': - resolution: {integrity: sha512-dO6wyKMDqFWh1vwr+zNZS7/ovlfGgl4S3P1LDy4CKjP6V6NGtdmEwWkWax8j/I8RzGZdfXKnoUfb/qhVg5bx0w==} + '@oxlint-tsgolint/win32-arm64@0.17.1': + resolution: {integrity: sha512-EO/Oj0ixHX+UQdu9hM7YUzibZI888MvPUo/DF8lSxFBt4JNEt8qGkwJEbCYjB/1LhUNmPHzSw2Tr9dCFVfW9nw==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.17.0': - resolution: {integrity: sha512-lPGYFp3yX2nh6hLTpIuMnJbZnt3Df42VkoA/fSkMYi2a/LXdDytQGpgZOrb5j47TICARd34RauKm0P3OA4Oxbw==} + '@oxlint-tsgolint/win32-x64@0.17.1': + resolution: {integrity: sha512-jhv7XktAJ1sMRSb//yDYTauFSZ06H81i2SLEBPaSUKxSKoPMK8p1ACUJlnmwZX2MgapRLEj1Ml22B6+HiM2YIA==} cpu: [x64] os: [win32] @@ -1809,8 +1812,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint-tsgolint@0.17.0: - resolution: {integrity: sha512-TdrKhDZCgEYqONFo/j+KvGan7/k3tP5Ouz88wCqpOvJtI2QmcLfGsm1fcMvDnTik48Jj6z83IJBqlkmK9DnY1A==} + oxlint-tsgolint@0.17.1: + resolution: {integrity: sha512-gJc7hb1ZQFbWjRDYpu1XG+5IRdr1S/Jz/W2ohcpaqIXuDmHU0ujGiM0x05J0nIfwMF3HOEcANi/+j6T0Uecdpg==} hasBin: true oxlint@1.56.0: @@ -2546,22 +2549,22 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.40.0': optional: true - '@oxlint-tsgolint/darwin-arm64@0.17.0': + '@oxlint-tsgolint/darwin-arm64@0.17.1': optional: true - '@oxlint-tsgolint/darwin-x64@0.17.0': + '@oxlint-tsgolint/darwin-x64@0.17.1': optional: true - '@oxlint-tsgolint/linux-arm64@0.17.0': + '@oxlint-tsgolint/linux-arm64@0.17.1': optional: true - '@oxlint-tsgolint/linux-x64@0.17.0': + '@oxlint-tsgolint/linux-x64@0.17.1': optional: true - '@oxlint-tsgolint/win32-arm64@0.17.0': + '@oxlint-tsgolint/win32-arm64@0.17.1': optional: true - '@oxlint-tsgolint/win32-x64@0.17.0': + '@oxlint-tsgolint/win32-x64@0.17.1': optional: true '@oxlint/binding-android-arm-eabi@1.56.0': @@ -4010,17 +4013,16 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.40.0 '@oxfmt/binding-win32-x64-msvc': 0.40.0 - oxlint-tsgolint@0.17.0: + oxlint-tsgolint@0.17.1: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.17.0 - '@oxlint-tsgolint/darwin-x64': 0.17.0 - '@oxlint-tsgolint/linux-arm64': 0.17.0 - '@oxlint-tsgolint/linux-x64': 0.17.0 - '@oxlint-tsgolint/win32-arm64': 0.17.0 - '@oxlint-tsgolint/win32-x64': 0.17.0 - optional: true + '@oxlint-tsgolint/darwin-arm64': 0.17.1 + '@oxlint-tsgolint/darwin-x64': 0.17.1 + '@oxlint-tsgolint/linux-arm64': 0.17.1 + '@oxlint-tsgolint/linux-x64': 0.17.1 + '@oxlint-tsgolint/win32-arm64': 0.17.1 + '@oxlint-tsgolint/win32-x64': 0.17.1 - oxlint@1.56.0(oxlint-tsgolint@0.17.0): + oxlint@1.56.0(oxlint-tsgolint@0.17.1): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.56.0 '@oxlint/binding-android-arm64': 1.56.0 @@ -4041,7 +4043,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.56.0 '@oxlint/binding-win32-ia32-msvc': 1.56.0 '@oxlint/binding-win32-x64-msvc': 1.56.0 - oxlint-tsgolint: 0.17.0 + oxlint-tsgolint: 0.17.1 package-manager-detector@1.6.0: {} diff --git a/readme.md b/readme.md index 86e4c00547a42156b147e6b50aa245b9f371d276..81e7559102638071c610f0fd9d8add37f27aa013 100644 --- a/readme.md +++ b/readme.md @@ -30,7 +30,7 @@ to Streamdown: - **Headless UI**: No built in styles or components, bring your own CSS to blend your markdown with your existing theme. `@clo/react-markdown` simply takes in your existing `unified` pipeline and works off of that. -- **Easy to Audit**: Only ~1000 lines of modern TypeScript. 4 total files. +- **Easy to Audit**: Only ~1000 lines of modern TypeScript. 4 source files. [Streamdown]: https://streamdown.ai [unified]: https://unifiedjs.com/ diff --git a/src/Predict.ts b/src/Predict.ts index 61858d873b246a6d6da7031d8e88dbade574425d..79451ef383b9291fbafe2393bcd4ab56269ddaa0 100644 --- a/src/Predict.ts +++ b/src/Predict.ts @@ -4,8 +4,8 @@ * specifically triggers a fast parsing path. */ export class Predict { + /** @internal Read-only state indicating the stable string */ #stable = ""; - /** Incrementally reparses the tail. */ update(text: string): string { if (!text.startsWith(this.#stable)) this.#stable = ""; @@ -266,23 +266,35 @@ function renderTail(tail: string, state: TailState) { if (state.pendingHtml !== null) tail = tail.slice(0, findPendingHtmlCutoff(tail, state.pendingHtml)); const link = state.links.at(-1); - if (link) return renderOpenLink(tail, link); + if (link) { + return maybePredictTable(renderOpenLink(tail, link)); + } const nestedClosers = renderNestedClosers(state); - if (nestedClosers) return appendCloser(tail, nestedClosers); + if (nestedClosers) { + return maybePredictTable(appendCloser(tail, nestedClosers)); + } const open = findLastOpen(state); - if (!open) return state.pendingDelim ? tail.slice(0, state.pendingDelim.start) : tail; - if (open.kind === "delim") { - if (!hasContentAfter(tail, open.start, open.token.length)) return tail.slice(0, open.start); - return appendCloserBeforeTrailingInlineWhitespace(tail, open.token); + if (!open) { + return maybePredictTable(state.pendingDelim ? tail.slice(0, state.pendingDelim.start) : tail); + } else if (open.kind === "delim") { + return maybePredictTable( + hasContentAfter(tail, open.start, open.token.length) + ? appendCloserBeforeTrailingInlineWhitespace(tail, open.token) + : tail.slice(0, open.start), + ); + } else if (!hasContentAfter(tail, open.start, open.token.length)) { + return maybePredictTable(open.kind === "fence" ? tail : tail.slice(0, open.start)); + } else if (open.kind === "fence") { + return maybePredictTable(tail); + } else if (open.kind === "code") { + return maybePredictTable(appendCloser(tail, open.token)); + } else if (open.token === "$$") { + return maybePredictTable(appendCloser(tail, (tail.endsWith("\n") ? "" : "\n") + "$$")); + } else if (/\s/.test(tail[tail.length - 1] ?? "")) { + return maybePredictTable(tail); + } else { + return maybePredictTable(appendCloser(tail, "$")); } - if (!hasContentAfter(tail, open.start, open.token.length)) { - return open.kind === "fence" ? tail : tail.slice(0, open.start); - } - if (open.kind === "fence") return tail; - if (open.kind === "code") return appendCloser(tail, open.token); - if (open.token === "$$") return appendCloser(tail, (tail.endsWith("\n") ? "" : "\n") + "$$"); - if (/\s/.test(tail[tail.length - 1] ?? "")) return tail; - return appendCloser(tail, "$"); } function renderOpenLink(tail: string, state: LinkState) { @@ -338,6 +350,51 @@ function isCompleteHtmlTag(tail: string, start: number, end: number) { return true; } +function maybePredictTable(text: string) { + const lastBlankLine = text.lastIndexOf("\n\n"); + const blockStart = lastBlankLine === -1 ? 0 : lastBlankLine + 2; + const before = text.slice(0, blockStart); + const block = text.slice(blockStart); + const newline = block.indexOf("\n"); + const header = newline === -1 ? block : block.slice(0, newline); + const indent = header.match(/^( *)\|/)?.[1]; + if (indent === undefined) return text; + if (countPipes(header) < 2 && !hasTableHeaderContent(header, indent)) return before; + const fullHeader = header.trimEnd().endsWith("|") + ? header + : appendCloserBeforeTrailingInlineWhitespace(header, " |"); + const pipeCount = countPipes(fullHeader); + const columns = + pipeCount < 2 ? 0 : fullHeader.trimEnd().endsWith("|") ? pipeCount - 1 : pipeCount; + if (columns === 0) return text; + const rest = newline === -1 ? "" : block.slice(newline + 1); + const fullDelimiter = renderTableDelimiter( + indent, + Array.from({ length: columns }, () => "-"), + ); + if (rest.length === 0) return before + fullHeader + "\n" + fullDelimiter; + const delimiterEnd = rest.indexOf("\n"); + const delimiter = delimiterEnd === -1 ? rest : rest.slice(0, delimiterEnd); + const afterDelimiter = delimiterEnd === -1 ? "" : rest.slice(delimiterEnd); + if (isCompleteTableDelimiter(delimiter, indent, columns)) return text; + if (delimiter.startsWith(indent + "|") && /^[ |:\-\t]*$/.test(delimiter.slice(indent.length))) { + const cells = parseTableDelimiterCells(delimiter, indent).map((cell) => { + const trimmed = cell.trim(); + if (trimmed.length === 0) return "-"; + let hyphenCount = 0; + for (let i = 0; i < trimmed.length; i++) if (trimmed.charCodeAt(i) === 0x2d) hyphenCount++; + return ( + (trimmed.startsWith(":") ? ":" : "") + + "-".repeat(Math.max(1, hyphenCount)) + + (trimmed.length > 1 && trimmed.endsWith(":") ? ":" : "") + ); + }); + while (cells.length < columns) cells.push("-"); + return before + fullHeader + "\n" + renderTableDelimiter(indent, cells) + afterDelimiter; + } + return before + fullHeader + "\n" + fullDelimiter + "\n" + rest; +} + function appendCloser(tail: string, closer: string) { return tail + closer.slice(overlapLength(tail, closer)); } @@ -363,6 +420,34 @@ function findLastOpen(state: TailState) { return state.exclusive; } +function countPipes(text: string) { + let count = 0; + for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 0x7c) count++; + return count; +} + +function hasTableHeaderContent(header: string, indent: string) { + return header.slice(indent.length + 1).trim().length > 0; +} + +function renderTableDelimiter(indent: string, cells: string[]) { + return indent + "|" + cells.map((cell) => ` ${cell} |`).join(""); +} + +function parseTableDelimiterCells(line: string, indent: string) { + const cells = line.slice(indent.length + 1).split("|"); + if (line.trimEnd().endsWith("|")) cells.pop(); + return cells; +} + +function isCompleteTableDelimiter(line: string, indent: string, columns: number) { + if (!line.startsWith(indent)) return false; + const trimmed = line.slice(indent.length).trim(); + if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false; + const cells = parseTableDelimiterCells(trimmed, "").map((cell) => cell.trim()); + return cells.length === columns && cells.every((cell) => /^:?-+:?$/.test(cell)); +} + function runLengthAt(text: string, start: number) { let end = start + 1; while (end < text.length && text[end] === text[start]) end += 1; diff --git a/tests/Markdown.memoization.test.tsx b/tests/Markdown.memoization.test.tsx deleted file mode 100644 index ac81e801ac4e6466e6d16f9a507b7bc89374a6c9..0000000000000000000000000000000000000000 --- a/tests/Markdown.memoization.test.tsx +++ /dev/null @@ -1,404 +0,0 @@ -import { cleanup, render, screen } from "@testing-library/react"; -import projectReadme from "../README.md?raw"; -import type { Components } from "rehype-react"; -import type { ComponentPropsWithoutRef, JSX } from "react"; -import { useRef } from "react"; -import remarkGfm from "remark-gfm"; -import remarkParse from "remark-parse"; -import { unified } from "unified"; -import { afterEach, expect, it, vi } from "vitest"; -import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts"; -import { type BaseProcessor, Memoizer } from "../src/Memoizer.ts"; - -type MarkdownProps = ComponentPropsWithoutRef & { - node?: unknown; -}; - -const renderOptions = { reactStrictMode: false } as const; -const strictRenderOptions = { reactStrictMode: true } as const; -const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; - -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); -}); - -function omitNode(props: Props): Omit { - const { node, ...rest } = props; - void node; - return rest; -} - -it("skips Markdown rerenders for semantically equal props, then wakes back up when content changes", () => { - const updateSpy = vi.spyOn(Memoizer.prototype, "update"); - const Paragraph = vi.fn(function Paragraph(props: MarkdownProps<"p">) { - return

; - }); - const components = { p: Paragraph } satisfies Partial; - - const { rerender } = render(, renderOptions); - - expect(updateSpy).toHaveBeenCalledTimes(1); - expect(Paragraph).toHaveBeenCalledTimes(1); - - rerender(); - rerender(); - - expect(updateSpy).toHaveBeenCalledTimes(1); - expect(Paragraph).toHaveBeenCalledTimes(1); - expect(screen.getByTestId("paragraph").textContent).toBe("hello"); - - rerender(); - - expect(updateSpy).toHaveBeenCalledTimes(2); - expect(Paragraph).toHaveBeenCalledTimes(2); - expect(screen.getByTestId("paragraph").textContent).toBe("hello!"); -}); - -it("keeps an unchanged inline custom renderer asleep when only another block changes", () => { - const Link = vi.fn(function Link(props: MarkdownProps<"a">) { - return ; - }); - const components = { a: Link } satisfies Partial; - - const { rerender } = render( - , - renderOptions, - ); - - const link = screen.getByTestId("link"); - expect(Link).toHaveBeenCalledTimes(1); - - rerender( - , - ); - rerender( - , - ); - - expect(Link).toHaveBeenCalledTimes(1); - expect(screen.getByTestId("link")).toBe(link); - expect(screen.getByTestId("link").textContent).toBe("stable"); -}); - -it("does not rerender an unchanged inline custom renderer when only adjacent text changes", () => { - const Link = vi.fn(function Link(props: MarkdownProps<"a">) { - return ; - }); - const components = { a: Link } satisfies Partial; - - const { rerender } = render( - , - renderOptions, - ); - - const link = screen.getByTestId("link"); - expect(Link).toHaveBeenCalledTimes(1); - - rerender( - , - ); - expect(screen.getByTestId("link")).toBe(link); - expect(Link).toHaveBeenCalledTimes(1); - - rerender( - , - ); - expect(screen.getByTestId("link")).toBe(link); - expect(Link).toHaveBeenCalledTimes(1); -}); - -it("does not rerender an unchanged inline custom renderer with formatted children when only adjacent text changes", () => { - const Link = vi.fn(function Link(props: MarkdownProps<"a">) { - return ; - }); - const components = { a: Link } satisfies Partial; - - const { rerender } = render( - , - renderOptions, - ); - - const link = screen.getByTestId("rich-link"); - expect(Link).toHaveBeenCalledTimes(1); - - rerender( - , - ); - - expect(screen.getByTestId("rich-link")).toBe(link); - expect(Link).toHaveBeenCalledTimes(1); -}); - -it("keeps an unchanged strong renderer asleep when surrounding paragraph text changes", () => { - const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { - return ; - }); - const components = { strong: Strong } satisfies Partial; - - const { rerender } = render( - , - renderOptions, - ); - - const strong = screen.getByTestId("strong"); - expect(Strong).toHaveBeenCalledTimes(1); - - rerender(); - rerender(); - - expect(screen.getByTestId("strong")).toBe(strong); - expect(Strong).toHaveBeenCalledTimes(1); -}); - -it("keeps an unchanged strong renderer asleep under Strict Mode when surrounding text changes", () => { - const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { - return ; - }); - const components = { strong: Strong } satisfies Partial; - - const { rerender } = render( - , - strictRenderOptions, - ); - - const strong = screen.getByTestId("strict-strong"); - const initialCalls = Strong.mock.calls.length; - - rerender(); - rerender(); - - expect(screen.getByTestId("strict-strong")).toBe(strong); - expect(Strong.mock.calls.length).toBe(initialCalls); -}); - -it("keeps an unchanged list item renderer asleep when a sibling item changes", () => { - const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) { - return

  • ; - }); - const components = { li: ListItem } satisfies Partial; - - const { rerender } = render( - , - renderOptions, - ); - - const stableItem = screen.getByText("stable").closest("li"); - expect(stableItem).not.toBeNull(); - expect(ListItem).toHaveBeenCalledTimes(3); - - rerender(); - - expect(screen.getByText("stable").closest("li")).toBe(stableItem); - expect(ListItem).toHaveBeenCalledTimes(4); -}); - -it("keeps an unchanged table cell renderer asleep when another cell changes", () => { - const TableCell = vi.fn(function TableCell(props: MarkdownProps<"td">) { - return ; - }); - const components = { td: TableCell } satisfies Partial; - - const { rerender } = render( - , - renderOptions, - ); - - const stableCell = screen.getByText("keep").closest("td"); - expect(stableCell).not.toBeNull(); - expect(TableCell).toHaveBeenCalledTimes(2); - - rerender( - , - ); - - expect(screen.getByText("keep").closest("td")).toBe(stableCell); - expect(TableCell).toHaveBeenCalledTimes(3); -}); - -it("does not rerender Markdown through the provider when the effective components stay the same", () => { - const updateSpy = vi.spyOn(Memoizer.prototype, "update"); - const Link = vi.fn(function Link(props: MarkdownProps<"a">) { - return ; - }); - - function WrappedMarkdown({ components }: { components: Partial }) { - return ( - - - - ); - } - - const { rerender } = render(, renderOptions); - - expect(updateSpy).toHaveBeenCalledTimes(1); - expect(Link).toHaveBeenCalledTimes(1); - - rerender(); - - expect(updateSpy).toHaveBeenCalledTimes(1); - expect(Link).toHaveBeenCalledTimes(1); - expect(screen.getByTestId("provider-link").textContent).toBe("stable"); -}); - -it("reprocesses the document when prediction changes", () => { - const { container, rerender } = render( - , - renderOptions, - ); - - expect(container.querySelector("em")).toBeNull(); - expect(container.textContent).toBe("hello *world"); - - rerender(); - - expect(container.querySelector("em")?.textContent).toBe("world"); - expect(container.textContent).toBe("hello world"); -}); - -it.each([ - { - content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"], - name: "paragraph merging edits", - predict: false, - processor: undefined, - }, - { - content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]"], - name: "reference link definition edits", - predict: false, - processor: undefined, - }, - { - content: [ - "| A | B |\n| --- | --- |\n| one | two |", - "| A | B |\n| --- | --- |\n| one! | two |", - "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |", - ], - name: "gfm table edits", - predict: false, - processor: gfmProcessor, - }, - { - content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"], - name: "prediction edits", - predict: true, - processor: undefined, - }, -])("matches a fresh render across $name", ({ content, predict, processor }) => { - const first = content[0]!; - const { container, rerender } = render( - , - renderOptions, - ); - - expect(container.innerHTML).toBe(renderFreshHtml(first, predict, processor)); - - for (const step of content.slice(1)) { - rerender(); - expect(container.innerHTML).toBe(renderFreshHtml(step, predict, processor)); - } -}); - -it("keeps a stable block tied to its own DOM node when blocks are inserted or removed above it", () => { - function Link(props: MarkdownProps<"a">) { - const instanceRef = useRef(Symbol("instance")); - return ; - } - - const components = { a: Link } satisfies Partial; - const { rerender } = render( - , - renderOptions, - ); - - const stableLink = screen.getByRole("link"); - - rerender( - , - ); - expect(screen.getByRole("link")).toBe(stableLink); - - rerender(); - expect(screen.getByRole("link")).toBe(stableLink); -}); - -it("keeps an unchanged list item tied to its DOM node when a sibling item is inserted above it", () => { - function ListItem(props: MarkdownProps<"li">) { - const instanceRef = useRef(Symbol("instance")); - return
  • ; - } - - const components = { li: ListItem } satisfies Partial; - const { rerender } = render( - , - renderOptions, - ); - - const stableItem = screen.getByText("stable").closest("li"); - expect(stableItem).not.toBeNull(); - - rerender(); - - expect(screen.getByText("stable").closest("li")).toBe(stableItem); -}); - -it("keeps demo README strong renderers asleep when editing a different block", () => { - const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { - return ; - }); - const components = { strong: Strong } satisfies Partial; - - const original = projectReadme; - const edited = projectReadme.replace( - "This package exports a React component to render Markdown using the [unified]", - "This package exports a React component to render memoized Markdown using the [unified]", - ); - - const { rerender } = render( - , - renderOptions, - ); - - const stableStrong = screen.getByText("Bring your Existing Pipeline").closest("strong"); - expect(stableStrong).not.toBeNull(); - - const strongCalls = Strong.mock.calls.length; - - rerender(); - - expect(screen.getByText("Bring your Existing Pipeline").closest("strong")).toBe(stableStrong); - expect(Strong.mock.calls.length).toBe(strongCalls); -}); - -function renderFreshHtml( - content: string, - predict: boolean | undefined, - processor: BaseProcessor | undefined, -) { - const { container, unmount } = render( - , - renderOptions, - ); - const html = container.innerHTML; - unmount(); - return html; -} diff --git a/tests/Markdown.test.tsx b/tests/Markdown.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1c65ac8ec8175e4881fa5c9ffee3bcb34394901f --- /dev/null +++ b/tests/Markdown.test.tsx @@ -0,0 +1,404 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import projectReadme from "../readme.md?raw"; +import type { Components } from "rehype-react"; +import type { ComponentPropsWithoutRef, JSX } from "react"; +import { useRef } from "react"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { afterEach, expect, it, vi } from "vitest"; +import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts"; +import { type BaseProcessor, Memoizer } from "../src/Memoizer.ts"; + +type MarkdownProps = ComponentPropsWithoutRef & { + node?: unknown; +}; + +const renderOptions = { reactStrictMode: false } as const; +const strictRenderOptions = { reactStrictMode: true } as const; +const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function omitNode(props: Props): Omit { + const { node, ...rest } = props; + void node; + return rest; +} + +it("skips Markdown rerenders for semantically equal props, then wakes back up when content changes", () => { + const updateSpy = vi.spyOn(Memoizer.prototype, "update"); + const Paragraph = vi.fn(function Paragraph(props: MarkdownProps<"p">) { + return

    ; + }); + const components = { p: Paragraph } satisfies Partial; + + const { rerender } = render(, renderOptions); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(Paragraph).toHaveBeenCalledTimes(1); + + rerender(); + rerender(); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(Paragraph).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("paragraph").textContent).toBe("hello"); + + rerender(); + + expect(updateSpy).toHaveBeenCalledTimes(2); + expect(Paragraph).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("paragraph").textContent).toBe("hello!"); +}); + +it("keeps an unchanged inline custom renderer asleep when only another block changes", () => { + const Link = vi.fn(function Link(props: MarkdownProps<"a">) { + return ; + }); + const components = { a: Link } satisfies Partial; + + const { rerender } = render( + , + renderOptions, + ); + + const link = screen.getByTestId("link"); + expect(Link).toHaveBeenCalledTimes(1); + + rerender( + , + ); + rerender( + , + ); + + expect(Link).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("link")).toBe(link); + expect(screen.getByTestId("link").textContent).toBe("stable"); +}); + +it("does not rerender an unchanged inline custom renderer when only adjacent text changes", () => { + const Link = vi.fn(function Link(props: MarkdownProps<"a">) { + return ; + }); + const components = { a: Link } satisfies Partial; + + const { rerender } = render( + , + renderOptions, + ); + + const link = screen.getByTestId("link"); + expect(Link).toHaveBeenCalledTimes(1); + + rerender( + , + ); + expect(screen.getByTestId("link")).toBe(link); + expect(Link).toHaveBeenCalledTimes(1); + + rerender( + , + ); + expect(screen.getByTestId("link")).toBe(link); + expect(Link).toHaveBeenCalledTimes(1); +}); + +it("does not rerender an unchanged inline custom renderer with formatted children when only adjacent text changes", () => { + const Link = vi.fn(function Link(props: MarkdownProps<"a">) { + return ; + }); + const components = { a: Link } satisfies Partial; + + const { rerender } = render( + , + renderOptions, + ); + + const link = screen.getByTestId("rich-link"); + expect(Link).toHaveBeenCalledTimes(1); + + rerender( + , + ); + + expect(screen.getByTestId("rich-link")).toBe(link); + expect(Link).toHaveBeenCalledTimes(1); +}); + +it("keeps an unchanged strong renderer asleep when surrounding paragraph text changes", () => { + const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { + return ; + }); + const components = { strong: Strong } satisfies Partial; + + const { rerender } = render( + , + renderOptions, + ); + + const strong = screen.getByTestId("strong"); + expect(Strong).toHaveBeenCalledTimes(1); + + rerender(); + rerender(); + + expect(screen.getByTestId("strong")).toBe(strong); + expect(Strong).toHaveBeenCalledTimes(1); +}); + +it("keeps an unchanged strong renderer asleep under Strict Mode when surrounding text changes", () => { + const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { + return ; + }); + const components = { strong: Strong } satisfies Partial; + + const { rerender } = render( + , + strictRenderOptions, + ); + + const strong = screen.getByTestId("strict-strong"); + const initialCalls = Strong.mock.calls.length; + + rerender(); + rerender(); + + expect(screen.getByTestId("strict-strong")).toBe(strong); + expect(Strong.mock.calls.length).toBe(initialCalls); +}); + +it("keeps an unchanged list item renderer asleep when a sibling item changes", () => { + const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) { + return

  • ; + }); + const components = { li: ListItem } satisfies Partial; + + const { rerender } = render( + , + renderOptions, + ); + + const stableItem = screen.getByText("stable").closest("li"); + expect(stableItem).not.toBeNull(); + expect(ListItem).toHaveBeenCalledTimes(3); + + rerender(); + + expect(screen.getByText("stable").closest("li")).toBe(stableItem); + expect(ListItem).toHaveBeenCalledTimes(4); +}); + +it("keeps an unchanged table cell renderer asleep when another cell changes", () => { + const TableCell = vi.fn(function TableCell(props: MarkdownProps<"td">) { + return ; + }); + const components = { td: TableCell } satisfies Partial; + + const { rerender } = render( + , + renderOptions, + ); + + const stableCell = screen.getByText("keep").closest("td"); + expect(stableCell).not.toBeNull(); + expect(TableCell).toHaveBeenCalledTimes(2); + + rerender( + , + ); + + expect(screen.getByText("keep").closest("td")).toBe(stableCell); + expect(TableCell).toHaveBeenCalledTimes(3); +}); + +it("does not rerender Markdown through the provider when the effective components stay the same", () => { + const updateSpy = vi.spyOn(Memoizer.prototype, "update"); + const Link = vi.fn(function Link(props: MarkdownProps<"a">) { + return ; + }); + + function WrappedMarkdown({ components }: { components: Partial }) { + return ( + + + + ); + } + + const { rerender } = render(, renderOptions); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(Link).toHaveBeenCalledTimes(1); + + rerender(); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(Link).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("provider-link").textContent).toBe("stable"); +}); + +it("reprocesses the document when prediction changes", () => { + const { container, rerender } = render( + , + renderOptions, + ); + + expect(container.querySelector("em")).toBeNull(); + expect(container.textContent).toBe("hello *world"); + + rerender(); + + expect(container.querySelector("em")?.textContent).toBe("world"); + expect(container.textContent).toBe("hello world"); +}); + +it.each([ + { + content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"], + name: "paragraph merging edits", + predict: false, + processor: undefined, + }, + { + content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]"], + name: "reference link definition edits", + predict: false, + processor: undefined, + }, + { + content: [ + "| A | B |\n| --- | --- |\n| one | two |", + "| A | B |\n| --- | --- |\n| one! | two |", + "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |", + ], + name: "gfm table edits", + predict: false, + processor: gfmProcessor, + }, + { + content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"], + name: "prediction edits", + predict: true, + processor: undefined, + }, +])("matches a fresh render across $name", ({ content, predict, processor }) => { + const first = content[0]!; + const { container, rerender } = render( + , + renderOptions, + ); + + expect(container.innerHTML).toBe(renderFreshHtml(first, predict, processor)); + + for (const step of content.slice(1)) { + rerender(); + expect(container.innerHTML).toBe(renderFreshHtml(step, predict, processor)); + } +}); + +it("keeps a stable block tied to its own DOM node when blocks are inserted or removed above it", () => { + function Link(props: MarkdownProps<"a">) { + const instanceRef = useRef(Symbol("instance")); + return ; + } + + const components = { a: Link } satisfies Partial; + const { rerender } = render( + , + renderOptions, + ); + + const stableLink = screen.getByRole("link"); + + rerender( + , + ); + expect(screen.getByRole("link")).toBe(stableLink); + + rerender(); + expect(screen.getByRole("link")).toBe(stableLink); +}); + +it("keeps an unchanged list item tied to its DOM node when a sibling item is inserted above it", () => { + function ListItem(props: MarkdownProps<"li">) { + const instanceRef = useRef(Symbol("instance")); + return
  • ; + } + + const components = { li: ListItem } satisfies Partial; + const { rerender } = render( + , + renderOptions, + ); + + const stableItem = screen.getByText("stable").closest("li"); + expect(stableItem).not.toBeNull(); + + rerender(); + + expect(screen.getByText("stable").closest("li")).toBe(stableItem); +}); + +it("keeps demo README strong renderers asleep when editing a different block", () => { + const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { + return ; + }); + const components = { strong: Strong } satisfies Partial; + + const original = projectReadme; + const edited = projectReadme.replace( + "This package exports a React component to render Markdown using the [unified]", + "This package exports a React component to render memoized Markdown using the [unified]", + ); + + const { rerender } = render( + , + renderOptions, + ); + + const stableStrong = screen.getByText("Bring your Existing Pipeline").closest("strong"); + expect(stableStrong).not.toBeNull(); + + const strongCalls = Strong.mock.calls.length; + + rerender(); + + expect(screen.getByText("Bring your Existing Pipeline").closest("strong")).toBe(stableStrong); + expect(Strong.mock.calls.length).toBe(strongCalls); +}); + +function renderFreshHtml( + content: string, + predict: boolean | undefined, + processor: BaseProcessor | undefined, +) { + const { container, unmount } = render( + , + renderOptions, + ); + const html = container.innerHTML; + unmount(); + return html; +} diff --git a/tests/Memoizer.test.tsx b/tests/Memoizer.test.tsx index ceb1009e3b396cdc5bd3eb0c111e8445675dbb92..b4316b5e3ee827283214a67b7d8cae317c76e739 100644 --- a/tests/Memoizer.test.tsx +++ b/tests/Memoizer.test.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import { Fragment } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import projectReadme from "../README.md?raw"; +import projectReadme from "../readme.md?raw"; import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import { unified } from "unified"; diff --git a/tests/memoizedHastToReact.test.tsx b/tests/memoizedHastToReact.test.tsx index f31a6d0293bd8003e028a387a44bc96b4046f0f5..2ffc6762de950e8f8ce66844243ba3cf1656740d 100644 --- a/tests/memoizedHastToReact.test.tsx +++ b/tests/memoizedHastToReact.test.tsx @@ -1,7 +1,7 @@ import type { Element, ElementContent, Properties, Root, RootContent, Text } from "hast"; import type { Components } from "rehype-react"; import type { ReactElement, ReactNode } from "react"; -import projectReadme from "../README.md?raw"; +import projectReadme from "../readme.md?raw"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import remarkGfm from "remark-gfm"; @@ -225,7 +225,7 @@ it("uses configured components for matching tags", () => { expect(childrenOf(link)).toEqual(["hello"]); }); -it("reuses the README list block when an earlier block changes", () => { +it("reuses the readme list block when an earlier block changes", () => { const original = projectReadme; const edited = projectReadme.replace( "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", () => { expect(second.state).toBe(first.state); }); -it("reuses the README list block with a custom strong component when an earlier block changes", () => { +it("reuses the readme list block with a custom strong component when an earlier block changes", () => { function Strong(props: { children?: ReactNode }) { return {props.children}; } diff --git a/tests/prediction.test.ts b/tests/prediction.test.ts index 42c925757af33b980e262af8641a19a18d432a89..a59a3ea942e108371b61de9917d57c2d18c13c7f 100644 --- a/tests/prediction.test.ts +++ b/tests/prediction.test.ts @@ -223,6 +223,47 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => { ])("%s", (_name, input, expected) => check(input, expected)); }); + describe("table prediction", () => { + test.each([ + ["bare pipe is hidden", "|", ""], + ["partial header", "| hello", "| hello |\n| - |"], + ["single header predicts separator", "| hello |", "| hello |\n| - |"], + [ + "unfinished second header cell closes before separator", + "| hello | **world", + "| hello | **world** |\n| - | - |", + ], + ["trailing newline keeps predicted separator", "| hello |\n", "| hello |\n| - |"], + ["partial separator line is completed", "| hello |\n|", "| hello |\n| - |"], + ["partial hyphen separator is completed", "| hello |\n| -", "| hello |\n| - |"], + ["partial left-aligned separator is completed", "| hello |\n| :", "| hello |\n| :- |"], + ["partial right-aligned separator is completed", "| hello |\n| -:", "| hello |\n| -: |"], + ["partial centered separator is completed", "| hello |\n| :-:", "| hello |\n| :-: |"], + ["synthetic separator stays ahead of body rows", "| hello |\n| a", "| hello |\n| - |\n| a"], + [ + "partial multi-column separator is completed", + "| hello | world\n| - |", + "| hello | world |\n| - | - |", + ], + ["real separator row stops prediction", "| hello |\n| - |\n| a", "| hello |\n| - |\n| a"], + [ + "complete left-aligned separator stops prediction", + "| hello |\n| :- |\n| a", + "| hello |\n| :- |\n| a", + ], + [ + "complete right-aligned separator stops prediction", + "| hello |\n| -: |\n| a", + "| hello |\n| -: |\n| a", + ], + [ + "complete centered separator stops prediction", + "| hello |\n| :-: |\n| a", + "| hello |\n| :-: |\n| a", + ], + ])("%s", (_name, input, expected) => check(input, expected)); + }); + describe("code span variants", () => { test.each([ ["double backtick code span", "a ``hello world", "a ``hello world``"],