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