authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 01:52:28-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-20 14:11:08-07:00
logb97903baac224e5529613b593fe793f6b8699d0f
tree432ff98d6fcf695365dbc677f33e1183d5b6f0bb
parent8dfe1d43f189dcc9d18b44480ea8fe2d3b021136
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

fix: children reordering


3 files changed, 62 insertions(+), 5 deletions(-)

src/hast.ts+18-2
......@@ -61,6 +61,15 @@ function recursiveMemoRender(
6161 const nextChildren: RenderState[] = [];
6262 const children: ReactNode[] = [];
6363 const countsByName = new Map<string, number>();
64 const usedKeys = new Set(previousChildren.map((child) => child.key).filter((key) => key !== undefined));
65 let prefixLength = 0;
66 for (; prefixLength < node.children.length && prefixLength < previousChildren.length; prefixLength += 1) {
67 if (!nodeDeepEquals(node.children[prefixLength], previousChildren[prefixLength]?.node)) break;
68 }
69 let suffixLength = 0;
70 for (; suffixLength < node.children.length - prefixLength && suffixLength < previousChildren.length - prefixLength; suffixLength += 1) {
71 if (!nodeDeepEquals(node.children[node.children.length - 1 - suffixLength], previousChildren[previousChildren.length - 1 - suffixLength]?.node)) break;
72 }
6473
6574 for (let i = 0; i < node.children.length; i += 1) {
6675 const child = node.children[i]!;
......@@ -70,8 +79,15 @@ function recursiveMemoRender(
7079 countsByName.set(child.tagName, count + 1);
7180 childKey = `${child.tagName}-${count}`;
7281 }
73
74 const childState = recursiveMemoRender(child, previousChildren[i] ?? null, options, childKey);
82 let previousIndex = -1;
83 if (i < prefixLength || node.children.length === previousChildren.length) previousIndex = i;
84 if (i >= node.children.length - suffixLength) {
85 previousIndex = previousChildren.length - (node.children.length - i);
86 }
87 if (previousIndex >= 0) childKey = previousChildren[previousIndex]?.key ?? childKey;
88 while (childKey && usedKeys.has(childKey) && previousChildren[previousIndex]?.key !== childKey) childKey = `${childKey}+`;
89 if (childKey) usedKeys.add(childKey);
90 const childState = recursiveMemoRender(child, previousChildren[previousIndex] ?? null, options, childKey);
7591 nextChildren.push(childState);
7692 if (childState.react !== undefined) children.push(childState.react);
7793 }
tests/Markdown.memoization.test.tsx+20
......@@ -341,6 +341,26 @@ it("keeps a stable block tied to its own DOM node when blocks are inserted or re
341341 expect(screen.getByRole("link")).toBe(stableLink);
342342});
343343
344it("keeps an unchanged list item tied to its DOM node when a sibling item is inserted above it", () => {
345 function ListItem(props: MarkdownProps<"li">) {
346 const instanceRef = useRef(Symbol("instance"));
347 return <li data-instance={String(instanceRef.current)} {...omitNode(props)} />;
348 }
349
350 const components = { li: ListItem } satisfies Partial<Components>;
351 const { rerender } = render(
352 <Markdown content={"- stable\n- tail"} components={components} />,
353 renderOptions,
354 );
355
356 const stableItem = screen.getByText("stable").closest("li");
357 expect(stableItem).not.toBeNull();
358
359 rerender(<Markdown content={"- inserted\n- stable\n- tail"} components={components} />);
360
361 expect(screen.getByText("stable").closest("li")).toBe(stableItem);
362});
363
344364it("keeps demo README strong renderers asleep when editing a different block", () => {
345365 const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) {
346366 return <strong data-testid="readme-strong" {...omitNode(props)} />;
tests/memoizedHastToReact.test.tsx+24-3
......@@ -165,7 +165,7 @@ it("reuses deep descendants through rebuilt ancestors", () => {
165165 expect(secondStrong).toBe(firstStrong);
166166});
167167
168it("does not reuse an equal subtree when its generated key changes", () => {
168it("reuses an equal subtree when a sibling of another tag is inserted above it", () => {
169169 const first = renderTree(
170170 root(element("span", [text("first")]), element("span", [text("stable")])),
171171 );
......@@ -180,8 +180,29 @@ it("does not reuse an equal subtree when its generated key changes", () => {
180180
181181 const secondStable = childAt(second.react, 1);
182182
183 expect(asElement(secondStable).key).toBe("span-0");
184 expect(secondStable).not.toBe(firstStable);
183 expect(asElement(secondStable).key).toBe("span-1");
184 expect(secondStable).toBe(firstStable);
185});
186
187it("preserves an unchanged list item when a sibling item is inserted above it", () => {
188 const first = renderTree(root(element("ul", [element("li", [text("stable")]), element("li", [text("tail")])])));
189 const firstList = childAt(first.react, 0);
190 const firstStable = childAt(firstList, 0);
191
192 const second = renderTree(
193 root(
194 element("ul", [
195 element("li", [text("inserted")]),
196 element("li", [text("stable")]),
197 element("li", [text("tail")]),
198 ]),
199 ),
200 first.state,
201 );
202 const secondList = childAt(second.react, 0);
203 const secondStable = childAt(secondList, 1);
204
205 expect(secondStable).toBe(firstStable);
185206});
186207
187208it("uses configured components for matching tags", () => {