Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
```

Expand Down
8 changes: 8 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
102 changes: 102 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<div>
<section data-skip-children="x" data-v="1"><span>client</span></section>
<p>old</p>
</div>
`,
newHTMLStringChunks: [
"<html><head></head><body><div><section data-skip-children='x' data-v='2'><span>server</span></section><p>new</p></div></body></html>",
],
skipChildren: true,
});

expect(newHTML).toBe(
normalize(`
<html>
<head></head>
<body>
<div><section data-skip-children="x" data-v="2"><span>client</span></section><p>new</p></div>
</body>
</html>
`),
);
});

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: `
<div>
<section data-skip-children="x"><span>client</span></section>
<p>old</p>
</div>
`,
newHTMLStringChunks: [
"<html><head></head><body><div><section data-skip-children='x'><span>ser",
"ver</span><i>extra</i></section><p>new</p></div></body></html>",
],
skipChildren: true,
});

expect(newHTML).toBe(
normalize(`
<html>
<head></head>
<body>
<div><section data-skip-children="x"><span>client</span></section><p>new</p></div>
</body>
</html>
`),
);
});

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: `
<div>
<section data-skip-children="x"><span>client</span></section>
</div>
`,
newHTMLStringChunks: [
"<html><head></head><body><div><article><b>fresh</b></article></div></body></html>",
],
skipChildren: true,
});

expect(newHTML).toBe(
normalize(`
<html>
<head></head>
<body>
<div><article><b>fresh</b></article></div>
</body>
</html>
`),
);
});

it("should apply nodes the parser moves before the walk frontier (table foster parenting across chunks)", async () => {
const [newHTML] = await testDiff({
oldHTMLString: `
Expand Down Expand Up @@ -2128,6 +2222,7 @@ describe("Diff test", () => {
slowChunks = false,
transition = false,
ignoreId = false,
skipChildren = false,
registerWC = false,
onNextNode,
}: {
Expand All @@ -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]> {
Expand All @@ -2162,6 +2258,7 @@ describe("Diff test", () => {
slowChunks,
transition,
ignoreId,
skipChildren,
registerWC,
onNextNode,
]) => {
Expand Down Expand Up @@ -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
Expand All @@ -2282,6 +2383,7 @@ describe("Diff test", () => {
slowChunks,
transition,
ignoreId,
skipChildren,
registerWC,
onNextNode,
],
Expand Down
41 changes: 37 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type Walker = {
[VISITED]: WeakSet<Node>;
[SETTLE]: (node: Node) => Promise<void>;
[IGNORED]: (node: Node | null) => unknown;
[SKIP_CHILDREN]: (node: Node | null) => unknown;
};

type NextNodeCallback = (node: Node) => void;
Expand All @@ -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;
Expand All @@ -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"]);

/**
Expand Down Expand Up @@ -76,9 +87,11 @@ export default async function diff(
function hasUnvisited(node: Node | null, visited: WeakSet<Node>, 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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down
Loading