| 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 | 1 | { |
| 2 | 2 | "name": "@clo/react-markdown", |
| 3 | 3 | "type": "module", |
| 4 | "exports": { | |
| 5 | ".": "./src/Markdown.ts", | |
| 6 | "./Predict": "./src/Predict.ts" | |
| 7 | }, | |
| 4 | 8 | "scripts": { |
| 5 | 9 | "demo": "vite dev", |
| 6 | 10 | "all": "pnpm --parallel fmt && pnpm --stream '/check|test$/'", |
| ... | ... | @@ -9,10 +13,6 @@ |
| 9 | 13 | "check:publish": "deno publish --dry-run --allow-dirty", |
| 10 | 14 | "fmt": "oxfmt --write ." |
| 11 | 15 | }, |
| 12 | "exports": { | |
| 13 | ".": "./src/Markdown.ts", | |
| 14 | "./Predict": "./src/Predict.ts" | |
| 15 | }, | |
| 16 | 16 | "dependencies": { |
| 17 | 17 | "@clo/lib": "npm:@jsr/clo__lib@3.0.0", |
| 18 | 18 | "@types/react": "^19", |
src/Memoizer.ts+75-12| ... | ... | @@ -4,9 +4,9 @@ import { type Components } from "rehype-react"; |
| 4 | 4 | import remarkRehype from "remark-rehype"; |
| 5 | 5 | import { Fragment, jsx } from "react/jsx-runtime"; |
| 6 | 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 | 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 | 10 | import remarkParse from "remark-parse"; |
| 11 | 11 | import { Predict } from "./Predict.ts"; |
| 12 | 12 | |
| ... | ... | @@ -38,6 +38,9 @@ export class Memoizer { |
| 38 | 38 | #content: string = ""; |
| 39 | 39 | #positions: number[] = []; |
| 40 | 40 | #parsed: Node[][] = []; |
| 41 | // React block identity must survive index shifts when blocks are inserted above. | |
| 42 | #keys: string[] = []; | |
| 43 | #nextKey = 0; | |
| 41 | 44 | #renderStates: (RenderState | null)[] = []; |
| 42 | 45 | #reactNodes: readonly ReactNode[] = []; |
| 43 | 46 | |
| ... | ... | @@ -67,6 +70,8 @@ export class Memoizer { |
| 67 | 70 | this.#content = ""; |
| 68 | 71 | this.#positions = []; |
| 69 | 72 | this.#parsed = []; |
| 73 | this.#keys = []; | |
| 74 | this.#nextKey = 0; | |
| 70 | 75 | this.#renderStates = []; |
| 71 | 76 | this.#reactNodes = []; |
| 72 | 77 | this.#predict = predict ? new Predict() : null; |
| ... | ... | @@ -126,15 +131,75 @@ export class Memoizer { |
| 126 | 131 | const newTransformed = extractBlocks(processor.runSync(parsedTree), newPositions, parseOffset); |
| 127 | 132 | |
| 128 | 133 | // React |
| 134 | const keys = this.#keys; | |
| 135 | const renderStates = this.#renderStates; | |
| 136 | const previousReactNodes = this.#reactNodes; | |
| 137 | const previousLength = renderStates.length; | |
| 129 | 138 | let reactNodes: ReactNode[] | null = null; |
| 130 | if (this.#reactNodes.length !== positions.length) { | |
| 131 | // If items are removed, the array must be resliced. | |
| 132 | reactNodes = this.#reactNodes.slice(0, positions.length); | |
| 133 | this.#renderStates.length = positions.length; | |
| 139 | ||
| 140 | // Insertions and removals above an unchanged tail shift indices without | |
| 141 | // changing block content. Matching the shared suffix is enough to keep the | |
| 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 | 199 | for (let i = blockStart, len = positions.length; i < len; i += 1) { |
| 136 | 200 | const ast = UNWRAP(newTransformed[i - blockStart]); |
| 137 | const previousState = this.#renderStates[i] ?? null; | |
| 201 | const previousState = renderStates[i] ?? null; | |
| 202 | ||
| 138 | 203 | // `memoizedHastToReact` handles diffing and incrementally updating the AST. |
| 139 | 204 | let { react: result, state } = memoizedHastToReact( |
| 140 | 205 | { type: "root", children: ast } as HastRoot, |
| ... | ... | @@ -142,10 +207,10 @@ export class Memoizer { |
| 142 | 207 | this.#components, |
| 143 | 208 | ); |
| 144 | 209 | if (previousState === state) continue; |
| 145 | this.#renderStates[i] = state; | |
| 210 | renderStates[i] = state; | |
| 146 | 211 | |
| 147 | 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 | 214 | const rendered = result as JSX.Element; |
| 150 | 215 | const children = rendered.props.children; |
| 151 | 216 | if (rendered.type === Fragment && children?.type) { |
| ... | ... | @@ -153,9 +218,7 @@ export class Memoizer { |
| 153 | 218 | } else { |
| 154 | 219 | result = setReactKey(rendered, key); |
| 155 | 220 | } |
| 156 | ||
| 157 | // If anything in the array changes, the array must be sliced. | |
| 158 | reactNodes ??= this.#reactNodes.slice(); | |
| 221 | reactNodes ??= previousReactNodes.slice(0, positions.length); | |
| 159 | 222 | reactNodes[i] = result; |
| 160 | 223 | } |
| 161 | 224 |
tests/Markdown.memoization.test.tsx+163-1| ... | ... | @@ -1,15 +1,20 @@ |
| 1 | 1 | import { cleanup, render, screen } from "@testing-library/react"; |
| 2 | 2 | import type { Components } from "rehype-react"; |
| 3 | 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 | 8 | import { afterEach, expect, it, vi } from "vitest"; |
| 5 | 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 | 12 | type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag> & { |
| 9 | 13 | node?: unknown; |
| 10 | 14 | }; |
| 11 | 15 | |
| 12 | 16 | const renderOptions = { reactStrictMode: false } as const; |
| 17 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; | |
| 13 | 18 | |
| 14 | 19 | afterEach(() => { |
| 15 | 20 | cleanup(); |
| ... | ... | @@ -129,6 +134,79 @@ it("does not rerender an unchanged inline custom renderer with formatted childre |
| 129 | 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 | 210 | it("does not rerender Markdown through the provider when the effective components stay the same", () => { |
| 133 | 211 | const updateSpy = vi.spyOn(Memoizer.prototype, "update"); |
| 134 | 212 | const Link = vi.fn(function Link(props: MarkdownProps<"a">) { |
| ... | ... | @@ -169,3 +247,87 @@ it("reprocesses the document when prediction changes", () => { |
| 169 | 247 | expect(container.querySelector("em")?.textContent).toBe("world"); |
| 170 | 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 | 207 | ["hello **world ", "hello **world** "], |
| 208 | 208 | ["hello _world ", "hello _world_ "], |
| 209 | 209 | ["hello __world ", "hello __world__ "], |
| 210 | ])( | |
| 211 | "closes before trailing whitespace for %j", | |
| 212 | (input, expected) => check(input, expected), | |
| 213 | ); | |
| 210 | ])("closes before trailing whitespace for %j", (input, expected) => check(input, expected)); | |
| 214 | 211 | }); |
| 215 | 212 | |
| 216 | 213 | describe("soft line breaks", () => { |