@@ -70,7 +73,6 @@ describe("Diff test", () => {
expect(transitionApplied).toBeFalse();
});
it("should call document.startViewTransition for each DOM update with transition=true", async () => {
- browser = await engine.chrome.launch();
const [newHTML, , , transitionApplied] = await testDiff({
oldHTMLString: `
@@ -1638,6 +1640,56 @@ describe("Diff test", () => {
);
});
+ it("should options.shouldIgnoreNode skip an ignored node followed by a sibling", async () => {
+ const [newHTML] = await testDiff({
+ oldHTMLString: `
+
+ skip
+ old
+
+ `,
+ newHTMLStringChunks: [
+ "
skipnew
",
+ ],
+ ignoreId: true,
+ });
+ expect(newHTML).toBe(
+ normalize(`
+
+
+
+
new
+
+
+ `),
+ );
+ });
+
+ it("should apply nodes the parser moves before the walk frontier (table foster parenting across chunks)", async () => {
+ const [newHTML] = await testDiff({
+ oldHTMLString: `
+
+ `,
+ newHTMLStringChunks: [
+ "
",
+ ],
+ slowChunks: true,
+ });
+ expect(newHTML).toBe(
+ normalize(`
+
+
+
+
+
+
+ `),
+ );
+ });
+
it("should add WC that modifies the DOM on connect it", async () => {
const [newHTML] = await testDiff({
oldHTMLString: `
diff --git a/src/index.ts b/src/index.ts
index f304f35..e8c28da 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,6 +12,7 @@ type Walker = {
[FIRST_CHILD]: (node: Node) => Promise
;
[NEXT_SIBLING]: (node: Node) => Promise;
[APPLY_TRANSITION]: (v: () => void) => void;
+ [VISITED]: WeakSet;
};
type NextNodeCallback = (node: Node) => void;
@@ -28,6 +29,7 @@ const DOCUMENT_FRAGMENT_TYPE = 11;
const APPLY_TRANSITION = 0;
const FIRST_CHILD = 1;
const NEXT_SIBLING = 2;
+const VISITED = 3;
const SPECIAL_TAGS = new Set(["HTML", "HEAD", "BODY"]);
const wait = () => new Promise((resolve) => requestAnimationFrame(resolve));
@@ -43,6 +45,37 @@ export default async function diff(
oldNode = (oldNode as Document).documentElement;
}
+ await applyRoot(oldNode, newNode, walker);
+ // The HTML parser can relocate a late chunk's nodes BEFORE the walk frontier
+ // (e.g. table foster parenting), leaving nodes the streamed walk never saw.
+ // Only then reconcile once against the settled document (onNextNode is not
+ // replayed; transitions already ran) — fully-walked pages skip this, so DOM
+ // mutations made meanwhile by custom elements/scripts are left alone.
+ if (hasUnvisited(newNode, walker[VISITED], options ?? {})) {
+ await applyRoot(oldNode, newNode, settledWalker(walker, options));
+ }
+}
+
+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;
+ }
+ }
+
+ return false;
+}
+
+/** Clone-inserted subtrees are applied wholesale — count them as walked. */
+function markSubtree(node: Node, visited: WeakSet) {
+ visited.add(node);
+ for (let child = node.firstChild; child; child = child.nextSibling) {
+ markSubtree(child, visited);
+ }
+}
+
+async function applyRoot(oldNode: Node, newNode: Node, walker: Walker) {
if (newNode.nodeType === DOCUMENT_FRAGMENT_TYPE) {
await setChildNodes(oldNode, newNode, walker);
} else {
@@ -50,11 +83,34 @@ export default async function diff(
}
}
+/** Walker over the fully-parsed streamed document: no waits, no callbacks. */
+function settledWalker(walker: Walker, options: Options = {}): Walker {
+ const hop = (field: "firstChild" | "nextSibling") => async (node: Node) => {
+ let nextNode = node[field];
+
+ while (options.shouldIgnoreNode?.(nextNode)) {
+ nextNode = nextNode!.nextSibling;
+ }
+
+ return nextNode;
+ };
+
+ return {
+ root: walker.root,
+ [FIRST_CHILD]: hop("firstChild"),
+ [NEXT_SIBLING]: hop("nextSibling"),
+ [APPLY_TRANSITION]: (v) => v(),
+ [VISITED]: walker[VISITED],
+ };
+}
+
/**
* Updates a specific htmlNode and does whatever it takes to convert it to another one.
*/
async function updateNode(oldNode: Node, newNode: Node, walker: Walker) {
if (oldNode.nodeType !== newNode.nodeType) {
+ markSubtree(newNode, walker[VISITED]);
+
return walker[APPLY_TRANSITION](() =>
oldNode.parentNode!.replaceChild(newNode.cloneNode(true), oldNode),
);
@@ -114,9 +170,10 @@ function setAttributes(
if (oldAttribute.name === "data-action") continue;
if (!newAttribute) {
- // Add a new attribute.
- newAttributes.removeNamedItemNS(namespace, name);
- oldAttributes.setNamedItemNS(oldAttribute);
+ // Add a new attribute — cloned: setNamedItemNS would STEAL the Attr node
+ // from the streamed tree, and the settled reconciliation pass would then
+ // see it missing there and remove it right back.
+ oldAttributes.setNamedItemNS(oldAttribute.cloneNode(true) as Attr);
} else if (newAttribute.value !== oldAttribute.value) {
// Update existing attribute.
newAttribute.value = oldAttribute.value;
@@ -175,6 +232,7 @@ async function setChildNodes(oldParent: Node, newParent: Node, walker: Walker) {
checkOld = oldNode;
oldNode = oldNode.nextSibling;
if (getKey(checkOld)) {
+ markSubtree(newNode, walker[VISITED]);
insertedNode = newNode.cloneNode(true);
walker[APPLY_TRANSITION](() =>
oldParent.insertBefore(insertedNode!, checkOld!),
@@ -183,6 +241,7 @@ async function setChildNodes(oldParent: Node, newParent: Node, walker: Walker) {
await updateNode(checkOld, newNode, walker);
}
} else {
+ markSubtree(newNode, walker[VISITED]);
insertedNode = newNode.cloneNode(true);
walker[APPLY_TRANSITION](() => oldParent.appendChild(insertedNode!));
}
@@ -247,17 +306,24 @@ async function htmlStreamWalker(
await wait();
}
+ const visited = new WeakSet();
+
function next(field: "firstChild" | "nextSibling") {
return async (node: Node) => {
if (!node) return null;
let nextNode = node[field];
+ // Hop over ignored nodes by SIBLING: hopping by `field` would descend
+ // INTO an ignored first child instead of skipping it.
while (options.shouldIgnoreNode?.(nextNode)) {
- nextNode = nextNode![field];
+ nextNode = nextNode!.nextSibling;
}
- if (nextNode) await options.onNextNode?.(nextNode);
+ if (nextNode) {
+ visited.add(nextNode);
+ await options.onNextNode?.(nextNode);
+ }
const waitChildren = field === "firstChild";
@@ -293,6 +359,8 @@ async function htmlStreamWalker(
: streamInProgress
}
+ if (doc.documentElement) visited.add(doc.documentElement);
+
return {
root: doc.documentElement,
[FIRST_CHILD]: next("firstChild"),
@@ -303,5 +371,6 @@ async function htmlStreamWalker(
window.lastDiffTransition = document.startViewTransition(v);
} else v();
},
+ [VISITED]: visited,
};
}