| 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 | 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 | 121 | if (nodeAffectsDocument(parsedTree)) { |
| 120 | 122 | blockStart = 0; |
| 121 | 123 | parseOffset = 0; |
| 122 | newPositions = positions; | |
| 123 | parsedTree = { type: "root", children: parsed.flat() }; | |
| 124 | parsedTree = processor.parse(content); | |
| 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 | 134 | // For all affected blocks, transform their ASTs. This is done in |
| ... | ... | @@ -187,7 +195,7 @@ export class Memoizer { |
| 187 | 195 | continue; |
| 188 | 196 | } |
| 189 | 197 | |
| 190 | keys[i] = keys[previousIndex] ?? String("m" + this.#nextKey++); | |
| 198 | keys[i] = keys[previousIndex] ?? String(this.#nextKey++); | |
| 191 | 199 | renderStates[i] = renderStates[previousIndex] ?? null; |
| 192 | 200 | if (reactNodes) reactNodes[i] = previousReactNodes[previousIndex]; |
| 193 | 201 | } |
| ... | ... | @@ -196,7 +204,7 @@ export class Memoizer { |
| 196 | 204 | if (positions.length < previousLength) { |
| 197 | 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 | 208 | const ast = UNWRAP(newTransformed[i - blockStart]); |
| 201 | 209 | const previousState = renderStates[i] ?? null; |
| 202 | 210 |
tests/Markdown.memoization.test.tsx+51| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { cleanup, render, screen } from "@testing-library/react"; |
| 2 | import projectReadme from "../README.md?raw"; | |
| 2 | 3 | import type { Components } from "rehype-react"; |
| 3 | 4 | import type { ComponentPropsWithoutRef, JSX } from "react"; |
| 4 | 5 | import { useRef } from "react"; |
| ... | ... | @@ -14,6 +15,7 @@ type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWith |
| 14 | 15 | }; |
| 15 | 16 | |
| 16 | 17 | const renderOptions = { reactStrictMode: false } as const; |
| 18 | const strictRenderOptions = { reactStrictMode: true } as const; | |
| 17 | 19 | const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; |
| 18 | 20 | |
| 19 | 21 | afterEach(() => { |
| ... | ... | @@ -155,6 +157,27 @@ it("keeps an unchanged strong renderer asleep when surrounding paragraph text ch |
| 155 | 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 | 181 | it("keeps an unchanged list item renderer asleep when a sibling item changes", () => { |
| 159 | 182 | const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) { |
| 160 | 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 | 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 | 372 | function renderFreshHtml( |
| 322 | 373 | content: string, |
| 323 | 374 | predict: boolean | undefined, |
tests/Memoizer.test.tsx+15| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import type { ReactNode } from "react"; |
| 2 | 2 | import { Fragment } from "react"; |
| 3 | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import projectReadme from "../README.md?raw"; | |
| 4 | 5 | import remarkGfm from "remark-gfm"; |
| 5 | 6 | import remarkParse from "remark-parse"; |
| 6 | 7 | import { unified } from "unified"; |
| ... | ... | @@ -193,6 +194,20 @@ describe("Memoizer incremental rendering", () => { |
| 193 | 194 | expect(second[2]).toBe(first[1]); |
| 194 | 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 | 213 | function createMemoizer(processor = defaultProcessor, predict = false) { |
tests/memoizedHastToReact.test.tsx+61| ... | ... | @@ -1,14 +1,17 @@ |
| 1 | 1 | import type { Element, ElementContent, Properties, Root, RootContent, Text } from "hast"; |
| 2 | 2 | import type { Components } from "rehype-react"; |
| 3 | 3 | import type { ReactElement, ReactNode } from "react"; |
| 4 | import projectReadme from "../README.md?raw"; | |
| 4 | 5 | import remarkParse from "remark-parse"; |
| 5 | 6 | import remarkRehype from "remark-rehype"; |
| 7 | import remarkGfm from "remark-gfm"; | |
| 6 | 8 | import { unified } from "unified"; |
| 7 | 9 | import type { Position } from "unist"; |
| 8 | 10 | import { expect, it } from "vitest"; |
| 9 | 11 | import { memoizedHastToReact, type RenderState } from "../src/hast.ts"; |
| 10 | 12 | |
| 11 | 13 | const markdownProcessor = unified().use(remarkParse).use(remarkRehype); |
| 14 | const gfmMarkdownProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype); | |
| 12 | 15 | type TestElementProps = Record<string, unknown> & { |
| 13 | 16 | children?: ReactNode; |
| 14 | 17 | className?: string; |
| ... | ... | @@ -199,6 +202,64 @@ it("uses configured components for matching tags", () => { |
| 199 | 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 | 263 | it("reuses equal custom-component subtrees when siblings change", () => { |
| 203 | 264 | function Link(_props: { href?: string; children?: ReactNode }) { |
| 204 | 265 | return null; |