| author | |
| committer | |
| log | b97903baac224e5529613b593fe793f6b8699d0f |
| tree | 432ff98d6fcf695365dbc677f33e1183d5b6f0bb |
| parent | 8dfe1d43f189dcc9d18b44480ea8fe2d3b021136 |
| signature |
3 files changed, 62 insertions(+), 5 deletions(-)
src/hast.ts+18-2| ... | @@ -61,6 +61,15 @@ function recursiveMemoRender( | ... | @@ -61,6 +61,15 @@ function recursiveMemoRender( |
| 61 | const nextChildren: RenderState[] = []; | 61 | const nextChildren: RenderState[] = []; |
| 62 | const children: ReactNode[] = []; | 62 | const children: ReactNode[] = []; |
| 63 | const countsByName = new Map<string, number>(); | 63 | 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 | } | ||
| 64 | 73 | ||
| 65 | for (let i = 0; i < node.children.length; i += 1) { | 74 | for (let i = 0; i < node.children.length; i += 1) { |
| 66 | const child = node.children[i]!; | 75 | const child = node.children[i]!; |
| ... | @@ -70,8 +79,15 @@ function recursiveMemoRender( | ... | @@ -70,8 +79,15 @@ function recursiveMemoRender( |
| 70 | countsByName.set(child.tagName, count + 1); | 79 | countsByName.set(child.tagName, count + 1); |
| 71 | childKey = `${child.tagName}-${count}`; | 80 | childKey = `${child.tagName}-${count}`; |
| 72 | } | 81 | } |
| 73 | 82 | let previousIndex = -1; | |
| 74 | const childState = recursiveMemoRender(child, previousChildren[i] ?? null, options, childKey); | 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); | ||
| 75 | nextChildren.push(childState); | 91 | nextChildren.push(childState); |
| 76 | if (childState.react !== undefined) children.push(childState.react); | 92 | if (childState.react !== undefined) children.push(childState.react); |
| 77 | } | 93 | } |
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 | ... | @@ -341,6 +341,26 @@ it("keeps a stable block tied to its own DOM node when blocks are inserted or re |
| 341 | expect(screen.getByRole("link")).toBe(stableLink); | 341 | expect(screen.getByRole("link")).toBe(stableLink); |
| 342 | }); | 342 | }); |
| 343 | 343 | ||
| 344 | it("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 | |||
| 344 | it("keeps demo README strong renderers asleep when editing a different block", () => { | 364 | it("keeps demo README strong renderers asleep when editing a different block", () => { |
| 345 | const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { | 365 | const Strong = vi.fn(function Strong(props: MarkdownProps<"strong">) { |
| 346 | return <strong data-testid="readme-strong" {...omitNode(props)} />; | 366 | 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", () => { | ... | @@ -165,7 +165,7 @@ it("reuses deep descendants through rebuilt ancestors", () => { |
| 165 | expect(secondStrong).toBe(firstStrong); | 165 | expect(secondStrong).toBe(firstStrong); |
| 166 | }); | 166 | }); |
| 167 | 167 | ||
| 168 | it("does not reuse an equal subtree when its generated key changes", () => { | 168 | it("reuses an equal subtree when a sibling of another tag is inserted above it", () => { |
| 169 | const first = renderTree( | 169 | const first = renderTree( |
| 170 | root(element("span", [text("first")]), element("span", [text("stable")])), | 170 | root(element("span", [text("first")]), element("span", [text("stable")])), |
| 171 | ); | 171 | ); |
| ... | @@ -180,8 +180,29 @@ it("does not reuse an equal subtree when its generated key changes", () => { | ... | @@ -180,8 +180,29 @@ it("does not reuse an equal subtree when its generated key changes", () => { |
| 180 | 180 | ||
| 181 | const secondStable = childAt(second.react, 1); | 181 | const secondStable = childAt(second.react, 1); |
| 182 | 182 | ||
| 183 | expect(asElement(secondStable).key).toBe("span-0"); | 183 | expect(asElement(secondStable).key).toBe("span-1"); |
| 184 | expect(secondStable).not.toBe(firstStable); | 184 | expect(secondStable).toBe(firstStable); |
| 185 | }); | ||
| 186 | |||
| 187 | it("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); | ||
| 185 | }); | 206 | }); |
| 186 | 207 | ||
| 187 | it("uses configured components for matching tags", () => { | 208 | it("uses configured components for matching tags", () => { |