From 4943b0126b7cece8bc800fc019ad7e38dec66e51 Mon Sep 17 00:00:00 2001 From: Aral Roca Gomez Date: Sat, 8 Aug 2026 12:48:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20shouldSkipChildren=20=E2=80=94=20leave?= =?UTF-8?q?=20a=20node's=20children=20to=20another=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node whose children are owned by an embedded renderer (a live React root) must not have its subtree diffed: the renderer's internal tree keeps node references, so text rewritten behind its back makes later renders bail out against content they never wrote, and a replacement by a different element that inherits (re-parents) those children makes the renderer's own teardown throw removeChild. The new option skips the subtree walk for matching nodes — attributes on the node itself still sync — never donates their children to a replacement, and keeps the settled reconciliation pass from counting the skipped (possibly still-streaming) children as pending work. Co-Authored-By: Claude Fable 5 --- README.md | 7 ++++ index.d.ts | 8 ++++ src/index.test.ts | 102 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 41 +++++++++++++++++-- 4 files changed, 154 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4bfb24e..2c4399b 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,13 @@ type Options = { // ignored node is never updated, moved or removed, and never counts as one // of the old children the diff has to prune (default: undefined) shouldIgnoreNode?: (node: Node | null) => boolean; + // callback to declare a node's children as another renderer's (a live + // embedded React root): the node itself still diffs — attributes sync as + // usual — but the diff never walks into its subtree, and a replacement by + // a different element never inherits (re-parents) those children. Called + // with nodes from BOTH trees, so the answer must come from the node itself + // (tag name, attributes), not from external identity (default: undefined) + shouldSkipChildren?: (node: Node | null) => boolean; }; ``` diff --git a/index.d.ts b/index.d.ts index 0fd8122..231a3c2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -10,4 +10,12 @@ type Options = { onNextNode?: NextNodeCallback; transition?: boolean; shouldIgnoreNode?: (node: Node | null) => boolean; + /** + * The node's children belong to another renderer (a live embedded React + * root): the diff updates the node itself — attributes sync as usual — but + * never walks into its subtree. Called with nodes from BOTH the live and + * the incoming tree, so the answer must come from the node itself (tag + * name, attributes), not from external identity. + */ + shouldSkipChildren?: (node: Node | null) => boolean; }; diff --git a/src/index.test.ts b/src/index.test.ts index 746869b..e61b683 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1748,6 +1748,100 @@ describe("Diff test", () => { ); }); + it("should options.shouldSkipChildren keep the old subtree while syncing the node's attributes", async () => { + // What an embedded framework root (a live React island) needs: the diff + // must not rewrite DOM another renderer owns — its fibers keep node + // references and later reconciliations bail out against text the morph + // changed behind their back, or throw removeChild on nodes it moved. + // The host element itself still belongs to the page (attributes sync); + // only its children are out of the diff's scope. + const [newHTML] = await testDiff({ + oldHTMLString: ` +
+
client
+

old

+
+ `, + newHTMLStringChunks: [ + "
server

new

", + ], + skipChildren: true, + }); + + expect(newHTML).toBe( + normalize(` + + + +
client

new

+ + + `), + ); + }); + + it("should options.shouldSkipChildren hold across chunk boundaries inside the skipped subtree", async () => { + // The skipped subtree may still be streaming when the walk reaches it: + // its children must neither be awaited (the walk moves on) nor applied + // once they arrive — including the settled reconciliation pass, which + // must not count them as unvisited work. + const [newHTML] = await testDiff({ + oldHTMLString: ` +
+
client
+

old

+
+ `, + newHTMLStringChunks: [ + "
ser", + "verextra

new

", + ], + skipChildren: true, + }); + + expect(newHTML).toBe( + normalize(` + + + +
client

new

+ + + `), + ); + }); + + it("should options.shouldSkipChildren never donate the old children to a different replacing element", async () => { + // Positional match against a DIFFERENT tag replaces the node, and the + // replacement normally inherits the old (already-diffed) children. A + // skipped node's children were never diffed and still belong to their + // renderer: moving them re-parents DOM its internal tree references + // (unmount then throws removeChild), so the incoming subtree applies + // wholesale and the old children leave the document with their node. + const [newHTML] = await testDiff({ + oldHTMLString: ` +
+
client
+
+ `, + newHTMLStringChunks: [ + "
fresh
", + ], + skipChildren: true, + }); + + expect(newHTML).toBe( + normalize(` + + + +
fresh
+ + + `), + ); + }); + it("should apply nodes the parser moves before the walk frontier (table foster parenting across chunks)", async () => { const [newHTML] = await testDiff({ oldHTMLString: ` @@ -2128,6 +2222,7 @@ describe("Diff test", () => { slowChunks = false, transition = false, ignoreId = false, + skipChildren = false, registerWC = false, onNextNode, }: { @@ -2147,6 +2242,7 @@ describe("Diff test", () => { slowChunks?: boolean; transition?: boolean; ignoreId?: boolean; + skipChildren?: boolean; registerWC?: boolean; onNextNode?: string }): Promise<[string, any[], Node[], boolean, any, string]> { @@ -2162,6 +2258,7 @@ describe("Diff test", () => { slowChunks, transition, ignoreId, + skipChildren, registerWC, onNextNode, ]) => { @@ -2263,6 +2360,10 @@ describe("Diff test", () => { if (!ignoreId) return false; return (node as Element)?.id === "ignore"; }, + shouldSkipChildren(node: Node) { + if (!skipChildren) return false; + return !!(node as Element)?.hasAttribute?.("data-skip-children"); + }, }); // @ts-ignore @@ -2282,6 +2383,7 @@ describe("Diff test", () => { slowChunks, transition, ignoreId, + skipChildren, registerWC, onNextNode, ], diff --git a/src/index.ts b/src/index.ts index 28e6d7f..3ba6cd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ type Walker = { [VISITED]: WeakSet; [SETTLE]: (node: Node) => Promise; [IGNORED]: (node: Node | null) => unknown; + [SKIP_CHILDREN]: (node: Node | null) => unknown; }; type NextNodeCallback = (node: Node) => void; @@ -19,6 +20,15 @@ type Options = { onNextNode?: NextNodeCallback; transition?: boolean; shouldIgnoreNode?: (node: Node | null) => boolean; + /** + * The node's children belong to another renderer (a live embedded React + * root): the diff updates the node itself — attributes sync as usual — but + * never walks into its subtree, so DOM that renderer's internal tree still + * references is not rewritten or moved behind its back. Called with nodes + * from BOTH the live and the incoming tree, so the answer must come from + * the node itself (tag name, attributes), not from external identity. + */ + shouldSkipChildren?: (node: Node | null) => boolean; }; const ELEMENT_TYPE = 1; @@ -30,6 +40,7 @@ const NEXT_SIBLING = 2; const VISITED = 3; const SETTLE = 4; const IGNORED = 5; +const SKIP_CHILDREN = 6; const SPECIAL_TAGS = new Set(["HTML", "HEAD", "BODY"]); /** @@ -76,9 +87,11 @@ export default async function diff( function hasUnvisited(node: Node | null, visited: WeakSet, options: Options): boolean { for (; node; node = node.nextSibling) { if (options.shouldIgnoreNode?.(node)) continue; - if (!visited.has(node) || hasUnvisited(node.firstChild, visited, options)) { - return true; - } + if (!visited.has(node)) return true; + // A skipped subtree may still be streaming when its host is walked, so its + // late children are unvisited by design — not pending work to reconcile. + if (options.shouldSkipChildren?.(node)) continue; + if (hasUnvisited(node.firstChild, visited, options)) return true; } return false; @@ -135,7 +148,26 @@ async function updateNode(oldNode: Node, newNode: Node, walker: Walker) { } if (oldNode.nodeType === ELEMENT_TYPE) { - await setChildNodes(oldNode, newNode, walker); + const skipChildren = !!walker[SKIP_CHILDREN](oldNode); + + // Children owned by another renderer are not the diff's to walk; the + // incoming ones count as applied so the settled pass leaves them alone too. + if (skipChildren) markSubtree(newNode, walker[VISITED]); + else await setChildNodes(oldNode, newNode, walker); + + // A skipped node replaced by a DIFFERENT element must not donate its + // children through the transplant below: another renderer still + // references them, and re-parenting makes its own teardown throw + // removeChild. The incoming subtree applies wholesale instead, and the + // old children leave the document attached to their old node. + if (skipChildren && oldNode.nodeName !== newNode.nodeName) { + await walker[SETTLE](newNode); + markSubtree(newNode, walker[VISITED]); + + return walker[APPLY_TRANSITION](() => + oldNode.parentNode!.replaceChild(newNode.cloneNode(true), oldNode), + ); + } walker[APPLY_TRANSITION](() => { if (oldNode.nodeName === newNode.nodeName) { @@ -441,6 +473,7 @@ async function htmlStreamWalker( }, [VISITED]: visited, [IGNORED]: (node) => options.shouldIgnoreNode?.(node), + [SKIP_CHILDREN]: (node) => options.shouldSkipChildren?.(node), // Waits until the node stops being the parser's frontier (or the stream // ends), so cloning it deeply cannot snapshot a half-parsed subtree. [SETTLE]: async (node: Node) => {