authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 01:08:24-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 01:17:10-07:00
log37b340018b2ef7f2193a5f0680a68803f575ddda
treea7d9b22760eff367589a0c838b54061b314f080a
parent3d54937be677300a48ef3538513c4dcbfac920b3
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

fix: stop a case re-rendering the full document


4 files changed, 140 insertions(+), 5 deletions(-)

src/Memoizer.ts+13-5
......@@ -115,12 +115,20 @@ export class Memoizer {
115115 parsed.splice(blockStart, parsed.length - blockStart, ...newParsed);
116116 }
117117
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.
119121 if (nodeAffectsDocument(parsedTree)) {
120122 blockStart = 0;
121123 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);
124132 }
125133
126134 // For all affected blocks, transform their ASTs. This is done in
......@@ -187,7 +195,7 @@ export class Memoizer {
187195 continue;
188196 }
189197
190 keys[i] = keys[previousIndex] ?? String("m" + this.#nextKey++);
198 keys[i] = keys[previousIndex] ?? String(this.#nextKey++);
191199 renderStates[i] = renderStates[previousIndex] ?? null;
192200 if (reactNodes) reactNodes[i] = previousReactNodes[previousIndex];
193201 }
......@@ -196,7 +204,7 @@ export class Memoizer {
196204 if (positions.length < previousLength) {
197205 keys.length = renderStates.length = positions.length;
198206 }
199 for (let i = blockStart, len = positions.length; i < len; i += 1) {
207 for (let i = blockStart, len = nextMiddleEnd; i < len; i += 1) {
200208 const ast = UNWRAP(newTransformed[i - blockStart]);
201209 const previousState = renderStates[i] ?? null;
202210
tests/Markdown.memoization.test.tsx+51
......@@ -1,4 +1,5 @@
11import { cleanup, render, screen } from "@testing-library/react";
2import projectReadme from "../README.md?raw";
23import type { Components } from "rehype-react";
34import type { ComponentPropsWithoutRef, JSX } from "react";
45import { useRef } from "react";
......@@ -14,6 +15,7 @@ type MarkdownProps<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWith
1415};
1516
1617const renderOptions = { reactStrictMode: false } as const;
18const strictRenderOptions = { reactStrictMode: true } as const;
1719const gfmProcessor = unified().use(remarkParse).use(remarkGfm) as BaseProcessor;
1820
1921afterEach(() => {
......@@ -155,6 +157,27 @@ it("keeps an unchanged strong renderer asleep when surrounding paragraph text ch
155157 expect(Strong).toHaveBeenCalledTimes(1);
156158});
157159
160it("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
158181it("keeps an unchanged list item renderer asleep when a sibling item changes", () => {
159182 const ListItem = vi.fn(function ListItem(props: MarkdownProps<"li">) {
160183 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
318341 expect(screen.getByRole("link")).toBe(stableLink);
319342});
320343
344it("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
321372function renderFreshHtml(
322373 content: string,
323374 predict: boolean | undefined,
tests/Memoizer.test.tsx+15
......@@ -1,6 +1,7 @@
11import type { ReactNode } from "react";
22import { Fragment } from "react";
33import { renderToStaticMarkup } from "react-dom/server";
4import projectReadme from "../README.md?raw";
45import remarkGfm from "remark-gfm";
56import remarkParse from "remark-parse";
67import { unified } from "unified";
......@@ -193,6 +194,20 @@ describe("Memoizer incremental rendering", () => {
193194 expect(second[2]).toBe(first[1]);
194195 expect(third[1]).toBe(second[2]);
195196 });
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 });
196211});
197212
198213function createMemoizer(processor = defaultProcessor, predict = false) {
tests/memoizedHastToReact.test.tsx+61
......@@ -1,14 +1,17 @@
11import type { Element, ElementContent, Properties, Root, RootContent, Text } from "hast";
22import type { Components } from "rehype-react";
33import type { ReactElement, ReactNode } from "react";
4import projectReadme from "../README.md?raw";
45import remarkParse from "remark-parse";
56import remarkRehype from "remark-rehype";
7import remarkGfm from "remark-gfm";
68import { unified } from "unified";
79import type { Position } from "unist";
810import { expect, it } from "vitest";
911import { memoizedHastToReact, type RenderState } from "../src/hast.ts";
1012
1113const markdownProcessor = unified().use(remarkParse).use(remarkRehype);
14const gfmMarkdownProcessor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype);
1215type TestElementProps = Record<string, unknown> & {
1316 children?: ReactNode;
1417 className?: string;
......@@ -199,6 +202,64 @@ it("uses configured components for matching tags", () => {
199202 expect(childrenOf(link)).toEqual(["hello"]);
200203});
201204
205it("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
232it("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
202263it("reuses equal custom-component subtrees when siblings change", () => {
203264 function Link(_props: { href?: string; children?: ReactNode }) {
204265 return null;