diff --git a/src/index.test.ts b/src/index.test.ts index 29d9e70..2b3e294 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -44,8 +44,11 @@ describe("Diff test", () => { }); describe("Chrome View Transitions API", () => { - it("should not call document.startViewTransition for each DOM update with transition=false", async () => { + beforeAll(async () => { browser = await engine.chrome.launch(); + }); + + it("should not call document.startViewTransition for each DOM update with transition=false", async () => { const [newHTML, , , transitionApplied] = await testDiff({ oldHTMLString: `
@@ -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: ` +
+
x
+
+ `, + newHTMLStringChunks: [ + "
", + "oops
y
", + ], + slowChunks: true, + }); + expect(newHTML).toBe( + normalize(` + + + +
oops
y
+ + + `), + ); + }); + 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, }; }