| author | |
| committer | |
| log | 3d54937be677300a48ef3538513c4dcbfac920b3 |
| tree | f5f5f1343dd9e191351baf7abe96103080d5467d |
| parent | 7cbbaedd32edafc10c7c0b128461dc123771c5e0 |
| signature |
5 files changed, 449 insertions(+), 21 deletions(-)
package.json+4-4| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | { | 1 | { |
| 2 | "name": "@clo/react-markdown", | 2 | "name": "@clo/react-markdown", |
| 3 | "type": "module", | 3 | "type": "module", |
| 4 | "exports": { | ||
| 5 | ".": "./src/Markdown.ts", | ||
| 6 | "./Predict": "./src/Predict.ts" | ||
| 7 | }, | ||
| 4 | "scripts": { | 8 | "scripts": { |
| 5 | "demo": "vite dev", | 9 | "demo": "vite dev", |
| 6 | "all": "pnpm --parallel fmt && pnpm --stream '/check|test$/'", | 10 | "all": "pnpm --parallel fmt && pnpm --stream '/check|test$/'", |
| ... | @@ -9,10 +13,6 @@ | ... | @@ -9,10 +13,6 @@ |
| 9 | "check:publish": "deno publish --dry-run --allow-dirty", | 13 | "check:publish": "deno publish --dry-run --allow-dirty", |
| 10 | "fmt": "oxfmt --write ." | 14 | "fmt": "oxfmt --write ." |
| 11 | }, | 15 | }, |
| 12 | "exports": { | ||
| 13 | ".": "./src/Markdown.ts", | ||
| 14 | "./Predict": "./src/Predict.ts" | ||
| 15 | }, | ||
| 16 | "dependencies": { | 16 | "dependencies": { |
| 17 | "@clo/lib": "npm:@jsr/clo__lib@3.0.0", | 17 | "@clo/lib": "npm:@jsr/clo__lib@3.0.0", |
| 18 | "@types/react": "^19", | 18 | "@types/react": "^19", |
src/Memoizer.ts+75-12| ... | @@ -4,9 +4,9 @@ import { type Components } from "rehype-react"; | ... | @@ -4,9 +4,9 @@ import { type Components } from "rehype-react"; |
| 4 | import remarkRehype from "remark-rehype"; | 4 | import remarkRehype from "remark-rehype"; |
| 5 | import { Fragment, jsx } from "react/jsx-runtime"; | 5 | import { Fragment, jsx } from "react/jsx-runtime"; |
| 6 | import { type Processor, unified } from "unified"; | 6 | import { type Processor, unified } from "unified"; |
| 7 | import type { Root as HastRoot } from "hast"; | 7 | import type { Root as HastRoot, Root } from "hast"; |
| 8 | import type { Literal, Node, Parent } from "unist"; | 8 | import type { Literal, Node, Parent } from "unist"; |
| 9 | import { memoizedHastToReact, type RenderState } from "./hast.ts"; | 9 | import { memoizedHastToReact, nodeDeepEquals, type RenderState } from "./hast.ts"; |
| 10 | import remarkParse from "remark-parse"; | 10 | import remarkParse from "remark-parse"; |
| 11 | import { Predict } from "./Predict.ts"; | 11 | import { Predict } from "./Predict.ts"; |
| 12 | 12 | ||
| ... | @@ -38,6 +38,9 @@ export class Memoizer { | ... | @@ -38,6 +38,9 @@ export class Memoizer { |
| 38 | #content: string = ""; | 38 | #content: string = ""; |
| 39 | #positions: number[] = []; | 39 | #positions: number[] = []; |
| 40 | #parsed: Node[][] = []; | 40 | #parsed: Node[][] = []; |
| 41 | // React block identity must survive index shifts when blocks are inserted above. | ||
| 42 | #keys: string[] = []; | ||
| 43 | #nextKey = 0; | ||
| 41 | #renderStates: (RenderState | null)[] = []; | 44 | #renderStates: (RenderState | null)[] = []; |
| 42 | #reactNodes: readonly ReactNode[] = []; | 45 | #reactNodes: readonly ReactNode[] = []; |
| 43 | 46 | ||
| ... | @@ -67,6 +70,8 @@ export class Memoizer { | ... | @@ -67,6 +70,8 @@ export class Memoizer { |
| 67 | this.#content = ""; | 70 | this.#content = ""; |
| 68 | this.#positions = []; | 71 | this.#positions = []; |
| 69 | this.#parsed = []; | 72 | this.#parsed = []; |
| 73 | this.#keys = []; | ||
| 74 | this.#nextKey = 0; | ||
| 70 | this.#renderStates = []; | 75 | this.#renderStates = []; |
| 71 | this.#reactNodes = []; | 76 | this.#reactNodes = []; |
| 72 | this.#predict = predict ? new Predict() : null; | 77 | this.#predict = predict ? new Predict() : null; |
| ... | @@ -126,15 +131,75 @@ export class Memoizer { | ... | @@ -126,15 +131,75 @@ export class Memoizer { |
| 126 | const newTransformed = extractBlocks(processor.runSync(parsedTree), newPositions, parseOffset); | 131 | const newTransformed = extractBlocks(processor.runSync(parsedTree), newPositions, parseOffset); |
| 127 | 132 | ||
| 128 | // React | 133 | // React |
| 134 | const keys = this.#keys; | ||
| 135 | const renderStates = this.#renderStates; | ||
| 136 | const previousReactNodes = this.#reactNodes; | ||
| 137 | const previousLength = renderStates.length; | ||
| 129 | let reactNodes: ReactNode[] | null = null; | 138 | let reactNodes: ReactNode[] | null = null; |
| 130 | if (this.#reactNodes.length !== positions.length) { | 139 | |
| 131 | // If items are removed, the array must be resliced. | 140 | // Insertions and removals above an unchanged tail shift indices without |
| 132 | reactNodes = this.#reactNodes.slice(0, positions.length); | 141 | // changing block content. Matching the shared suffix is enough to keep the |
| 133 | this.#renderStates.length = positions.length; | 142 | // unaffected tail bound to its previous keys and render states. |
| 143 | let suffixLength = 0; | ||
| 144 | for (; suffixLength < newTransformed.length; suffixLength += 1) { | ||
| 145 | const nextIndex = positions.length - 1 - suffixLength; | ||
| 146 | const previousIndex = previousLength - 1 - suffixLength; | ||
| 147 | if (nextIndex < blockStart || previousIndex < blockStart) break; | ||
| 148 | |||
| 149 | const next = UNWRAP(newTransformed[nextIndex - blockStart]); | ||
| 150 | const state = renderStates[previousIndex]; | ||
| 151 | const prev = state?.node.type === "root" ? (state.node as Root) : null; | ||
| 152 | if (!prev || !nodeDeepEquals(next, prev.children)) break; | ||
| 153 | } | ||
| 154 | |||
| 155 | // (If realignment is going to occurs, the array must be cloned) | ||
| 156 | const previousMiddleEnd = previousLength - suffixLength; | ||
| 157 | const nextMiddleEnd = positions.length - suffixLength; | ||
| 158 | const realign = previousLength !== positions.length || previousMiddleEnd !== nextMiddleEnd; | ||
| 159 | if (realign) reactNodes = previousReactNodes.slice(0, positions.length); | ||
| 160 | |||
| 161 | if (positions.length > previousLength) { | ||
| 162 | keys.length = renderStates.length = positions.length; | ||
| 163 | } | ||
| 164 | // Realignment reads from the previous layout and writes the next layout | ||
| 165 | // into the same backing arrays. Traversal direction prevents later reads | ||
| 166 | // from observing values that were already overwritten earlier in the pass. | ||
| 167 | for ( | ||
| 168 | let direction = positions.length < previousLength ? 1 : -1, | ||
| 169 | i = direction === 1 ? 0 : positions.length - 1, | ||
| 170 | end = direction === 1 ? positions.length : -1; | ||
| 171 | i !== end; | ||
| 172 | i += direction | ||
| 173 | ) { | ||
| 174 | let previousIndex: number = -1; | ||
| 175 | if (i < blockStart) { | ||
| 176 | previousIndex = i; | ||
| 177 | } else if (i >= nextMiddleEnd) { | ||
| 178 | previousIndex = previousLength - (positions.length - i); | ||
| 179 | } else if (i < previousMiddleEnd) { | ||
| 180 | previousIndex = i; | ||
| 181 | } | ||
| 182 | if (previousIndex < 0 || previousIndex >= previousLength) { | ||
| 183 | keys[i] = String(this.#nextKey); | ||
| 184 | this.#nextKey += 1; | ||
| 185 | renderStates[i] = null; | ||
| 186 | if (reactNodes) reactNodes[i] = undefined as ReactNode | undefined; | ||
| 187 | continue; | ||
| 188 | } | ||
| 189 | |||
| 190 | keys[i] = keys[previousIndex] ?? String("m" + this.#nextKey++); | ||
| 191 | renderStates[i] = renderStates[previousIndex] ?? null; | ||
| 192 | if (reactNodes) reactNodes[i] = previousReactNodes[previousIndex]; | ||
| 193 | } | ||
| 194 | |||
| 195 | // Create and splice the React elements | ||
| 196 | if (positions.length < previousLength) { | ||
| 197 | keys.length = renderStates.length = positions.length; | ||
| 134 | } | 198 | } |
| 135 | for (let i = blockStart, len = positions.length; i < len; i += 1) { | 199 | for (let i = blockStart, len = positions.length; i < len; i += 1) { |
| 136 | const ast = UNWRAP(newTransformed[i - blockStart]); | 200 | const ast = UNWRAP(newTransformed[i - blockStart]); |
| 137 | const previousState = this.#renderStates[i] ?? null; | 201 | const previousState = renderStates[i] ?? null; |
| 202 | |||
| 138 | // `memoizedHastToReact` handles diffing and incrementally updating the AST. | 203 | // `memoizedHastToReact` handles diffing and incrementally updating the AST. |
| 139 | let { react: result, state } = memoizedHastToReact( | 204 | let { react: result, state } = memoizedHastToReact( |
| 140 | { type: "root", children: ast } as HastRoot, | 205 | { type: "root", children: ast } as HastRoot, |
| ... | @@ -142,10 +207,10 @@ export class Memoizer { | ... | @@ -142,10 +207,10 @@ export class Memoizer { |
| 142 | this.#components, | 207 | this.#components, |
| 143 | ); | 208 | ); |
| 144 | if (previousState === state) continue; | 209 | if (previousState === state) continue; |
| 145 | this.#renderStates[i] = state; | 210 | renderStates[i] = state; |
| 146 | 211 | ||
| 147 | // Do a little trolling and unwrap the fragment to clean up the Virtual DOM | 212 | // Do a little trolling and unwrap the fragment to clean up the Virtual DOM |
| 148 | const key = String("m" + i); | 213 | const key = keys[i]!; |
| 149 | const rendered = result as JSX.Element; | 214 | const rendered = result as JSX.Element; |
| 150 | const children = rendered.props.children; | 215 | const children = rendered.props.children; |
| 151 | if (rendered.type === Fragment && children?.type) { | 216 | if (rendered.type === Fragment && children?.type) { |
| ... | @@ -153,9 +218,7 @@ export class Memoizer { | ... | @@ -153,9 +218,7 @@ export class Memoizer { |
| 153 | } else { | 218 | } else { |
| 154 | result = setReactKey(rendered, key); | 219 | result = setReactKey(rendered, key); |
| 155 | } | 220 | } |
| 156 | 221 | reactNodes ??= previousReactNodes.slice(0, positions.length); | |
| 157 | // If anything in the array changes, the array must be sliced. | ||
| 158 | reactNodes ??= this.#reactNodes.slice(); | ||
| 159 | reactNodes[i] = result; | 222 | reactNodes[i] = result; |
| 160 | } | 223 | } |
| 161 | 224 |
tests/Markdown.memoization.test.tsx+163-1| ... | @@ -1,15 +1,20 @@ | ... | @@ -1,15 +1,20 @@ |
| 1 | import { cleanup, render, screen } from "@testing-library/react"; | 1 | import { cleanup, render, screen } from "@testing-library/react"; |
| 2 | import type { Components } from "rehype-react"; | 2 | import type { Components } from "rehype-react"; |
| 3 | import type { ComponentPropsWithoutRef, JSX } from "react"; | 3 | import type { ComponentPropsWithoutRef, JSX } from "react"; |
| 4 | import { useRef } from "react"; | ||
| 5 | import remarkGfm from "remark-gfm"; | ||
| 6 | import remarkParse from "remark-parse"; | ||
| 7 | import { unified } from "unified"; | ||
| 4 | import { afterEach, expect, it, vi } from "vitest"; | 8 | import { afterEach, expect, it, vi } from "vitest"; |
| 5 | import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts"; | 9 | import { Markdown, MarkdownOptionsProvider } from "../src/Markdown.ts"; |
| 6 | import { Memoizer } from "../src/Memoizer.ts"; | 10 | import { type BaseProcessor, Memoizer } from "../src/Memoizer.ts"; |
| 7 | 11 | ||
| 8 | type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & { | 12 | type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & { |
| 9 | node?: unknown; | 13 | node?: unknown; |
| 10 | }; | 14 | }; |
| 11 | 15 | ||
| 12 | const renderOptions = { reactStrictMode: false } as const; | 16 | const renderOptions = { reactStrictMode: false } as const; |
| 17 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; | ||
| 13 | 18 | ||
| 14 | afterEach(() => { | 19 | afterEach(() => { |
| 15 | cleanup(); | 20 | cleanup(); |
| ... | @@ -129,6 +134,79 @@ it("does not rerender an unchanged inline custom renderer with formatted childre | ... | @@ -129,6 +134,79 @@ it("does not rerender an unchanged inline custom renderer with formatted childre |
| 129 | expect(Link).toHaveBeenCalledTimes(1); | 134 | expect(Link).toHaveBeenCalledTimes(1); |
| 130 | }); | 135 | }); |
| 131 | 136 | ||
| 137 | it("keeps an unchanged strong renderer asleep when surrounding paragraph text changes", () => { | ||
| 138 | const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { | ||
| 139 | return <strong data-testid="strong" {...omitNode(props)} />; | ||
| 140 | }); | ||
| 141 | const components = { strong: Strong } satisfies Partial<Components>; | ||
| 142 | |||
| 143 | const { rerender } = render( | ||
| 144 | <Markdown content={"alpha **stable** omega"} components={components} />, | ||
| 145 | renderOptions, | ||
| 146 | ); | ||
| 147 | |||
| 148 | const strong = screen.getByTestId("strong"); | ||
| 149 | expect(Strong).toHaveBeenCalledTimes(1); | ||
| 150 | |||
| 151 | rerender(<Markdown content={"alpha! **stable** omega"} components={components} />); | ||
| 152 | rerender(<Markdown content={"alpha! **stable** omega?"} components={components} />); | ||
| 153 | |||
| 154 | expect(screen.getByTestId("strong")).toBe(strong); | ||
| 155 | expect(Strong).toHaveBeenCalledTimes(1); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it("keeps an unchanged list item renderer asleep when a sibling item changes", () => { | ||
| 159 | const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) { | ||
| 160 | return <li data-testid="list-item" {...omitNode(props)} />; | ||
| 161 | }); | ||
| 162 | const components = { li: ListItem } satisfies Partial<Components>; | ||
| 163 | |||
| 164 | const { rerender } = render( | ||
| 165 | <Markdown content={"- first\n- stable\n- third"} components={components} />, | ||
| 166 | renderOptions, | ||
| 167 | ); | ||
| 168 | |||
| 169 | const stableItem = screen.getByText("stable").closest("li"); | ||
| 170 | expect(stableItem).not.toBeNull(); | ||
| 171 | expect(ListItem).toHaveBeenCalledTimes(3); | ||
| 172 | |||
| 173 | rerender(<Markdown content={"- first changed\n- stable\n- third"} components={components} />); | ||
| 174 | |||
| 175 | expect(screen.getByText("stable").closest("li")).toBe(stableItem); | ||
| 176 | expect(ListItem).toHaveBeenCalledTimes(4); | ||
| 177 | }); | ||
| 178 | |||
| 179 | it("keeps an unchanged table cell renderer asleep when another cell changes", () => { | ||
| 180 | const TableCell = vi.fn(function TableCell(props: MarkdownProps<"td">) { | ||
| 181 | return <td data-testid="table-cell" {...omitNode(props)} />; | ||
| 182 | }); | ||
| 183 | const components = { td: TableCell } satisfies Partial<Components>; | ||
| 184 | |||
| 185 | const { rerender } = render( | ||
| 186 | <Markdown | ||
| 187 | components={components} | ||
| 188 | content={"| left | stable |\n| --- | --- |\n| one | keep |"} | ||
| 189 | processor={gfmProcessor} | ||
| 190 | />, | ||
| 191 | renderOptions, | ||
| 192 | ); | ||
| 193 | |||
| 194 | const stableCell = screen.getByText("keep").closest("td"); | ||
| 195 | expect(stableCell).not.toBeNull(); | ||
| 196 | expect(TableCell).toHaveBeenCalledTimes(2); | ||
| 197 | |||
| 198 | rerender( | ||
| 199 | <Markdown | ||
| 200 | components={components} | ||
| 201 | content={"| left | stable |\n| --- | --- |\n| one! | keep |"} | ||
| 202 | processor={gfmProcessor} | ||
| 203 | />, | ||
| 204 | ); | ||
| 205 | |||
| 206 | expect(screen.getByText("keep").closest("td")).toBe(stableCell); | ||
| 207 | expect(TableCell).toHaveBeenCalledTimes(3); | ||
| 208 | }); | ||
| 209 | |||
| 132 | it("does not rerender Markdown through the provider when the effective components stay the same", () => { | 210 | it("does not rerender Markdown through the provider when the effective components stay the same", () => { |
| 133 | const updateSpy = vi.spyOn(Memoizer.prototype, "update"); | 211 | const updateSpy = vi.spyOn(Memoizer.prototype, "update"); |
| 134 | const Link = vi.fn(function Link(props: MarkdownProps<"a">) { | 212 | const Link = vi.fn(function Link(props: MarkdownProps<"a">) { |
| ... | @@ -169,3 +247,87 @@ it("reprocesses the document when prediction changes", () => { | ... | @@ -169,3 +247,87 @@ it("reprocesses the document when prediction changes", () => { |
| 169 | expect(container.querySelector("em")?.textContent).toBe("world"); | 247 | expect(container.querySelector("em")?.textContent).toBe("world"); |
| 170 | expect(container.textContent).toBe("hello world"); | 248 | expect(container.textContent).toBe("hello world"); |
| 171 | }); | 249 | }); |
| 250 | |||
| 251 | it.each([ | ||
| 252 | { | ||
| 253 | content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"], | ||
| 254 | name: "paragraph merging edits", | ||
| 255 | predict: false, | ||
| 256 | processor: undefined, | ||
| 257 | }, | ||
| 258 | { | ||
| 259 | content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]"], | ||
| 260 | name: "reference link definition edits", | ||
| 261 | predict: false, | ||
| 262 | processor: undefined, | ||
| 263 | }, | ||
| 264 | { | ||
| 265 | content: [ | ||
| 266 | "| A | B |\n| --- | --- |\n| one | two |", | ||
| 267 | "| A | B |\n| --- | --- |\n| one! | two |", | ||
| 268 | "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |", | ||
| 269 | ], | ||
| 270 | name: "gfm table edits", | ||
| 271 | predict: false, | ||
| 272 | processor: gfmProcessor, | ||
| 273 | }, | ||
| 274 | { | ||
| 275 | content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"], | ||
| 276 | name: "prediction edits", | ||
| 277 | predict: true, | ||
| 278 | processor: undefined, | ||
| 279 | }, | ||
| 280 | ])("matches a fresh render across $name", ({ content, predict, processor }) => { | ||
| 281 | const first = content[0]!; | ||
| 282 | const { container, rerender } = render( | ||
| 283 | <Markdown content={first} predict={predict} processor={processor} />, | ||
| 284 | renderOptions, | ||
| 285 | ); | ||
| 286 | |||
| 287 | expect(container.innerHTML).toBe(renderFreshHtml(first, predict, processor)); | ||
| 288 | |||
| 289 | for (const step of content.slice(1)) { | ||
| 290 | rerender(<Markdown content={step} predict={predict} processor={processor} />); | ||
| 291 | expect(container.innerHTML).toBe(renderFreshHtml(step, predict, processor)); | ||
| 292 | } | ||
| 293 | }); | ||
| 294 | |||
| 295 | it("keeps a stable block tied to its own DOM node when blocks are inserted or removed above it", () => { | ||
| 296 | function Link(props: MarkdownProps<"a">) { | ||
| 297 | const instanceRef = useRef(Symbol("instance")); | ||
| 298 | return <a data-instance={String(instanceRef.current)} {...omitNode(props)} />; | ||
| 299 | } | ||
| 300 | |||
| 301 | const components = { a: Link } satisfies Partial<Components>; | ||
| 302 | const { rerender } = render( | ||
| 303 | <Markdown content={"before\n\n[stable](https://example.com)"} components={components} />, | ||
| 304 | renderOptions, | ||
| 305 | ); | ||
| 306 | |||
| 307 | const stableLink = screen.getByRole("link"); | ||
| 308 | |||
| 309 | rerender( | ||
| 310 | <Markdown | ||
| 311 | content={"intro\n\nbefore\n\n[stable](https://example.com)"} | ||
| 312 | components={components} | ||
| 313 | />, | ||
| 314 | ); | ||
| 315 | expect(screen.getByRole("link")).toBe(stableLink); | ||
| 316 | |||
| 317 | rerender(<Markdown content={"intro\n\n[stable](https://example.com)"} components={components} />); | ||
| 318 | expect(screen.getByRole("link")).toBe(stableLink); | ||
| 319 | }); | ||
| 320 | |||
| 321 | function renderFreshHtml( | ||
| 322 | content: string, | ||
| 323 | predict: boolean | undefined, | ||
| 324 | processor: BaseProcessor | undefined, | ||
| 325 | ) { | ||
| 326 | const { container, unmount } = render( | ||
| 327 | <Markdown content={content} predict={predict} processor={processor} />, | ||
| 328 | renderOptions, | ||
| 329 | ); | ||
| 330 | const html = container.innerHTML; | ||
| 331 | unmount(); | ||
| 332 | return html; | ||
| 333 | } |
tests/Memoizer.test.tsx created+206| ... | @@ -0,0 +1,206 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { Fragment } from "react"; | ||
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 4 | import remarkGfm from "remark-gfm"; | ||
| 5 | import remarkParse from "remark-parse"; | ||
| 6 | import { unified } from "unified"; | ||
| 7 | import type { Literal, Node, Parent } from "unist"; | ||
| 8 | import { describe, expect, it } from "vitest"; | ||
| 9 | import { | ||
| 10 | componentsAreEqual, | ||
| 11 | defaultProcessor, | ||
| 12 | extractBlocks, | ||
| 13 | indexOfDiff, | ||
| 14 | Memoizer, | ||
| 15 | nodeAffectsDocument, | ||
| 16 | } from "../src/Memoizer.ts"; | ||
| 17 | |||
| 18 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm); | ||
| 19 | |||
| 20 | describe("Memoizer utilities", () => { | ||
| 21 | it("finds the first differing index for edits in the middle, start, and end", () => { | ||
| 22 | expect(indexOfDiff("alpha", "alpha!")).toBe(5); | ||
| 23 | expect(indexOfDiff("!alpha", "alpha")).toBe(0); | ||
| 24 | expect(indexOfDiff("alXha", "alpha")).toBe(2); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("treats semantically equal component maps as equal", () => { | ||
| 28 | const Link = () => null; | ||
| 29 | const Paragraph = () => null; | ||
| 30 | |||
| 31 | expect(componentsAreEqual({ a: Link, p: Paragraph }, { p: Paragraph, a: Link })).toBe(true); | ||
| 32 | expect(componentsAreEqual({ a: Link }, { a: Link, p: Paragraph })).toBe(false); | ||
| 33 | expect(componentsAreEqual({ a: Link }, { a: Paragraph })).toBe(false); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("detects document-wide nodes even when nested", () => { | ||
| 37 | expect( | ||
| 38 | nodeAffectsDocument({ | ||
| 39 | children: [ | ||
| 40 | { | ||
| 41 | children: [ | ||
| 42 | { | ||
| 43 | children: [], | ||
| 44 | type: "paragraph", | ||
| 45 | } as Parent, | ||
| 46 | { | ||
| 47 | identifier: "ref", | ||
| 48 | label: "ref", | ||
| 49 | title: null, | ||
| 50 | type: "definition", | ||
| 51 | url: "https://example.com", | ||
| 52 | } as Node, | ||
| 53 | ], | ||
| 54 | type: "container", | ||
| 55 | } as Parent, | ||
| 56 | ], | ||
| 57 | type: "root", | ||
| 58 | } as Parent), | ||
| 59 | ).toBe(true); | ||
| 60 | |||
| 61 | expect( | ||
| 62 | nodeAffectsDocument({ | ||
| 63 | children: [ | ||
| 64 | { | ||
| 65 | children: [], | ||
| 66 | type: "paragraph", | ||
| 67 | } as Parent, | ||
| 68 | ], | ||
| 69 | type: "root", | ||
| 70 | } as Parent), | ||
| 71 | ).toBe(false); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("groups transformed nodes into block buckets and ignores pure whitespace text", () => { | ||
| 75 | const blocks = extractBlocks( | ||
| 76 | { | ||
| 77 | children: [ | ||
| 78 | { | ||
| 79 | children: [], | ||
| 80 | position: { | ||
| 81 | end: { column: 6, line: 1, offset: 5 }, | ||
| 82 | start: { column: 1, line: 1, offset: 0 }, | ||
| 83 | }, | ||
| 84 | type: "paragraph", | ||
| 85 | } as Node, | ||
| 86 | { | ||
| 87 | type: "text", | ||
| 88 | value: "\n\n", | ||
| 89 | } as Literal, | ||
| 90 | { | ||
| 91 | children: [], | ||
| 92 | position: { | ||
| 93 | end: { column: 5, line: 3, offset: 14 }, | ||
| 94 | start: { column: 1, line: 3, offset: 10 }, | ||
| 95 | }, | ||
| 96 | type: "paragraph", | ||
| 97 | } as Node, | ||
| 98 | ], | ||
| 99 | type: "root", | ||
| 100 | } as Parent, | ||
| 101 | [0, 10], | ||
| 102 | 0, | ||
| 103 | ); | ||
| 104 | |||
| 105 | expect(blocks).toHaveLength(2); | ||
| 106 | expect(blocks[0]).toHaveLength(1); | ||
| 107 | expect(blocks[1]).toHaveLength(1); | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | |||
| 111 | describe("Memoizer incremental rendering", () => { | ||
| 112 | it("returns the same array reference when content does not change", () => { | ||
| 113 | const memoizer = createMemoizer(); | ||
| 114 | |||
| 115 | const first = memoizer.update("hello"); | ||
| 116 | const second = memoizer.update("hello"); | ||
| 117 | |||
| 118 | expect(second).toBe(first); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("reuses unchanged block react nodes when editing a different block", () => { | ||
| 122 | const memoizer = createMemoizer(); | ||
| 123 | const first = memoizer.update("alpha\n\nbeta"); | ||
| 124 | const second = memoizer.update("alpha!\n\nbeta"); | ||
| 125 | const third = memoizer.update("alpha!\n\nbeta!"); | ||
| 126 | |||
| 127 | expect(second[1]).toBe(first[1]); | ||
| 128 | expect(third[0]).toBe(second[0]); | ||
| 129 | expect(third[1]).not.toBe(second[1]); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("reprocesses reference-link consumers when a later definition changes", () => { | ||
| 133 | const memoizer = createMemoizer(); | ||
| 134 | |||
| 135 | const before = renderNodes(memoizer.update("[ref][id]\n\n[id]: /one")); | ||
| 136 | const after = renderNodes(memoizer.update("[ref][id]\n\n[id]: /two")); | ||
| 137 | |||
| 138 | expect(before).toContain('href="/one"'); | ||
| 139 | expect(after).toContain('href="/two"'); | ||
| 140 | }); | ||
| 141 | |||
| 142 | it.each([ | ||
| 143 | { | ||
| 144 | content: ["alpha\n\nbeta", "alpha\nbeta", "alpha\n\nbeta\n\ngamma", "alpha\n\nbeta"], | ||
| 145 | name: "paragraphs merging and splitting", | ||
| 146 | predict: false, | ||
| 147 | processor: defaultProcessor, | ||
| 148 | }, | ||
| 149 | { | ||
| 150 | content: ["[ref][id]\n\n[id]: /one", "[ref][id]\n\n[id]: /two", "[ref][id]\n\n[id]: /three"], | ||
| 151 | name: "reference link definitions", | ||
| 152 | predict: false, | ||
| 153 | processor: defaultProcessor, | ||
| 154 | }, | ||
| 155 | { | ||
| 156 | content: [ | ||
| 157 | "| A | B |\n| --- | --- |\n| one | two |", | ||
| 158 | "| A | B |\n| --- | --- |\n| one! | two |", | ||
| 159 | "| A | B |\n| --- | --- |\n| one! | two |\n| three | four |", | ||
| 160 | ], | ||
| 161 | name: "gfm tables", | ||
| 162 | predict: false, | ||
| 163 | processor: gfmProcessor, | ||
| 164 | }, | ||
| 165 | { | ||
| 166 | content: ["- [ ] open\n- [x] done", "- [x] open\n- [x] done", "- [x] open\n- [ ] done"], | ||
| 167 | name: "gfm task lists", | ||
| 168 | predict: false, | ||
| 169 | processor: gfmProcessor, | ||
| 170 | }, | ||
| 171 | { | ||
| 172 | content: ["hello *world", "hello brave *world", "hello brave world", "hello brave `world"], | ||
| 173 | name: "prediction across mid-document edits", | ||
| 174 | predict: true, | ||
| 175 | processor: defaultProcessor, | ||
| 176 | }, | ||
| 177 | ])("matches a fresh memoizer for $name", ({ content, predict, processor }) => { | ||
| 178 | const incremental = createMemoizer(processor, predict); | ||
| 179 | |||
| 180 | for (const step of content) { | ||
| 181 | const fresh = createMemoizer(processor, predict); | ||
| 182 | |||
| 183 | expect(renderNodes(incremental.update(step))).toBe(renderNodes(fresh.update(step))); | ||
| 184 | } | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("keeps a stable block tied to its own react node when blocks are inserted or removed above it", () => { | ||
| 188 | const memoizer = createMemoizer(); | ||
| 189 | const first = memoizer.update("before\n\n[stable](https://example.com)"); | ||
| 190 | const second = memoizer.update("intro\n\nbefore\n\n[stable](https://example.com)"); | ||
| 191 | const third = memoizer.update("intro\n\n[stable](https://example.com)"); | ||
| 192 | |||
| 193 | expect(second[2]).toBe(first[1]); | ||
| 194 | expect(third[1]).toBe(second[2]); | ||
| 195 | }); | ||
| 196 | }); | ||
| 197 | |||
| 198 | function createMemoizer(processor = defaultProcessor, predict = false) { | ||
| 199 | const memoizer = new Memoizer(); | ||
| 200 | memoizer.reconfigure(processor, predict, {}); | ||
| 201 | return memoizer; | ||
| 202 | } | ||
| 203 | |||
| 204 | function renderNodes(nodes: readonly ReactNode[]) { | ||
| 205 | return renderToStaticMarkup(<Fragment>{nodes}</Fragment>); | ||
| 206 | } | ||
tests/prediction.test.ts+1-4| ... | @@ -207,10 +207,7 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => { | ... | @@ -207,10 +207,7 @@ describe.each(["stateless", "stateful"] as const)("%s processing", (mode) => { |
| 207 | ["hello **world ", "hello **world** "], | 207 | ["hello **world ", "hello **world** "], |
| 208 | ["hello _world ", "hello _world_ "], | 208 | ["hello _world ", "hello _world_ "], |
| 209 | ["hello __world ", "hello __world__ "], | 209 | ["hello __world ", "hello __world__ "], |
| 210 | ])( | 210 | ])("closes before trailing whitespace for %j", (input, expected) => check(input, expected)); |
| 211 | "closes before trailing whitespace for %j", | ||
| 212 | (input, expected) => check(input, expected), | ||
| 213 | ); | ||
| 214 | }); | 211 | }); |
| 215 | 212 | ||
| 216 | describe("soft line breaks", () => { | 213 | describe("soft line breaks", () => { |