| author | |
| committer | |
| log | 37b340018b2ef7f2193a5f0680a68803f575ddda |
| tree | a7d9b22760eff367589a0c838b54061b314f080a |
| parent | 3d54937be677300a48ef3538513c4dcbfac920b3 |
| signature |
4 files changed, 140 insertions(+), 5 deletions(-)
src/Memoizer.ts+13-5| ... | @@ -115,12 +115,20 @@ export class Memoizer { | ... | @@ -115,12 +115,20 @@ export class Memoizer { |
| 115 | parsed.splice(blockStart, parsed.length - blockStart, ...newParsed); | 115 | parsed.splice(blockStart, parsed.length - blockStart, ...newParsed); |
| 116 | } | 116 | } |
| 117 | 117 | ||
| 118 | // If a reflink is involved, bring in the entire parsed AST for transformation. | 118 | // If a reflink is involved, fall back to a full reparse. Rehydrating a |
| 119 | // synthetic root from cached blocks can lose source offsets on transformed | ||
| 120 | // top-level nodes, which would scramble block bucketing and key identity. | ||
| 119 | if (nodeAffectsDocument(parsedTree)) { | 121 | if (nodeAffectsDocument(parsedTree)) { |
| 120 | blockStart = 0; | 122 | blockStart = 0; |
| 121 | parseOffset = 0; | 123 | parseOffset = 0; |
| 122 | newPositions = positions; | 124 | parsedTree = processor.parse(content); |
| 123 | parsedTree = { type: "root", children: parsed.flat() }; | 125 | newPositions = parsedTree.children.map( |
| 126 | (child) => UNWRAP(UNWRAP(child.position).start.offset) + parseOffset, | ||
| 127 | ); | ||
| 128 | positions.splice(0, positions.length, ...newPositions); | ||
| 129 | const newParsed = extractBlocks(parsedTree, newPositions, parseOffset); | ||
| 130 | ASSERT(newParsed.length === newPositions.length); | ||
| 131 | parsed.splice(0, parsed.length, ...newParsed); | ||
| 124 | } | 132 | } |
| 125 | 133 | ||
| 126 | // For all affected blocks, transform their ASTs. This is done in | 134 | // For all affected blocks, transform their ASTs. This is done in |
| ... | @@ -187,7 +195,7 @@ export class Memoizer { | ... | @@ -187,7 +195,7 @@ export class Memoizer { |
| 187 | continue; | 195 | continue; |
| 188 | } | 196 | } |
| 189 | 197 | ||
| 190 | keys[i] = keys[previousIndex] ?? String("m" + this.#nextKey++); | 198 | keys[i] = keys[previousIndex] ?? String(this.#nextKey++); |
| 191 | renderStates[i] = renderStates[previousIndex] ?? null; | 199 | renderStates[i] = renderStates[previousIndex] ?? null; |
| 192 | if (reactNodes) reactNodes[i] = previousReactNodes[previousIndex]; | 200 | if (reactNodes) reactNodes[i] = previousReactNodes[previousIndex]; |
| 193 | } | 201 | } |
| ... | @@ -196,7 +204,7 @@ export class Memoizer { | ... | @@ -196,7 +204,7 @@ export class Memoizer { |
| 196 | if (positions.length < previousLength) { | 204 | if (positions.length < previousLength) { |
| 197 | keys.length = renderStates.length = positions.length; | 205 | keys.length = renderStates.length = positions.length; |
| 198 | } | 206 | } |
| 199 | for (let i = blockStart, len = positions.length; i < len; i += 1) { | 207 | for (let i = blockStart, len = nextMiddleEnd; i < len; i += 1) { |
| 200 | const ast = UNWRAP(newTransformed[i - blockStart]); | 208 | const ast = UNWRAP(newTransformed[i - blockStart]); |
| 201 | const previousState = renderStates[i] ?? null; | 209 | const previousState = renderStates[i] ?? null; |
| 202 | 210 |
tests/Markdown.memoization.test.tsx+51| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { cleanup, render, screen } from "@testing-library/react"; | 1 | import { cleanup, render, screen } from "@testing-library/react"; |
| 2 | import projectReadme from "../README.md?raw"; | ||
| 2 | import type { Components } from "rehype-react"; | 3 | import type { Components } from "rehype-react"; |
| 3 | import type { ComponentPropsWithoutRef, JSX } from "react"; | 4 | import type { ComponentPropsWithoutRef, JSX } from "react"; |
| 4 | import { useRef } from "react"; | 5 | import { useRef } from "react"; |
| ... | @@ -14,6 +15,7 @@ type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWith | ... | @@ -14,6 +15,7 @@ type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWith |
| 14 | }; | 15 | }; |
| 15 | 16 | ||
| 16 | const renderOptions = { reactStrictMode: false } as const; | 17 | const renderOptions = { reactStrictMode: false } as const; |
| 18 | const strictRenderOptions = { reactStrictMode: true } as const; | ||
| 17 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; | 19 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; |
| 18 | 20 | ||
| 19 | afterEach(() => { | 21 | afterEach(() => { |
| ... | @@ -155,6 +157,27 @@ it("keeps an unchanged strong renderer asleep when surrounding paragraph text ch | ... | @@ -155,6 +157,27 @@ it("keeps an unchanged strong renderer asleep when surrounding paragraph text ch |
| 155 | expect(Strong).toHaveBeenCalledTimes(1); | 157 | expect(Strong).toHaveBeenCalledTimes(1); |
| 156 | }); | 158 | }); |
| 157 | 159 | ||
| 160 | it("keeps an unchanged strong renderer asleep under Strict Mode when surrounding text changes", () => { | ||
| 161 | const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { | ||
| 162 | return <strong data-testid="strict-strong" {...omitNode(props)} />; | ||
| 163 | }); | ||
| 164 | const components = { strong: Strong } satisfies Partial<Components>; | ||
| 165 | |||
| 166 | const { rerender } = render( | ||
| 167 | <Markdown content={"alpha **stable** omega"} components={components} />, | ||
| 168 | strictRenderOptions, | ||
| 169 | ); | ||
| 170 | |||
| 171 | const strong = screen.getByTestId("strict-strong"); | ||
| 172 | const initialCalls = Strong.mock.calls.length; | ||
| 173 | |||
| 174 | rerender(<Markdown content={"alpha! **stable** omega"} components={components} />); | ||
| 175 | rerender(<Markdown content={"alpha! **stable** omega?"} components={components} />); | ||
| 176 | |||
| 177 | expect(screen.getByTestId("strict-strong")).toBe(strong); | ||
| 178 | expect(Strong.mock.calls.length).toBe(initialCalls); | ||
| 179 | }); | ||
| 180 | |||
| 158 | it("keeps an unchanged list item renderer asleep when a sibling item changes", () => { | 181 | it("keeps an unchanged list item renderer asleep when a sibling item changes", () => { |
| 159 | const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) { | 182 | const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) { |
| 160 | return <li data-testid="list-item" {...omitNode(props)} />; | 183 | return <li data-testid="list-item" {...omitNode(props)} />; |
| ... | @@ -318,6 +341,34 @@ it("keeps a stable block tied to its own DOM node when blocks are inserted or re | ... | @@ -318,6 +341,34 @@ it("keeps a stable block tied to its own DOM node when blocks are inserted or re |
| 318 | expect(screen.getByRole("link")).toBe(stableLink); | 341 | expect(screen.getByRole("link")).toBe(stableLink); |
| 319 | }); | 342 | }); |
| 320 | 343 | ||
| 344 | it("keeps demo README strong renderers asleep when editing a different block", () => { | ||
| 345 | const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { | ||
| 346 | return <strong data-testid="readme-strong" {...omitNode(props)} />; | ||
| 347 | }); | ||
| 348 | const components = { strong: Strong } satisfies Partial<Components>; | ||
| 349 | |||
| 350 | const original = projectReadme; | ||
| 351 | const edited = projectReadme.replace( | ||
| 352 | "This package exports a React component to render Markdown using the [unified]", | ||
| 353 | "This package exports a React component to render memoized Markdown using the [unified]", | ||
| 354 | ); | ||
| 355 | |||
| 356 | const { rerender } = render( | ||
| 357 | <Markdown components={components} content={original} processor={gfmProcessor} />, | ||
| 358 | renderOptions, | ||
| 359 | ); | ||
| 360 | |||
| 361 | const stableStrong = screen.getByText("Bring your Existing Pipeline").closest("strong"); | ||
| 362 | expect(stableStrong).not.toBeNull(); | ||
| 363 | |||
| 364 | const strongCalls = Strong.mock.calls.length; | ||
| 365 | |||
| 366 | rerender(<Markdown components={components} content={edited} processor={gfmProcessor} />); | ||
| 367 | |||
| 368 | expect(screen.getByText("Bring your Existing Pipeline").closest("strong")).toBe(stableStrong); | ||
| 369 | expect(Strong.mock.calls.length).toBe(strongCalls); | ||
| 370 | }); | ||
| 371 | |||
| 321 | function renderFreshHtml( | 372 | function renderFreshHtml( |
| 322 | content: string, | 373 | content: string, |
| 323 | predict: boolean | undefined, | 374 | predict: boolean | undefined, |
tests/Memoizer.test.tsx+15| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { ReactNode } from "react"; | 1 | import type { ReactNode } from "react"; |
| 2 | import { Fragment } from "react"; | 2 | import { Fragment } from "react"; |
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import projectReadme from "../README.md?raw"; | ||
| 4 | import remarkGfm from "remark-gfm"; | 5 | import remarkGfm from "remark-gfm"; |
| 5 | import remarkParse from "remark-parse"; | 6 | import remarkParse from "remark-parse"; |
| 6 | import { unified } from "unified"; | 7 | import { unified } from "unified"; |
| ... | @@ -193,6 +194,20 @@ describe("Memoizer incremental rendering", () => { | ... | @@ -193,6 +194,20 @@ describe("Memoizer incremental rendering", () => { |
| 193 | expect(second[2]).toBe(first[1]); | 194 | expect(second[2]).toBe(first[1]); |
| 194 | expect(third[1]).toBe(second[2]); | 195 | expect(third[1]).toBe(second[2]); |
| 195 | }); | 196 | }); |
| 197 | |||
| 198 | it("reuses the README list block react node when editing an earlier paragraph", () => { | ||
| 199 | const memoizer = createMemoizer(gfmProcessor); | ||
| 200 | const original = projectReadme; | ||
| 201 | const edited = projectReadme.replace( | ||
| 202 | "This package exports a React component to render Markdown using the [unified]", | ||
| 203 | "This package exports a React component to render memoized Markdown using the [unified]", | ||
| 204 | ); | ||
| 205 | |||
| 206 | const first = memoizer.update(original); | ||
| 207 | const second = memoizer.update(edited); | ||
| 208 | |||
| 209 | expect(second[2]).toBe(first[2]); | ||
| 210 | }); | ||
| 196 | }); | 211 | }); |
| 197 | 212 | ||
| 198 | function createMemoizer(processor = defaultProcessor, predict = false) { | 213 | function createMemoizer(processor = defaultProcessor, predict = false) { |
tests/memoizedHastToReact.test.tsx+61| ... | @@ -1,14 +1,17 @@ | ... | @@ -1,14 +1,17 @@ |
| 1 | import type { Element, ElementContent, Properties, Root, RootContent, Text } from "hast"; | 1 | import type { Element, ElementContent, Properties, Root, RootContent, Text } from "hast"; |
| 2 | import type { Components } from "rehype-react"; | 2 | import type { Components } from "rehype-react"; |
| 3 | import type { ReactElement, ReactNode } from "react"; | 3 | import type { ReactElement, ReactNode } from "react"; |
| 4 | import projectReadme from "../README.md?raw"; | ||
| 4 | import remarkParse from "remark-parse"; | 5 | import remarkParse from "remark-parse"; |
| 5 | import remarkRehype from "remark-rehype"; | 6 | import remarkRehype from "remark-rehype"; |
| 7 | import remarkGfm from "remark-gfm"; | ||
| 6 | import { unified } from "unified"; | 8 | import { unified } from "unified"; |
| 7 | import type { Position } from "unist"; | 9 | import type { Position } from "unist"; |
| 8 | import { expect, it } from "vitest"; | 10 | import { expect, it } from "vitest"; |
| 9 | import { memoizedHastToReact, type RenderState } from "../src/hast.ts"; | 11 | import { memoizedHastToReact, type RenderState } from "../src/hast.ts"; |
| 10 | 12 | ||
| 11 | const markdownProcessor = unified().use(remarkParse).use(remarkRehype); | 13 | const markdownProcessor = unified().use(remarkParse).use(remarkRehype); |
| 14 | const gfmMarkdownProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype); | ||
| 12 | type TestElementProps = Record<string, unknown> & { | 15 | type TestElementProps = Record<string, unknown> & { |
| 13 | children?: ReactNode; | 16 | children?: ReactNode; |
| 14 | className?: string; | 17 | className?: string; |
| ... | @@ -199,6 +202,64 @@ it("uses configured components for matching tags", () => { | ... | @@ -199,6 +202,64 @@ it("uses configured components for matching tags", () => { |
| 199 | expect(childrenOf(link)).toEqual(["hello"]); | 202 | expect(childrenOf(link)).toEqual(["hello"]); |
| 200 | }); | 203 | }); |
| 201 | 204 | ||
| 205 | it("reuses the README list block when an earlier block changes", () => { | ||
| 206 | const original = projectReadme; | ||
| 207 | const edited = projectReadme.replace( | ||
| 208 | "This package exports a React component to render Markdown using the [unified]", | ||
| 209 | "This package exports a React component to render memoized Markdown using the [unified]", | ||
| 210 | ); | ||
| 211 | |||
| 212 | const originalTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(original)); | ||
| 213 | const editedTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(edited)); | ||
| 214 | const originalList = getElement( | ||
| 215 | originalTree.children.find( | ||
| 216 | (child: RootContent) => child.type === "element" && child.tagName === "ul", | ||
| 217 | ), | ||
| 218 | ); | ||
| 219 | const editedList = getElement( | ||
| 220 | editedTree.children.find( | ||
| 221 | (child: RootContent) => child.type === "element" && child.tagName === "ul", | ||
| 222 | ), | ||
| 223 | ); | ||
| 224 | |||
| 225 | const first = renderTree(root(originalList)); | ||
| 226 | const second = renderTree(root(editedList), first.state); | ||
| 227 | |||
| 228 | expect(second.react).toBe(first.react); | ||
| 229 | expect(second.state).toBe(first.state); | ||
| 230 | }); | ||
| 231 | |||
| 232 | it("reuses the README list block with a custom strong component when an earlier block changes", () => { | ||
| 233 | function Strong(props: { children?: ReactNode }) { | ||
| 234 | return <strong data-testid="memo-strong">{props.children}</strong>; | ||
| 235 | } | ||
| 236 | |||
| 237 | const original = projectReadme; | ||
| 238 | const edited = projectReadme.replace( | ||
| 239 | "This package exports a React component to render Markdown using the [unified]", | ||
| 240 | "This package exports a React component to render memoized Markdown using the [unified]", | ||
| 241 | ); | ||
| 242 | |||
| 243 | const originalTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(original)); | ||
| 244 | const editedTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(edited)); | ||
| 245 | const originalList = getElement( | ||
| 246 | originalTree.children.find( | ||
| 247 | (child: RootContent) => child.type === "element" && child.tagName === "ul", | ||
| 248 | ), | ||
| 249 | ); | ||
| 250 | const editedList = getElement( | ||
| 251 | editedTree.children.find( | ||
| 252 | (child: RootContent) => child.type === "element" && child.tagName === "ul", | ||
| 253 | ), | ||
| 254 | ); | ||
| 255 | |||
| 256 | const first = renderTree(root(originalList), null, { strong: Strong }); | ||
| 257 | const second = renderTree(root(editedList), first.state, { strong: Strong }); | ||
| 258 | |||
| 259 | expect(second.react).toBe(first.react); | ||
| 260 | expect(second.state).toBe(first.state); | ||
| 261 | }); | ||
| 262 | |||
| 202 | it("reuses equal custom-component subtrees when siblings change", () => { | 263 | it("reuses equal custom-component subtrees when siblings change", () => { |
| 203 | function Link(_props: { href?: string; children?: ReactNode }) { | 264 | function Link(_props: { href?: string; children?: ReactNode }) { |
| 204 | return null; | 265 | return null; |