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
56 changes: 54 additions & 2 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<div>
Expand All @@ -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: `
<div>
Expand Down Expand Up @@ -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: `
<div>
<span id="ignore">skip</span>
<b>old</b>
</div>
`,
newHTMLStringChunks: [
"<html><head></head><body><div><span id='ignore'>skip</span><b>new</b></div></body></html>",
],
ignoreId: true,
});
expect(newHTML).toBe(
normalize(`
<html>
<head></head>
<body>
<div><b>new</b></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: `
<div>
<table><tbody><tr><td>x</td></tr></tbody></table>
</div>
`,
newHTMLStringChunks: [
"<html><head></head><body><div><table><tbody><tr><td>y</td></tr>",
"oops</tbody></table></div></body></html>",
],
slowChunks: true,
});
expect(newHTML).toBe(
normalize(`
<html>
<head></head>
<body>
<div>oops<table><tbody><tr><td>y</td></tr></tbody></table></div>
</body>
</html>
`),
);
});

it("should add WC that modifies the DOM on connect it", async () => {
const [newHTML] = await testDiff({
oldHTMLString: `
Expand Down
79 changes: 74 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Walker = {
[FIRST_CHILD]: (node: Node) => Promise<Node | null>;
[NEXT_SIBLING]: (node: Node) => Promise<Node | null>;
[APPLY_TRANSITION]: (v: () => void) => void;
[VISITED]: WeakSet<Node>;
};

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

Expand All @@ -43,18 +45,72 @@ 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<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;
}
}

return false;
}

/** Clone-inserted subtrees are applied wholesale — count them as walked. */
function markSubtree(node: Node, visited: WeakSet<Node>) {
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 {
await updateNode(oldNode, newNode, walker);
}
}

/** 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),
);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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!),
Expand All @@ -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!));
}
Expand Down Expand Up @@ -247,17 +306,24 @@ async function htmlStreamWalker(
await wait();
}

const visited = new WeakSet<Node>();

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";

Expand Down Expand Up @@ -293,6 +359,8 @@ async function htmlStreamWalker(
: streamInProgress
}

if (doc.documentElement) visited.add(doc.documentElement);

return {
root: doc.documentElement,
[FIRST_CHILD]: next("firstChild"),
Expand All @@ -303,5 +371,6 @@ async function htmlStreamWalker(
window.lastDiffTransition = document.startViewTransition(v);
} else v();
},
[VISITED]: visited,
};
}
Loading