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: `
+
+ `,
+ newHTMLStringChunks: [
+ "",
+ ],
+ skipChildren: true,
+ });
+
+ expect(newHTML).toBe(
+ normalize(`
+
+
+
+
+
+
+ `),
+ );
+ });
+
+ 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: `
+
+ `,
+ newHTMLStringChunks: [
+ "",
+ ],
+ skipChildren: true,
+ });
+
+ expect(newHTML).toBe(
+ normalize(`
+
+
+
+
+
+
+ `),
+ );
+ });
+
+ 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: `
+
+
+
+ `,
+ newHTMLStringChunks: [
+ "",
+ ],
+ skipChildren: true,
+ });
+
+ expect(newHTML).toBe(
+ normalize(`
+
+
+
+
+
+
+ `),
+ );
+ });
+
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) => {