From e56fff1c810f75bf3309ea27e8a0bf9fed78a1d5 Mon Sep 17 00:00:00 2001 From: Aral Roca Date: Fri, 24 Jul 2026 16:49:54 +0200 Subject: [PATCH 1/3] =?UTF-8?q?test:=20failing=20cases=20=E2=80=94=20shoul?= =?UTF-8?q?dIgnoreNode=20first-child=20skip=20+=20foster-parented=20node?= =?UTF-8?q?=20across=20chunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also: launch Chrome in beforeAll for the View Transitions describe (bun >=1.3 fails tests whose hooks throw; 1.2.9 swallowed the undefined browser). --- src/index.test.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) 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: ` From 4be80876291e96b5bb035f7ebcc98adeba627a0f Mon Sep 17 00:00:00 2001 From: Aral Roca Date: Fri, 24 Jul 2026 16:54:47 +0200 Subject: [PATCH 2/3] fix: never lose nodes the parser relocates behind the walk frontier; skip ignored nodes by sibling Three related walker fixes: - shouldIgnoreNode hopped by the walk field, so an ignored FIRST child made the walker descend into the ignored node's subtree instead of skipping to its sibling. - The HTML parser can move a late chunk's nodes BEFORE positions the walker already passed (table foster parenting): diff() now runs one settled reconciliation pass over the fully-parsed document (no waits, callbacks not replayed) so those nodes always land. Converged trees make it a no-op. - setAttributes stole Attr nodes from the streamed tree (setNamedItemNS moves them), which made the settled pass see them as removed; new attributes are cloned instead. --- src/index.ts | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index f304f35..bc0be7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,15 @@ 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), so reconcile once against the settled + // document — a cheap pass over an already-converged tree (onNextNode is not + // replayed; transitions already ran during the streamed pass). + await applyRoot(oldNode, newNode, settledWalker(walker, options)); +} + +async function applyRoot(oldNode: Node, newNode: Node, walker: Walker) { if (newNode.nodeType === DOCUMENT_FRAGMENT_TYPE) { await setChildNodes(oldNode, newNode, walker); } else { @@ -50,6 +59,26 @@ 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(), + }; +} + /** * Updates a specific htmlNode and does whatever it takes to convert it to another one. */ @@ -114,9 +143,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; @@ -253,8 +283,10 @@ async function htmlStreamWalker( 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); From 07e6a19874b79bf1a875f0da199f4aab9da813bb Mon Sep 17 00:00:00 2001 From: Aral Roca Date: Fri, 24 Jul 2026 17:45:53 +0200 Subject: [PATCH 3/3] fix: gate the settled reconciliation pass on unvisited nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unconditional second pass fought DOM mutations made between insertion and stream end by the page's own code (custom element connectedCallback, scripts). The stream walker now tracks visited nodes (clone-inserted subtrees count as walked) and the reconciliation only runs when the settled document contains nodes the walk never saw — fully-walked pages skip it entirely. --- src/index.ts | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index bc0be7f..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)); @@ -45,10 +47,32 @@ export default async function diff( await applyRoot(oldNode, newNode, walker); // The HTML parser can relocate a late chunk's nodes BEFORE the walk frontier - // (e.g. table foster parenting), so reconcile once against the settled - // document — a cheap pass over an already-converged tree (onNextNode is not - // replayed; transitions already ran during the streamed pass). - await applyRoot(oldNode, newNode, settledWalker(walker, options)); + // (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) { @@ -76,6 +100,7 @@ function settledWalker(walker: Walker, options: Options = {}): Walker { [FIRST_CHILD]: hop("firstChild"), [NEXT_SIBLING]: hop("nextSibling"), [APPLY_TRANSITION]: (v) => v(), + [VISITED]: walker[VISITED], }; } @@ -84,6 +109,8 @@ function settledWalker(walker: Walker, options: Options = {}): Walker { */ 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), ); @@ -205,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!), @@ -213,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!)); } @@ -277,6 +306,8 @@ async function htmlStreamWalker( await wait(); } + const visited = new WeakSet(); + function next(field: "firstChild" | "nextSibling") { return async (node: Node) => { if (!node) return null; @@ -289,7 +320,10 @@ async function htmlStreamWalker( nextNode = nextNode!.nextSibling; } - if (nextNode) await options.onNextNode?.(nextNode); + if (nextNode) { + visited.add(nextNode); + await options.onNextNode?.(nextNode); + } const waitChildren = field === "firstChild"; @@ -325,6 +359,8 @@ async function htmlStreamWalker( : streamInProgress } + if (doc.documentElement) visited.add(doc.documentElement); + return { root: doc.documentElement, [FIRST_CHILD]: next("firstChild"), @@ -335,5 +371,6 @@ async function htmlStreamWalker( window.lastDiffTransition = document.startViewTransition(v); } else v(); }, + [VISITED]: visited, }; }