From b97903baac224e5529613b593fe793f6b8699d0f Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 20 Mar 2026 01:52:28 -0700 Subject: [PATCH] fix: children reordering --- src/hast.ts | 20 ++++++++++++++++++-- tests/Markdown.memoization.test.tsx | 20 ++++++++++++++++++++ tests/memoizedHastToReact.test.tsx | 27 ++++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/hast.ts b/src/hast.ts index 41dfff46f6451c092e37b43c77d5873b6198a715..f2b7e352d1fe3ca349316e7888b81d0ca7a2dae7 100644 --- a/src/hast.ts +++ b/src/hast.ts @@ -61,6 +61,15 @@ function recursiveMemoRender( const nextChildren: RenderState[] = []; const children: ReactNode[] = []; const countsByName = new Map(); + const usedKeys = new Set(previousChildren.map((child) => child.key).filter((key) => key !== undefined)); + let prefixLength = 0; + for (; prefixLength < node.children.length && prefixLength < previousChildren.length; prefixLength += 1) { + if (!nodeDeepEquals(node.children[prefixLength], previousChildren[prefixLength]?.node)) break; + } + let suffixLength = 0; + for (; suffixLength < node.children.length - prefixLength && suffixLength < previousChildren.length - prefixLength; suffixLength += 1) { + if (!nodeDeepEquals(node.children[node.children.length - 1 - suffixLength], previousChildren[previousChildren.length - 1 - suffixLength]?.node)) break; + } for (let i = 0; i < node.children.length; i += 1) { const child = node.children[i]!; @@ -70,8 +79,15 @@ function recursiveMemoRender( countsByName.set(child.tagName, count + 1); childKey = `${child.tagName}-${count}`; } - - const childState = recursiveMemoRender(child, previousChildren[i] ?? null, options, childKey); + let previousIndex = -1; + if (i < prefixLength || node.children.length === previousChildren.length) previousIndex = i; + if (i >= node.children.length - suffixLength) { + previousIndex = previousChildren.length - (node.children.length - i); + } + if (previousIndex >= 0) childKey = previousChildren[previousIndex]?.key ?? childKey; + while (childKey && usedKeys.has(childKey) && previousChildren[previousIndex]?.key !== childKey) childKey = `${childKey}+`; + if (childKey) usedKeys.add(childKey); + const childState = recursiveMemoRender(child, previousChildren[previousIndex] ?? null, options, childKey); nextChildren.push(childState); if (childState.react !== undefined) children.push(childState.react); } diff --git a/tests/Markdown.memoization.test.tsx b/tests/Markdown.memoization.test.tsx index d832a5bc0f0ea7608bcdd2ccc1f6cc32a5e6c92b..ac81e801ac4e6466e6d16f9a507b7bc89374a6c9 100644 --- a/tests/Markdown.memoization.test.tsx +++ b/tests/Markdown.memoization.test.tsx @@ -341,6 +341,26 @@ 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 an unchanged list item tied to its DOM node when a sibling item is inserted above it", () => { + function ListItem(props: MarkdownProps<"li">) { + const instanceRef = useRef(Symbol("instance")); + return
  • ; + } + + const components = { li: ListItem } satisfies Partial; + const { rerender } = render( + , + renderOptions, + ); + + const stableItem = screen.getByText("stable").closest("li"); + expect(stableItem).not.toBeNull(); + + rerender(); + + expect(screen.getByText("stable").closest("li")).toBe(stableItem); +}); + it("keeps demo README strong renderers asleep when editing a different block", () => { const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { return ; diff --git a/tests/memoizedHastToReact.test.tsx b/tests/memoizedHastToReact.test.tsx index 4258782e0ca650fa3747747143417b825f2bc1ff..e9b3a44ab3d6c700249b038a24f8df077cdd436b 100644 --- a/tests/memoizedHastToReact.test.tsx +++ b/tests/memoizedHastToReact.test.tsx @@ -165,7 +165,7 @@ it("reuses deep descendants through rebuilt ancestors", () => { expect(secondStrong).toBe(firstStrong); }); -it("does not reuse an equal subtree when its generated key changes", () => { +it("reuses an equal subtree when a sibling of another tag is inserted above it", () => { const first = renderTree( root(element("span", [text("first")]), element("span", [text("stable")])), ); @@ -180,8 +180,29 @@ it("does not reuse an equal subtree when its generated key changes", () => { const secondStable = childAt(second.react, 1); - expect(asElement(secondStable).key).toBe("span-0"); - expect(secondStable).not.toBe(firstStable); + expect(asElement(secondStable).key).toBe("span-1"); + expect(secondStable).toBe(firstStable); +}); + +it("preserves an unchanged list item when a sibling item is inserted above it", () => { + const first = renderTree(root(element("ul", [element("li", [text("stable")]), element("li", [text("tail")])]))); + const firstList = childAt(first.react, 0); + const firstStable = childAt(firstList, 0); + + const second = renderTree( + root( + element("ul", [ + element("li", [text("inserted")]), + element("li", [text("stable")]), + element("li", [text("tail")]), + ]), + ), + first.state, + ); + const secondList = childAt(second.react, 0); + const secondStable = childAt(secondList, 1); + + expect(secondStable).toBe(firstStable); }); it("uses configured components for matching tags", () => { -- 2.54.0