diff --git a/src/Memoizer.ts b/src/Memoizer.ts index 2586edc02dab0139915bab796e0767a068689cf7..0ace2982418b420bd56fdc07195cbf9b1f5a687e 100644 --- a/src/Memoizer.ts +++ b/src/Memoizer.ts @@ -115,12 +115,20 @@ export class Memoizer { parsed.splice(blockStart, parsed.length - blockStart, ...newParsed); } - // If a reflink is involved, bring in the entire parsed AST for transformation. + // If a reflink is involved, fall back to a full reparse. Rehydrating a + // synthetic root from cached blocks can lose source offsets on transformed + // top-level nodes, which would scramble block bucketing and key identity. if (nodeAffectsDocument(parsedTree)) { blockStart = 0; parseOffset = 0; - newPositions = positions; - parsedTree = { type: "root", children: parsed.flat() }; + parsedTree = processor.parse(content); + newPositions = parsedTree.children.map( + (child) => UNWRAP(UNWRAP(child.position).start.offset) + parseOffset, + ); + positions.splice(0, positions.length, ...newPositions); + const newParsed = extractBlocks(parsedTree, newPositions, parseOffset); + ASSERT(newParsed.length === newPositions.length); + parsed.splice(0, parsed.length, ...newParsed); } // For all affected blocks, transform their ASTs. This is done in @@ -187,7 +195,7 @@ export class Memoizer { continue; } - keys[i] = keys[previousIndex] ?? String("m" + this.#nextKey++); + keys[i] = keys[previousIndex] ?? String(this.#nextKey++); renderStates[i] = renderStates[previousIndex] ?? null; if (reactNodes) reactNodes[i] = previousReactNodes[previousIndex]; } @@ -196,7 +204,7 @@ export class Memoizer { if (positions.length < previousLength) { keys.length = renderStates.length = positions.length; } - for (let i = blockStart, len = positions.length; i < len; i += 1) { + for (let i = blockStart, len = nextMiddleEnd; i < len; i += 1) { const ast = UNWRAP(newTransformed[i - blockStart]); const previousState = renderStates[i] ?? null; diff --git a/tests/Markdown.memoization.test.tsx b/tests/Markdown.memoization.test.tsx index 8e67434fe28ca6c01f6915cc427a6d9979b7d810..d832a5bc0f0ea7608bcdd2ccc1f6cc32a5e6c92b 100644 --- a/tests/Markdown.memoization.test.tsx +++ b/tests/Markdown.memoization.test.tsx @@ -1,4 +1,5 @@ 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"; @@ -14,6 +15,7 @@ type MarkdownProps = ComponentPropsWith }; const renderOptions = { reactStrictMode: false } as const; +const strictRenderOptions = { reactStrictMode: true } as const; const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor; afterEach(() => { @@ -155,6 +157,27 @@ it("keeps an unchanged strong renderer asleep when surrounding paragraph text ch 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
  • ; @@ -318,6 +341,34 @@ it("keeps a stable block tied to its own DOM node when blocks are inserted or re expect(screen.getByRole("link")).toBe(stableLink); }); +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, diff --git a/tests/Memoizer.test.tsx b/tests/Memoizer.test.tsx index 26ee0f3a44136d3678d1c8475dc41f8a19708882..0e3df355c15afa3d828131e6aa77ff685d56a532 100644 --- a/tests/Memoizer.test.tsx +++ b/tests/Memoizer.test.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { Fragment } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import projectReadme from "../README.md?raw"; import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import { unified } from "unified"; @@ -193,6 +194,20 @@ describe("Memoizer incremental rendering", () => { expect(second[2]).toBe(first[1]); expect(third[1]).toBe(second[2]); }); + + it("reuses the README list block react node when editing an earlier paragraph", () => { + const memoizer = createMemoizer(gfmProcessor); + 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 first = memoizer.update(original); + const second = memoizer.update(edited); + + expect(second[2]).toBe(first[2]); + }); }); function createMemoizer(processor = defaultProcessor, predict = false) { diff --git a/tests/memoizedHastToReact.test.tsx b/tests/memoizedHastToReact.test.tsx index fcffe5d1898fcb69f62f2759f902f04e559296a5..4258782e0ca650fa3747747143417b825f2bc1ff 100644 --- a/tests/memoizedHastToReact.test.tsx +++ b/tests/memoizedHastToReact.test.tsx @@ -1,14 +1,17 @@ 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 remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; +import remarkGfm from "remark-gfm"; import { unified } from "unified"; import type { Position } from "unist"; import { expect, it } from "vitest"; import { memoizedHastToReact, type RenderState } from "../src/hast.ts"; const markdownProcessor = unified().use(remarkParse).use(remarkRehype); +const gfmMarkdownProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype); type TestElementProps = Record & { children?: ReactNode; className?: string; @@ -199,6 +202,64 @@ it("uses configured components for matching tags", () => { expect(childrenOf(link)).toEqual(["hello"]); }); +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]", + "This package exports a React component to render memoized Markdown using the [unified]", + ); + + const originalTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(original)); + const editedTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(edited)); + const originalList = getElement( + originalTree.children.find( + (child: RootContent) => child.type === "element" && child.tagName === "ul", + ), + ); + const editedList = getElement( + editedTree.children.find( + (child: RootContent) => child.type === "element" && child.tagName === "ul", + ), + ); + + const first = renderTree(root(originalList)); + const second = renderTree(root(editedList), first.state); + + expect(second.react).toBe(first.react); + expect(second.state).toBe(first.state); +}); + +it("reuses the README list block with a custom strong component when an earlier block changes", () => { + function Strong(props: { children?: ReactNode }) { + return {props.children}; + } + + 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 originalTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(original)); + const editedTree = gfmMarkdownProcessor.runSync(gfmMarkdownProcessor.parse(edited)); + const originalList = getElement( + originalTree.children.find( + (child: RootContent) => child.type === "element" && child.tagName === "ul", + ), + ); + const editedList = getElement( + editedTree.children.find( + (child: RootContent) => child.type === "element" && child.tagName === "ul", + ), + ); + + const first = renderTree(root(originalList), null, { strong: Strong }); + const second = renderTree(root(editedList), first.state, { strong: Strong }); + + expect(second.react).toBe(first.react); + expect(second.state).toBe(first.state); +}); + it("reuses equal custom-component subtrees when siblings change", () => { function Link(_props: { href?: string; children?: ReactNode }) { return null;