diff --git a/change/@microsoft-fast-element-893a7afa-7056-4d39-9257-150cb9bf26f8.json b/change/@microsoft-fast-element-893a7afa-7056-4d39-9257-150cb9bf26f8.json new file mode 100644 index 00000000000..090e30279d9 --- /dev/null +++ b/change/@microsoft-fast-element-893a7afa-7056-4d39-9257-150cb9bf26f8.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Preserve hydration target traversal behavior without performing per-node Range membership checks.", + "packageName": "@microsoft/fast-element", + "email": "7559015+janechu@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/fast-element/SIZES.md b/packages/fast-element/SIZES.md index 1b48086fe26..2b3fa0c27c2 100644 --- a/packages/fast-element/SIZES.md +++ b/packages/fast-element/SIZES.md @@ -4,7 +4,7 @@ Bundle sizes for `@microsoft/fast-element` exports. | Export | Minified | Gzip | Brotli | |--------|----------|------|--------| -| CDN Rollup Bundle | 78.79 KB | 23.43 KB | 20.84 KB | +| CDN Rollup Bundle | 78.66 KB | 23.52 KB | 20.88 KB | | FASTElement (@microsoft/fast-element/fast-element.js) | 23.11 KB | 7.12 KB | 6.41 KB | | Updates (@microsoft/fast-element/updates.js) | 473 B | 335 B | 290 B | | Observable (@microsoft/fast-element/observable.js) | 6.75 KB | 2.51 KB | 2.23 KB | @@ -15,11 +15,11 @@ Bundle sizes for `@microsoft/fast-element` exports. | slotted (@microsoft/fast-element/slotted.js) | 4.66 KB | 1.81 KB | 1.59 KB | | volatile (@microsoft/fast-element/volatile.js) | 6.84 KB | 2.54 KB | 2.26 KB | | when (@microsoft/fast-element/when.js) | 1.88 KB | 731 B | 589 B | -| html (@microsoft/fast-element/html.js) | 27.91 KB | 8.98 KB | 8.04 KB | -| repeat (@microsoft/fast-element/repeat.js) | 31.80 KB | 10.00 KB | 9.03 KB | +| html (@microsoft/fast-element/html.js) | 27.77 KB | 9.05 KB | 8.12 KB | +| repeat (@microsoft/fast-element/repeat.js) | 31.68 KB | 10.08 KB | 9.09 KB | | css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B | -| enableHydration (@microsoft/fast-element/hydration.js) | 46.71 KB | 13.94 KB | 12.51 KB | -| declarativeTemplate (@microsoft/fast-element/declarative.js) | 62.33 KB | 19.49 KB | 17.46 KB | +| enableHydration (@microsoft/fast-element/hydration.js) | 46.57 KB | 14.01 KB | 12.58 KB | +| declarativeTemplate (@microsoft/fast-element/declarative.js) | 62.20 KB | 19.55 KB | 17.50 KB | | ArrayObserver (@microsoft/fast-element/arrays.js) | 12.55 KB | 4.46 KB | 4.03 KB | | observerMap (@microsoft/fast-element/observer-map.js) | 21.96 KB | 7.73 KB | 6.97 KB | | attributeMap (@microsoft/fast-element/attribute-map.js) | 15.31 KB | 5.41 KB | 4.88 KB | diff --git a/packages/fast-element/src/hydration/target-builder.pw.spec.ts b/packages/fast-element/src/hydration/target-builder.pw.spec.ts new file mode 100644 index 00000000000..8f56083bd4c --- /dev/null +++ b/packages/fast-element/src/hydration/target-builder.pw.spec.ts @@ -0,0 +1,1759 @@ +import { expect, test } from "@playwright/test"; + +test.describe("buildViewBindingTargets", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + }); + + test("bounds Range.comparePoint calls by element marker processing", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + const root = document.createElement("div"); + root.innerHTML = ` +
+ + +
+
+ ${Array.from( + { length: 100 }, + (_, index) => ``, + ).join("")} +
+
+ +
+ +
+ `; + + const first = root.querySelector("#first")!; + const last = root.querySelector("#last")!; + const after = root.querySelector("#after")!; + const candidateWalker = document.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT, + ); + candidateWalker.currentNode = first; + let candidateCount = 1; + + while (candidateWalker.nextNode() !== after) { + candidateCount++; + } + + const originalComparePoint = Range.prototype.comparePoint; + let comparePointCalls = 0; + + let targets: Record; + try { + Range.prototype.comparePoint = function ( + node: Node, + offset: number, + ): number { + comparePointCalls++; + return originalComparePoint.call(this, node, offset); + }; + ({ targets } = buildViewBindingTargets( + first, + last, + ["first", "firstChild", "middle", "last", "lastChild"].map( + targetNodeId => ({ targetNodeId }), + ), + )); + } finally { + Range.prototype.comparePoint = originalComparePoint; + } + + return { + targets: Object.fromEntries( + Object.entries(targets!).map(([key, node]) => [ + key, + (node as Element).id, + ]), + ), + beforeMarker: root.querySelector("#before")!.getAttribute("data-fe"), + afterMarker: root.querySelector("#after")!.getAttribute("data-fe"), + lastChildMarker: root + .querySelector("#last-child")! + .getAttribute("data-fe"), + candidateCount, + markerProcessingEvents: 5, + comparePointCalls, + }; + }); + + expect(result).toMatchObject({ + targets: { + first: "first", + firstChild: "first-child", + middle: "middle", + last: "last", + lastChild: "last-child", + }, + beforeMarker: "1", + afterMarker: "1", + lastChildMarker: null, + }); + expect(result.comparePointCalls).toBe(0); + expect(result.comparePointCalls).toBeLessThan(result.candidateCount / 10); + }); + + test("avoids Range.comparePoint for content-only traversal", async ({ page }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + const root = document.createElement("div"); + root.innerHTML = `content`; + const originalComparePoint = Range.prototype.comparePoint; + let comparePointCalls = 0; + let targets: Record; + + try { + Range.prototype.comparePoint = function ( + node: Node, + offset: number, + ): number { + comparePointCalls++; + return originalComparePoint.call(this, node, offset); + }; + ({ targets } = buildViewBindingTargets( + root.firstChild!, + root.lastChild!, + [{ targetNodeId: "content" }], + )); + } finally { + Range.prototype.comparePoint = originalComparePoint; + } + + return { + target: targets!.content.textContent, + comparePointCalls, + }; + }); + + expect(result).toEqual({ + target: "content", + comparePointCalls: 0, + }); + }); + + test("includes descendants when both endpoints are the same node", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + const element = document.createElement("div"); + element.id = "endpoint"; + element.setAttribute("data-fe", "1"); + element.innerHTML = ``; + + const { targets } = buildViewBindingTargets( + element, + element, + ["endpoint", "child"].map(targetNodeId => ({ targetNodeId })), + ); + + const emptyElement = document.createElement("div"); + emptyElement.id = "empty"; + emptyElement.setAttribute("data-fe", "1"); + const emptyTarget = buildViewBindingTargets(emptyElement, emptyElement, [ + { targetNodeId: "empty" }, + ]).targets.empty; + + const text = document.createTextNode("text"); + const comment = document.createComment("comment"); + buildViewBindingTargets(text, text, []); + buildViewBindingTargets(comment, comment, []); + + return { + ...Object.fromEntries( + Object.entries(targets).map(([key, node]) => [ + key, + (node as Element).id, + ]), + ), + empty: (emptyTarget as Element).id, + }; + }); + + expect(result).toEqual({ + endpoint: "endpoint", + child: "child", + empty: "empty", + }); + }); + + test("preserves first-only behavior for reversed and separate roots", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + function targetId(first: Element, last: Node): string { + first.setAttribute("data-fe", "1"); + const { targets } = buildViewBindingTargets(first, last, [ + { targetNodeId: "target" }, + ]); + return (targets.target as Element).id; + } + + const reversed = document.createElement("div"); + reversed.innerHTML = ``; + + const separateFirst = document.createElement("div"); + separateFirst.id = "separate-first"; + const separateLast = document.createElement("div"); + + const shadowHostA = document.createElement("div"); + const shadowHostB = document.createElement("div"); + const shadowFirst = document.createElement("span"); + shadowFirst.id = "shadow-first"; + const shadowLast = document.createElement("span"); + shadowHostA.attachShadow({ mode: "open" }).append(shadowFirst); + shadowHostB.attachShadow({ mode: "open" }).append(shadowLast); + + return { + reversed: targetId( + reversed.querySelector("#later")!, + reversed.querySelector("#earlier")!, + ), + separate: targetId(separateFirst, separateLast), + shadow: targetId(shadowFirst, shadowLast), + }; + }); + + expect(result).toEqual({ + reversed: "later", + separate: "separate-first", + shadow: "shadow-first", + }); + }); + + test("walks from a non-leaf first endpoint through an ancestor last endpoint", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + function createLast() { + const last = document.createElement("div"); + last.innerHTML = ` +
+ + + + +
+ + `; + return last; + } + + const legacyLast = createLast(); + const legacyFirst = legacyLast.querySelector("#first")!; + const range = document.createRange(); + range.setStart(legacyFirst, 0); + range.setEnd(legacyLast, legacyLast.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + const legacy: string[] = []; + let legacyNode: Node | null = (walker.currentNode = legacyFirst); + while (legacyNode !== null) { + legacy.push((legacyNode as Element).id); + legacyNode = walker.nextNode(); + } + range.detach(); + + const last = createLast(); + const first = last.querySelector("#first")!; + + const { targets } = buildViewBindingTargets( + first, + last, + ["first", "firstChild", "followingSibling", "followingBranch"].map( + targetNodeId => ({ targetNodeId }), + ), + ); + + return { + legacy, + targets: Object.values(targets).map(node => (node as Element).id), + }; + }); + + expect(result).toEqual({ + legacy: ["first", "first-child", "following-sibling", "following-branch"], + targets: ["first", "first-child", "following-sibling", "following-branch"], + }); + }); + + test("matches a live Range when attribute removal mutates an endpoint", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + class EndpointMutator extends HTMLElement { + static observedAttributes = ["data-fe"]; + + attributeChangedCallback( + _name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (oldValue === null || newValue !== null) { + return; + } + + const root = this.parentElement!; + + if (this.dataset.mutation === "move-first") { + root.append(this); + return; + } + + const endpoint = root.querySelector("[data-endpoint]"); + + if (this.dataset.mutation === "remove") { + endpoint!.remove(); + } else { + root.append(endpoint!); + } + } + } + + customElements.define("endpoint-mutator", EndpointMutator); + + function createRoot(mutation: "remove" | "move" | "move-first") { + const root = document.createElement("div"); + root.innerHTML = ` + + + + + `; + return root; + } + + function walkLegacy(mutation: "remove" | "move" | "move-first") { + const root = createRoot(mutation); + const first = root.querySelector("#first")!; + const last = root.querySelector("#last")!; + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + const visited: string[] = []; + let node: Node | null = (walker.currentNode = first); + while (node !== null) { + const element = node as Element; + visited.push(element.id); + element.removeAttribute("data-fe"); + node = walker.nextNode(); + } + range.detach(); + return visited; + } + + function build(mutation: "remove" | "move" | "move-first") { + const root = createRoot(mutation); + + const { targets } = buildViewBindingTargets( + root.querySelector("#first")!, + root.querySelector("#last")!, + ["first", "middle", "escaped"].map(targetNodeId => ({ + targetNodeId, + })), + ); + + return { + targets: Object.values(targets).map(node => (node as Element).id), + afterMarker: root.querySelector("#after")!.getAttribute("data-fe"), + }; + } + + return { + legacy: { + remove: walkLegacy("remove"), + move: walkLegacy("move"), + moveFirst: walkLegacy("move-first"), + }, + remove: build("remove"), + move: build("move"), + moveFirst: build("move-first"), + }; + }); + + expect(result).toEqual({ + legacy: { + remove: ["first", "middle"], + move: ["first", "middle"], + moveFirst: ["first"], + }, + remove: { + targets: ["first", "middle"], + afterMarker: "1", + }, + move: { + targets: ["first", "middle"], + afterMarker: "1", + }, + moveFirst: { + targets: ["first"], + afterMarker: "1", + }, + }); + }); + + test("matches the legacy walker when marker removal reparents the current node", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + class CurrentNodeReparenter extends HTMLElement { + static observedAttributes = ["data-fe"]; + + attributeChangedCallback( + _name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (oldValue === null || newValue !== null) { + return; + } + + const root = this.parentElement!.parentElement!; + const after = root.querySelector("#after2")!; + after.parentElement!.insertBefore(this, after); + } + } + + customElements.define("current-node-reparenter", CurrentNodeReparenter); + + function createRoot() { + const root = document.createElement("div"); + root.innerHTML = ` +
+
+ +
+
+
+
+
+ `; + return root; + } + + function walkLegacy() { + const root = createRoot(); + const first = root.querySelector("#first")!; + const last = root.querySelector("#last")!; + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + const visited: string[] = []; + let node: Node | null = (walker.currentNode = first); + + while (node !== null) { + const element = node as Element; + if (element.hasAttribute("data-fe")) { + visited.push(element.id); + } + element.removeAttribute("data-fe"); + node = walker.nextNode(); + } + + range.detach(); + return visited; + } + + const root = createRoot(); + const { targets } = buildViewBindingTargets( + root.querySelector("#first")!, + root.querySelector("#last")!, + ["first", "mover", "after2"].map(targetNodeId => ({ + targetNodeId, + })), + ); + + return { + legacy: walkLegacy(), + current: Object.values(targets).map(node => (node as Element).id), + afterMarker: root.querySelector("#after2")!.getAttribute("data-fe"), + }; + }); + + expect(result).toEqual({ + legacy: ["first", "mover"], + current: ["first", "mover"], + afterMarker: "1", + }); + }); + + test("matches the legacy walker when marker removal reparents the current ancestor", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + class CurrentAncestorReparenter extends HTMLElement { + static observedAttributes = ["data-fe"]; + + attributeChangedCallback( + _name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (oldValue === null || newValue !== null) { + return; + } + + const ancestor = this.parentElement!; + const root = ancestor.parentElement!.parentElement!; + root.querySelector("#outside")!.append(ancestor); + } + } + + customElements.define( + "current-ancestor-reparenter", + CurrentAncestorReparenter, + ); + + function createRoot() { + const root = document.createElement("div"); + root.innerHTML = ` +
+
+
+ +
+
+
+
+
+ `; + return root; + } + + function walkLegacy() { + const root = createRoot(); + const first = root.querySelector("#first")!; + const last = root.querySelector("#last")!; + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + const visited: string[] = []; + let node: Node | null = (walker.currentNode = first); + + while (node !== null) { + const element = node as Element; + if (element.hasAttribute("data-fe")) { + visited.push(element.id); + } + element.removeAttribute("data-fe"); + node = walker.nextNode(); + } + + range.detach(); + return visited; + } + + const root = createRoot(); + const { targets } = buildViewBindingTargets( + root.querySelector("#first")!, + root.querySelector("#last")!, + ["first", "mover", "after2"].map(targetNodeId => ({ + targetNodeId, + })), + ); + + return { + legacy: walkLegacy(), + current: Object.values(targets).map(node => (node as Element).id), + afterMarker: root.querySelector("#after2")!.getAttribute("data-fe"), + }; + }); + + expect(result).toEqual({ + legacy: ["first", "mover"], + current: ["first", "mover"], + afterMarker: "1", + }); + }); + + test("matches a live Range when the exclusive stop is synchronously removed or moved", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + class ExclusiveStopMutator extends HTMLElement { + static observedAttributes = ["data-fe"]; + + attributeChangedCallback( + _name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (oldValue === null || newValue !== null) { + return; + } + + const stop = this.parentElement!.querySelector( + "[data-exclusive-stop]", + ); + + if (this.dataset.mutation === "remove") { + stop!.remove(); + } else { + this.parentElement!.append(stop!); + } + } + } + + customElements.define("exclusive-stop-mutator", ExclusiveStopMutator); + + function createRoot(mutation: "remove" | "move") { + const root = document.createElement("div"); + root.innerHTML = ` + + + + + + + + `; + return root; + } + + function createSiblingRoot(mutation: "remove" | "move") { + const root = document.createElement("div"); + root.innerHTML = ` + + + + + + + `; + return root; + } + + function walkLegacy(mutation: "remove" | "move") { + const root = createRoot(mutation); + const first = root.querySelector("#first")!; + const last = root.querySelector("#last")!; + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + const visited: string[] = []; + let node: Node | null = (walker.currentNode = first); + while (node !== null) { + const element = node as Element; + visited.push(element.id); + element.removeAttribute("data-fe"); + node = walker.nextNode(); + } + range.detach(); + return visited; + } + + function build(mutation: "remove" | "move") { + const root = createRoot(mutation); + const { targets } = buildViewBindingTargets( + root.querySelector("#first")!, + root.querySelector("#last")!, + ["first", "middle", "last", "lastChild", "escaped"].map( + targetNodeId => ({ targetNodeId }), + ), + ); + + return { + targets: Object.values(targets).map(node => (node as Element).id), + escapedMarker: root + .querySelector("#escaped")! + .getAttribute("data-fe"), + }; + } + + function walkLegacySibling(mutation: "remove" | "move") { + const root = createSiblingRoot(mutation); + const first = root.querySelector("#first")!; + const last = root.querySelector("#last")!; + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_COMMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + walker.currentNode = first; + first.removeAttribute("data-fe"); + walker.nextNode(); + const sibling = walker.nextSibling(); + const escaped = walker.nextSibling(); + range.detach(); + + return { + sibling: (sibling as Element).id, + escaped: escaped === null ? null : (escaped as Element).id, + }; + } + + function buildSibling(mutation: "remove" | "move") { + const root = createSiblingRoot(mutation); + + try { + buildViewBindingTargets( + root.querySelector("#first")!, + root.querySelector("#last")!, + [{ targetNodeId: "first" }], + ); + return false; + } catch { + return true; + } + } + + return { + remove: { + legacy: walkLegacy("remove"), + current: build("remove"), + siblingLegacy: walkLegacySibling("remove"), + siblingThrew: buildSibling("remove"), + }, + move: { + legacy: walkLegacy("move"), + current: build("move"), + siblingLegacy: walkLegacySibling("move"), + siblingThrew: buildSibling("move"), + }, + }; + }); + + expect(result).toEqual({ + remove: { + legacy: ["first", "middle", "last", "last-child"], + current: { + targets: ["first", "middle", "last", "last-child"], + escapedMarker: "1", + }, + siblingLegacy: { + sibling: "last", + escaped: null, + }, + siblingThrew: true, + }, + move: { + legacy: ["first", "middle", "last", "last-child"], + current: { + targets: ["first", "middle", "last", "last-child"], + escapedMarker: "1", + }, + siblingLegacy: { + sibling: "last", + escaped: null, + }, + siblingThrew: true, + }, + }); + }); + + test("uses the live Range when the exclusive stop moves before the endpoint", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + class StopBeforeEndpointMutator extends HTMLElement { + static observedAttributes = ["data-fe"]; + + attributeChangedCallback( + _name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (oldValue === null || newValue !== null) { + return; + } + + const root = this.parentElement!; + root.insertBefore( + root.querySelector("[data-exclusive-stop]")!, + root.querySelector("#middle")!, + ); + } + } + + customElements.define( + "stop-before-endpoint-mutator", + StopBeforeEndpointMutator, + ); + + function createRoot() { + const root = document.createElement("div"); + const first = document.createElement("stop-before-endpoint-mutator"); + first.id = "first"; + first.setAttribute("data-fe", "1"); + const middle = document.createElement("span"); + middle.id = "middle"; + middle.setAttribute("data-fe", "1"); + const last = document.createElement("i"); + last.id = "last"; + last.setAttribute("data-fe", "1"); + const stop = document.createElement("b"); + stop.id = "stop"; + stop.setAttribute("data-fe", "1"); + stop.setAttribute("data-exclusive-stop", ""); + root.append(first, middle, last, stop); + return { root, first, last }; + } + + function walkLegacy() { + const { first, last } = createRoot(); + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + const visited: string[] = []; + let node: Node | null = (walker.currentNode = first); + + while (node !== null) { + const element = node as Element; + visited.push(element.id); + element.removeAttribute("data-fe"); + node = walker.nextNode(); + } + + range.detach(); + return visited; + } + + const { root, first, last } = createRoot(); + const { targets } = buildViewBindingTargets( + first, + last, + ["first", "stop", "middle", "last"].map(targetNodeId => ({ + targetNodeId, + })), + ); + + return { + legacy: walkLegacy(), + current: Object.values(targets).map(node => (node as Element).id), + order: Array.from(root.children).map(node => node.id), + }; + }); + + expect(result).toEqual({ + legacy: ["first", "stop", "middle", "last"], + current: ["first", "stop", "middle", "last"], + order: ["first", "stop", "middle", "last"], + }); + }); + + test("uses the live Range when the exclusive stop moves into content", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + class StopIntoContentMutator extends HTMLElement { + static observedAttributes = ["data-fe"]; + + attributeChangedCallback( + _name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (oldValue === null || newValue !== null) { + return; + } + + this.parentElement!.querySelector("#content")!.append( + this.parentElement!.querySelector("[data-exclusive-stop]")!, + ); + } + } + + customElements.define("stop-into-content-mutator", StopIntoContentMutator); + + function createRoot() { + const root = document.createElement("div"); + const first = document.createElement("stop-into-content-mutator"); + first.id = "first"; + first.setAttribute("data-fe", "1"); + const start = document.createComment("fe:b"); + const content = document.createElement("div"); + content.id = "content"; + const end = document.createComment("fe:/b"); + const last = document.createElement("i"); + last.id = "last"; + const stop = document.createElement("b"); + stop.id = "stop"; + stop.setAttribute("data-exclusive-stop", ""); + root.append(first, start, content, end, last, stop); + return { root, first, start, content, end, last, stop }; + } + + function walkLegacy() { + const { first, start, end, last } = createRoot(); + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_COMMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + walker.currentNode = first; + first.removeAttribute("data-fe"); + + const foundStart = walker.nextSibling() === start; + let current = walker.nextSibling(); + + while (current !== null && current !== end) { + current = walker.nextSibling(); + } + + range.detach(); + return { + foundStart, + foundEnd: current === end, + }; + } + + const { first, content, end, last, stop } = createRoot(); + const { targets, boundaries } = buildViewBindingTargets(first, last, [ + { targetNodeId: "first" }, + { targetNodeId: "content" }, + ]); + + return { + legacy: walkLegacy(), + current: { + first: (targets.first as Element).id, + foundEnd: end.data === "", + boundaryFirst: (boundaries.content.first as Element).id, + boundaryLast: (boundaries.content.last as Element).id, + stopParent: (stop.parentNode as Element).id, + contentChild: content.firstElementChild!.id, + }, + }; + }); + + expect(result).toEqual({ + legacy: { + foundStart: true, + foundEnd: true, + }, + current: { + first: "first", + foundEnd: true, + boundaryFirst: "content", + boundaryLast: "content", + stopParent: "content", + contentChild: "stop", + }, + }); + }); + + test("stops sibling traversal when a returned container contains the boundary", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + function createRoot() { + const root = document.createElement("div"); + const start = document.createComment("fe:b"); + const container = document.createElement("div"); + container.id = "container"; + container.innerHTML = ` + + + `; + const end = document.createComment("fe:/b"); + root.append(start, container, end); + return { root, start, container, end }; + } + + const legacyRoot = createRoot(); + const legacyLast = legacyRoot.root.querySelector("#last")!; + const range = document.createRange(); + range.setStart(legacyRoot.start, 0); + range.setEnd(legacyLast, legacyLast.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_COMMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + walker.currentNode = legacyRoot.start; + const legacyContainer = walker.nextSibling(); + const legacyEscaped = walker.nextSibling(); + range.detach(); + + const currentRoot = createRoot(); + let currentError: Error | null = null; + + try { + buildViewBindingTargets( + currentRoot.start, + currentRoot.root.querySelector("#last")!, + [{ targetNodeId: "content" }], + ); + } catch (error) { + currentError = error as Error; + } + + return { + legacyContainer: (legacyContainer as Element).id, + legacyEscaped: + legacyEscaped === null ? null : (legacyEscaped as Element).id, + currentThrew: currentError !== null, + currentMessage: currentError?.message, + endData: currentRoot.end.data, + }; + }); + + expect(result).toMatchObject({ + legacyContainer: "container", + legacyEscaped: null, + currentThrew: true, + endData: "fe:/b", + }); + expect(result.currentMessage).toContain( + "matching `` content binding close marker", + ); + }); + + test("uses the live Range after endpoint removal without visiting an appended marker", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + class FinalEndpointMutator extends HTMLElement { + static observedAttributes = ["data-fe"]; + + attributeChangedCallback( + _name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (oldValue === null || newValue !== null) { + return; + } + + const endpoint = + this.parentElement!.querySelector("[data-endpoint]")!; + endpoint.remove(); + + const appended = document.createElement("b"); + appended.id = "appended"; + appended.setAttribute("data-fe", "1"); + this.parentElement!.append(appended); + } + } + + customElements.define("final-endpoint-mutator", FinalEndpointMutator); + + function createRoot() { + const root = document.createElement("div"); + root.innerHTML = ` + + + + `; + return root; + } + + function walkLegacy() { + const root = createRoot(); + const first = root.querySelector("#first")!; + const last = root.querySelector("#last")!; + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + const visited: string[] = []; + let node: Node | null = (walker.currentNode = first); + + while (node !== null) { + const element = node as Element; + visited.push(element.id); + element.removeAttribute("data-fe"); + node = walker.nextNode(); + } + + range.detach(); + return { + visited, + appendedMarker: root + .querySelector("#appended")! + .getAttribute("data-fe"), + }; + } + + const root = createRoot(); + const originalComparePoint = Range.prototype.comparePoint; + let comparePointCalls = 0; + let targets: Record; + + try { + Range.prototype.comparePoint = function ( + node: Node, + offset: number, + ): number { + comparePointCalls++; + return originalComparePoint.call(this, node, offset); + }; + ({ targets } = buildViewBindingTargets( + root.querySelector("#first")!, + root.querySelector("#last")!, + ["first", "middle"].map(targetNodeId => ({ targetNodeId })), + )); + } finally { + Range.prototype.comparePoint = originalComparePoint; + } + + return { + legacy: walkLegacy(), + current: { + targets: Object.values(targets!).map(node => (node as Element).id), + appendedMarker: root + .querySelector("#appended")! + .getAttribute("data-fe"), + comparePointCalls, + }, + }; + }); + + expect(result.legacy).toEqual({ + visited: ["first", "middle"], + appendedMarker: "1", + }); + expect(result.current).toMatchObject({ + targets: ["first", "middle"], + appendedMarker: "1", + }); + expect(result.current.comparePointCalls).toBeLessThanOrEqual(5); + }); + + test("excludes children synchronously appended to the last endpoint", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + class LastEndpointAppender extends HTMLElement { + static observedAttributes = ["data-fe"]; + + attributeChangedCallback( + _name: string, + oldValue: string | null, + newValue: string | null, + ) { + if (oldValue === null || newValue !== null) { + return; + } + + const appended = document.createElement("b"); + appended.id = "appended"; + appended.setAttribute("data-fe", "1"); + this.parentElement!.querySelector("#last")!.append(appended); + } + } + + customElements.define("last-endpoint-appender", LastEndpointAppender); + + function createRoot() { + const root = document.createElement("div"); + root.innerHTML = ` + + + + + `; + return root; + } + + function walkLegacy() { + const root = createRoot(); + const first = root.querySelector("#first")!; + const last = root.querySelector("#last")!; + const range = document.createRange(); + range.setStart(first, 0); + range.setEnd(last, last.childNodes.length); + const walker = document.createTreeWalker( + range.commonAncestorContainer, + NodeFilter.SHOW_ELEMENT, + { + acceptNode(node) { + return range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + }, + ); + const visited: string[] = []; + let node: Node | null = (walker.currentNode = first); + + while (node !== null) { + const element = node as Element; + visited.push(element.id); + element.removeAttribute("data-fe"); + node = walker.nextNode(); + } + + range.detach(); + return { + visited, + appendedMarker: root + .querySelector("#appended")! + .getAttribute("data-fe"), + }; + } + + const root = createRoot(); + const { targets } = buildViewBindingTargets( + root.querySelector("#first")!, + root.querySelector("#last")!, + ["first", "last", "originalChild", "appended"].map(targetNodeId => ({ + targetNodeId, + })), + ); + + return { + legacy: walkLegacy(), + current: { + visited: Object.values(targets).map(node => (node as Element).id), + appendedMarker: root + .querySelector("#appended")! + .getAttribute("data-fe"), + }, + }; + }); + + expect(result.legacy).toEqual({ + visited: ["first", "last", "original-child"], + appendedMarker: "1", + }); + expect(result.current).toEqual(result.legacy); + }); + + test("supports ordered endpoints in detached fragments and shadow roots", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + const fragment = document.createDocumentFragment(); + const fragmentFirst = document.createElement("span"); + fragmentFirst.id = "fragment-first"; + fragmentFirst.setAttribute("data-fe", "1"); + const fragmentLast = document.createElement("div"); + fragmentLast.id = "fragment-last"; + fragmentLast.setAttribute("data-fe", "1"); + fragmentLast.innerHTML = ``; + const fragmentAfter = document.createElement("b"); + fragmentAfter.setAttribute("data-fe", "1"); + fragment.append(fragmentFirst, fragmentLast, fragmentAfter); + + const fragmentTargets = buildViewBindingTargets( + fragmentFirst, + fragmentLast, + ["first", "last", "child"].map(targetNodeId => ({ targetNodeId })), + ).targets; + + const host = document.createElement("div"); + const shadowRoot = host.attachShadow({ mode: "open" }); + shadowRoot.innerHTML = ` + +
+ + `; + const shadowTargets = buildViewBindingTargets( + shadowRoot.querySelector("#shadow-first")!, + shadowRoot.querySelector("#shadow-last")!, + ["first", "last", "child"].map(targetNodeId => ({ targetNodeId })), + ).targets; + + return { + fragment: Object.values(fragmentTargets).map( + node => (node as Element).id, + ), + fragmentAfterMarker: fragmentAfter.getAttribute("data-fe"), + shadow: Object.values(shadowTargets).map(node => (node as Element).id), + shadowAfterMarker: shadowRoot + .querySelector("#shadow-after")! + .getAttribute("data-fe"), + }; + }); + + expect(result).toEqual({ + fragment: ["fragment-first", "fragment-last", "fragment-child"], + fragmentAfterMarker: "1", + shadow: ["shadow-first", "shadow-last", "shadow-child"], + shadowAfterMarker: "1", + }); + }); + + test("terminates at the end of detached fragments and shadow roots", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + function build(root: DocumentFragment | ShadowRoot) { + const first = document.createElement("span"); + first.id = "first"; + first.setAttribute("data-fe", "1"); + const last = document.createElement("div"); + last.id = "last"; + last.setAttribute("data-fe", "1"); + root.append(first, last); + + return Object.values( + buildViewBindingTargets(first, last, [ + { targetNodeId: "first" }, + { targetNodeId: "last" }, + ]).targets, + ).map(node => (node as Element).id); + } + + const fragment = build(document.createDocumentFragment()); + const shadow = build( + document.createElement("div").attachShadow({ mode: "open" }), + ); + + return { fragment, shadow }; + }); + + expect(result).toEqual({ + fragment: ["first", "last"], + shadow: ["first", "last"], + }); + }); + + test("supports a last endpoint nested within the first endpoint", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + const first = document.createElement("div"); + first.id = "first"; + first.setAttribute("data-fe", "1"); + first.innerHTML = + `` + + `
` + + `
` + + `
`; + const last = first.querySelector("#last")!; + + const { targets } = buildViewBindingTargets( + first, + last, + ["first", "before", "last", "lastChild"].map(targetNodeId => ({ + targetNodeId, + })), + ); + + return { + targets: Object.values(targets).map(node => (node as Element).id), + afterMarker: first.querySelector("#after")!.getAttribute("data-fe"), + }; + }); + + expect(result).toEqual({ + targets: ["first", "before", "last", "last-child"], + afterMarker: "1", + }); + }); + + test("preserves empty, single-text, and multi-node content targets", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + function build(markup: string) { + const root = document.createElement("div"); + root.innerHTML = markup; + const first = root.firstChild!; + const last = root.lastChild!; + const { targets, boundaries } = buildViewBindingTargets(first, last, [ + { targetNodeId: "content" }, + ]); + const boundary = boundaries.content; + + return { + childNodes: Array.from(root.childNodes).map(node => ({ + type: node.nodeType, + text: node.textContent, + })), + targetType: targets.content.nodeType, + targetText: targets.content.textContent, + boundary: + boundary === undefined + ? null + : [boundary.first.textContent, boundary.last.textContent], + }; + } + + return { + empty: build(``), + single: build(`single`), + multi: build(`firstlast`), + }; + }); + + expect(result.empty).toEqual({ + childNodes: [ + { type: 8, text: "" }, + { type: 3, text: "" }, + { type: 8, text: "" }, + ], + targetType: 3, + targetText: "", + boundary: null, + }); + expect(result.single).toEqual({ + childNodes: [ + { type: 8, text: "" }, + { type: 3, text: "single" }, + { type: 8, text: "" }, + ], + targetType: 3, + targetText: "single", + boundary: null, + }); + expect(result.multi).toEqual({ + childNodes: [ + { type: 8, text: "" }, + { type: 1, text: "first" }, + { type: 1, text: "last" }, + { type: 3, text: "" }, + { type: 8, text: "" }, + ], + targetType: 3, + targetText: "", + boundary: ["first", "last"], + }); + }); + + test("keeps nested content markers balanced and resumes after the outer range", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + const root = document.createElement("div"); + root.innerHTML = + `first` + + `deep` + + `last` + + `
`; + + const { targets, boundaries } = buildViewBindingTargets( + root.firstChild!, + root.lastChild!, + [{ targetNodeId: "outer" }, { targetNodeId: "after" }], + ); + + return { + after: (targets.after as Element).id, + outerType: targets.outer.nodeType, + boundary: [ + (boundaries.outer.first as Element).id, + (boundaries.outer.last as Element).id, + ], + nestedMarkers: Array.from(root.childNodes) + .filter(node => node.nodeType === Node.COMMENT_NODE) + .map(node => (node as Comment).data) + .filter(data => data !== ""), + }; + }); + + expect(result).toEqual({ + after: "after", + outerType: 3, + boundary: ["first", "last"], + nestedMarkers: ["fe:b", "fe:b", "fe:/b", "fe:/b"], + }); + }); + + test("skips balanced custom-element boundaries without consuming nested targets", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + const root = document.createElement("div"); + root.innerHTML = + `
` + + `` + + `
`; + + const { targets } = buildViewBindingTargets( + root.firstChild!, + root.lastChild!, + [{ targetNodeId: "parent" }], + ); + + return { + parent: (targets.parent as Element).id, + nestedMarker: root + .querySelector("#nested-target")! + .getAttribute("data-fe"), + deepMarker: root.querySelector("#deep-target")!.getAttribute("data-fe"), + boundaryData: Array.from(root.childNodes) + .filter(node => node.nodeType === Node.COMMENT_NODE) + .map(node => (node as Comment).data), + }; + }); + + expect(result).toEqual({ + parent: "parent-target", + nestedMarker: "1", + deepMarker: "1", + boundaryData: ["", "", "", ""], + }); + }); + + test("preserves current and representative legacy marker targeting", async ({ + page, + }) => { + const result = await page.evaluate(async () => { + const { + buildViewBindingTargets, + // @ts-expect-error: Client module. + } = await import("/main.js"); + + const root = document.createElement("div"); + root.innerHTML = + `
` + + `
` + + `legacy content` + + ``; + + const { targets } = buildViewBindingTargets( + root.firstChild!, + root.lastChild!, + ["current", "legacyOne", "legacyTwo", "content"].map(targetNodeId => ({ + targetNodeId, + })), + ); + + return { + targets: Object.fromEntries( + Object.entries(targets).map(([key, node]) => [ + key, + node.nodeType === Node.ELEMENT_NODE + ? (node as Element).id + : node.textContent, + ]), + ), + currentMarker: root.querySelector("#current")!.getAttribute("data-fe"), + legacyMarkers: root + .querySelector("#legacy")! + .getAttributeNames() + .filter(name => name.startsWith("data-fe")), + }; + }); + + expect(result).toEqual({ + targets: { + current: "current", + legacyOne: "legacy", + legacyTwo: "legacy", + content: "legacy content", + }, + currentMarker: null, + legacyMarkers: [], + }); + }); +}); diff --git a/packages/fast-element/src/hydration/target-builder.ts b/packages/fast-element/src/hydration/target-builder.ts index d08f23d6318..537db8f688b 100644 --- a/packages/fast-element/src/hydration/target-builder.ts +++ b/packages/fast-element/src/hydration/target-builder.ts @@ -74,13 +74,19 @@ export interface ViewBehaviorBoundaries { } function isComment(node: Node): node is Comment { - return node.nodeType === Node.COMMENT_NODE; + return node.nodeType === 8; } function isText(node: Node): node is Text { - return node.nodeType === Node.TEXT_NODE; + return node.nodeType === 3; } +function getNodeLength(node: Node): number { + return isComment(node) || isText(node) ? node.data.length : node.childNodes.length; +} + +const hydrationNodeMask = 133; + /** * Returns a range object inclusive of all nodes including and between the * provided first and last node. @@ -94,13 +100,95 @@ export function createRangeForNodes(first: Node, last: Node): Range { // The lastIndex should be inclusive of the end of the lastChild. Obtain offset based // on usageNotes: https://developer.mozilla.org/en-US/docs/Web/API/Range/setEnd#usage_notes - range.setEnd( - last, - isComment(last) || isText(last) ? last.data.length : last.childNodes.length, - ); + range.setEnd(last, getNodeLength(last)); return range; } +type HydrationMove = (sibling?: boolean) => Node | null; + +function createHydrationTraversal( + firstNode: Node, + lastNode: Node, +): [HydrationMove, () => void] { + const range = createRangeForNodes(firstNode, lastNode); + let walker = + range.startContainer === firstNode + ? document.createTreeWalker(range.commonAncestorContainer, hydrationNodeMask) + : null; + let hasReachedLastNode = lastNode.contains(firstNode); + let mutationMode = false; + + if (walker !== null) { + walker.currentNode = firstNode; + } + + function move(sibling?: boolean): Node | null { + if ( + walker === null || + (!mutationMode && sibling && walker.currentNode.contains(lastNode)) + ) { + return null; + } + + const current = walker.currentNode; + const candidate = sibling ? walker.nextSibling() : walker.nextNode(); + + if (mutationMode || !hasReachedLastNode || lastNode.contains(candidate)) { + if (candidate === lastNode) { + hasReachedLastNode = true; + } + + return candidate; + } + + walker.currentNode = current; + return null; + } + + return [ + move, + () => { + if ( + walker !== null && + !mutationMode && + (range.startContainer !== firstNode || + range.endContainer !== lastNode || + range.endOffset !== getNodeLength(lastNode) || + !range.intersectsNode(walker.currentNode)) + ) { + const current = walker.currentNode; + walker = document.createTreeWalker(walker.root, walker.whatToShow, { + acceptNode: node => + range.comparePoint(node, 0) === 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT, + }); + walker.currentNode = current; + mutationMode = true; + } + }, + ]; +} + +function throwHydrationError( + node: Node, + factories: CompiledViewBehaviorFactory[], + expected: string, +): never { + const result = getHydrationDiagnostic().formatStructuralError( + node, + getHostName(node), + expected, + ); + throw new HydrationTargetElementError( + result.message, + factories, + node, + result.expected, + result.received, + ); +} + /** * Maps compiled ViewBehaviorFactory IDs to their corresponding DOM nodes in the * server-rendered shadow root. Uses a TreeWalker to scan the existing DOM between @@ -130,19 +218,7 @@ export function buildViewBindingTargets( lastNode: Node, factories: CompiledViewBehaviorFactory[], ): { targets: ViewBehaviorTargets; boundaries: ViewBehaviorBoundaries } { - const range = createRangeForNodes(firstNode, lastNode); - const treeRoot = range.commonAncestorContainer; - const walker = document.createTreeWalker( - treeRoot, - NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_COMMENT + NodeFilter.SHOW_TEXT, - { - acceptNode(node) { - return range.comparePoint(node, 0) === 0 - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT; - }, - }, - ); + const [move, update] = createHydrationTraversal(firstNode, lastNode); const targets: ViewBehaviorTargets = {}; const boundaries: ViewBehaviorBoundaries = {}; @@ -151,11 +227,11 @@ export function buildViewBindingTargets( const hydrationIndexOffset = getHydrationIndexOffset(factories); let factoryPointer = hydrationIndexOffset; - let node: Node | null = (walker.currentNode = firstNode); + let node: Node | null = firstNode; while (node !== null) { switch (node.nodeType) { - case Node.ELEMENT_NODE: { + case 1: { const element = node as Element; const legacyIndices = HydrationMarkup.parseLegacyAttributeBindingIndices(element); @@ -165,20 +241,10 @@ export function buildViewBindingTargets( const factoryIndex = index + hydrationIndexOffset; const factory = factories[factoryIndex]; if (!factory) { - const expected = formatNoMoreAttributeBindings( - factories.length, - ); - const result = getHydrationDiagnostic().formatStructuralError( + throwHydrationError( node, - getHostName(node), - expected, - ); - throw new HydrationTargetElementError( - result.message, factories, - element, - result.expected, - result.received, + formatNoMoreAttributeBindings(factories.length), ); } @@ -187,6 +253,7 @@ export function buildViewBindingTargets( } HydrationMarkup.removeLegacyAttributeBindingMarkers(element); + update(); break; } @@ -195,35 +262,26 @@ export function buildViewBindingTargets( for (let i = 0; i < count; i++) { const factory = factories[factoryPointer++]; if (!factory) { - const expected = formatNoMoreAttributeBindings( - factories.length, - ); - const result = getHydrationDiagnostic().formatStructuralError( + throwHydrationError( node, - getHostName(node), - expected, - ); - throw new HydrationTargetElementError( - result.message, factories, - node as Element, - result.expected, - result.received, + formatNoMoreAttributeBindings(factories.length), ); } targetFactory(factory, node, targets); } element.removeAttribute(HydrationMarkup.attributeMarkerName); + update(); } break; } - case Node.COMMENT_NODE: { + case 8: { const data = (node as Comment).data; if (HydrationMarkup.isElementBoundaryStartMarker(node)) { // Element boundary — clear start marker and skip subtree (node as Comment).data = ""; - skipToElementBoundaryEnd(walker, factories, node); + skipToElementBoundaryEnd(move, factories, node); } else if (HydrationMarkup.isContentBindingStartMarker(data)) { // Content binding — consume next factory const legacyIndex = @@ -236,23 +294,15 @@ export function buildViewBindingTargets( factoryPointer = Math.max(factoryPointer, factoryIndex + 1); if (!factory) { - const expected = formatNoMoreContentBindings(factories.length); - const result = getHydrationDiagnostic().formatStructuralError( + throwHydrationError( node, - getHostName(node), - expected, - ); - throw new HydrationTargetElementError( - result.message, factories, - node, - result.expected, - result.received, + formatNoMoreContentBindings(factories.length), ); } targetContentBinding( node as Comment, - walker, + move, factory, factories, targets, @@ -263,39 +313,25 @@ export function buildViewBindingTargets( } } - node = walker.nextNode(); + node = move(); } - range.detach(); return { targets, boundaries }; } function targetContentBinding( node: Comment, - walker: TreeWalker, + move: HydrationMove, factory: CompiledViewBehaviorFactory, factories: CompiledViewBehaviorFactory[], targets: ViewBehaviorTargets, boundaries: ViewBehaviorBoundaries, ) { - const nodes: Node[] = []; - let current: Node | null = walker.nextSibling(); + let current: Node | null = move(true); node.data = ""; if (current === null) { - const expected = expectedContentAfterStartMarker; - const result = getHydrationDiagnostic().formatStructuralError( - node, - getHostName(node), - expected, - ); - throw new HydrationTargetElementError( - result.message, - factories, - node, - result.expected, - result.received, - ); + throwHydrationError(node, factories, expectedContentAfterStartMarker); } const first = current; @@ -311,30 +347,17 @@ function targetContentBinding( depth--; } } - nodes.push(current); - current = walker.nextSibling(); + current = move(true); } if (current === null) { - const expected = expectedContentEndMarker; - const result = getHydrationDiagnostic().formatStructuralError( - node, - getHostName(node), - expected, - ); - throw new HydrationTargetElementError( - result.message, - factories, - node, - result.expected, - result.received, - ); + throwHydrationError(node, factories, expectedContentEndMarker); } (current as Comment).data = ""; - if (nodes.length === 1 && isText(nodes[0])) { - targetFactory(factory, nodes[0], targets); + if (isText(first) && first.nextSibling === current) { + targetFactory(factory, first, targets); } else { // If current === first, it means there is no content in // the view. This happens when a `when` directive evaluates false, @@ -346,11 +369,11 @@ function targetContentBinding( }; } // Insert a text node so text content binding targets it - const dummyTextNode = current.parentNode!.insertBefore( - document.createTextNode(""), - current, + targetFactory( + factory, + current.parentNode!.insertBefore(document.createTextNode(""), current), + targets, ); - targetFactory(factory, dummyTextNode, targets); } } @@ -359,42 +382,28 @@ function targetContentBinding( * depth counting to handle nested element boundaries correctly. */ function skipToElementBoundaryEnd( - walker: TreeWalker, + move: HydrationMove, factories: CompiledViewBehaviorFactory[], startNode: Node, ) { let depth = 0; - let current = walker.nextSibling(); + let current = move(true); while (current !== null) { if (isComment(current)) { if (HydrationMarkup.isElementBoundaryStartMarker(current)) { current.data = ""; depth++; } else if (HydrationMarkup.isElementBoundaryEndMarker(current)) { - if (depth === 0) { - current.data = ""; + current.data = ""; + if (depth-- === 0) { return; } - current.data = ""; - depth--; } } - current = walker.nextSibling(); + current = move(true); } - const expected = expectedElementBoundaryEndMarker; - const result = getHydrationDiagnostic().formatStructuralError( - startNode, - getHostName(startNode), - expected, - ); - throw new HydrationTargetElementError( - result.message, - factories, - startNode, - result.expected, - result.received, - ); + throwHydrationError(startNode, factories, expectedElementBoundaryEndMarker); } /** @@ -405,12 +414,8 @@ function skipToElementBoundaryEnd( function getHydrationIndexOffset(factories: CompiledViewBehaviorFactory[]): number { let offset = 0; - for (let i = 0, ii = factories.length; i < ii; ++i) { - if (factories[i].targetNodeId === "h") { - offset++; - } else { - break; - } + while (offset < factories.length && factories[offset].targetNodeId === "h") { + offset++; } return offset; @@ -421,10 +426,11 @@ export function targetFactory( node: Node, targets: ViewBehaviorTargets, ): void { - if (factory.targetNodeId === undefined) { + const id = factory.targetNodeId; + if (id === undefined) { // Dev error, this shouldn't ever be thrown throw new Error("Factory could not be target to the node"); } - targets[factory.targetNodeId] = node; + targets[id] = node; } diff --git a/packages/fast-element/test/main.ts b/packages/fast-element/test/main.ts index 4dc195ba11f..22fea8fd092 100644 --- a/packages/fast-element/test/main.ts +++ b/packages/fast-element/test/main.ts @@ -46,6 +46,7 @@ export { export { DOM, DOMAspect } from "../src/dom.js"; export { DOMPolicy } from "../src/dom-policy.js"; export { hydrationDebugger } from "../src/hydration/hydration-debugger.js"; +export { buildViewBindingTargets } from "../src/hydration/target-builder.js"; export { Observable, observable } from "../src/observation/observable.js"; export { Updates } from "../src/observation/update-queue.js"; export { volatile } from "../src/observation/volatile.js";