From 544fb5acc1f811cc6f1be291dfd175cf9747cbed Mon Sep 17 00:00:00 2001
From: AK <144495202+AKnassa@users.noreply.github.com>
Date: Sun, 12 Jul 2026 00:20:19 -0400
Subject: [PATCH] fix: reconcile prerendered content a content binding does not
adopt
A content binding hydrates by adopting the nodes the server rendered between
its boundary markers. When it could not adopt them it left them in the DOM
owned by no view, so nothing ever bound them, and once the value resolved a
second, client-rendered copy was composed alongside them.
This happens when a `when` condition is falsy as the element hydrates but the
server rendered the branch, which is the case whenever the condition depends
on data that has not resolved yet. `repeat` is unaffected because it
reconciles its prerendered item views against the live DOM instead.
Discard the prerendered range the binding does not adopt, consuming the
boundary so a range is discarded at most once.
Adds a fast-html hydration fixture covering a `defer-and-hydrate` child
rendered inside a conditional content binding, with the equivalent repeat
binding as a control.
Fixes #7634
---
...-92fae02f-bbcf-4b67-87b0-301d74cd58c0.json | 7 +
...-fcbdf1a0-f598-4dca-99ac-7936667294ee.json | 7 +
.../src/templating/html-binding-directive.ts | 81 ++++++++++--
.../fixtures/when-nested-elements/entry.html | 15 +++
.../fast-build.config.json | 6 +
.../fixtures/when-nested-elements/index.html | 73 +++++++++++
.../fixtures/when-nested-elements/main.ts | 123 ++++++++++++++++++
.../fixtures/when-nested-elements/state.json | 5 +
.../when-nested-elements/templates.html | 41 ++++++
.../when-nested-elements.spec.ts | 96 ++++++++++++++
10 files changed, 446 insertions(+), 8 deletions(-)
create mode 100644 change/@microsoft-fast-element-92fae02f-bbcf-4b67-87b0-301d74cd58c0.json
create mode 100644 change/@microsoft-fast-html-fcbdf1a0-f598-4dca-99ac-7936667294ee.json
create mode 100644 packages/fast-html/test/fixtures/when-nested-elements/entry.html
create mode 100644 packages/fast-html/test/fixtures/when-nested-elements/fast-build.config.json
create mode 100644 packages/fast-html/test/fixtures/when-nested-elements/index.html
create mode 100644 packages/fast-html/test/fixtures/when-nested-elements/main.ts
create mode 100644 packages/fast-html/test/fixtures/when-nested-elements/state.json
create mode 100644 packages/fast-html/test/fixtures/when-nested-elements/templates.html
create mode 100644 packages/fast-html/test/fixtures/when-nested-elements/when-nested-elements.spec.ts
diff --git a/change/@microsoft-fast-element-92fae02f-bbcf-4b67-87b0-301d74cd58c0.json b/change/@microsoft-fast-element-92fae02f-bbcf-4b67-87b0-301d74cd58c0.json
new file mode 100644
index 00000000000..4c20fc92227
--- /dev/null
+++ b/change/@microsoft-fast-element-92fae02f-bbcf-4b67-87b0-301d74cd58c0.json
@@ -0,0 +1,7 @@
+{
+ "type": "patch",
+ "comment": "fix: reconcile prerendered content that a content binding does not adopt while hydrating, so content inside a when() whose condition has not resolved is no longer left in the DOM unowned and then duplicated once it does.",
+ "packageName": "@microsoft/fast-element",
+ "email": "144495202+AKnassa@users.noreply.github.com",
+ "dependentChangeType": "none"
+}
diff --git a/change/@microsoft-fast-html-fcbdf1a0-f598-4dca-99ac-7936667294ee.json b/change/@microsoft-fast-html-fcbdf1a0-f598-4dca-99ac-7936667294ee.json
new file mode 100644
index 00000000000..ffe419a5ab1
--- /dev/null
+++ b/change/@microsoft-fast-html-fcbdf1a0-f598-4dca-99ac-7936667294ee.json
@@ -0,0 +1,7 @@
+{
+ "type": "none",
+ "comment": "test: add a hydration fixture covering a deferred child rendered inside a conditional content binding.",
+ "packageName": "@microsoft/fast-html",
+ "email": "144495202+AKnassa@users.noreply.github.com",
+ "dependentChangeType": "none"
+}
diff --git a/packages/fast-element/src/templating/html-binding-directive.ts b/packages/fast-element/src/templating/html-binding-directive.ts
index 196a2f34f92..c69cf3ae2c4 100644
--- a/packages/fast-element/src/templating/html-binding-directive.ts
+++ b/packages/fast-element/src/templating/html-binding-directive.ts
@@ -91,6 +91,59 @@ function isContentTemplate(value: any): value is ContentTemplate {
return value.create !== undefined;
}
+/**
+ * Discards the prerendered nodes of a content binding that the binding did not adopt.
+ *
+ * A content binding hydrates by adopting the nodes the server rendered between its
+ * boundary markers. When it cannot adopt them - because the value is no longer a
+ * template, or because the view has already hydrated - those nodes must be reconciled
+ * away, just as `repeat` discards the prerendered item views it does not adopt.
+ *
+ * Left in place they are owned by no view, so nothing ever binds them: their bindings
+ * are never wired to the source, and once the value does resolve, a second,
+ * client-rendered copy is composed alongside them.
+ *
+ * The boundary is consumed, so a range is discarded at most once.
+ */
+function discardUnadoptedContent(
+ directive: HTMLBindingDirective,
+ controller: ViewController,
+): void {
+ if (!isHydratable(controller)) {
+ return;
+ }
+
+ const viewNodes = controller.bindingViewBoundaries[directive.targetNodeId];
+
+ if (viewNodes === undefined) {
+ return;
+ }
+
+ delete controller.bindingViewBoundaries[directive.targetNodeId];
+
+ const { first, last } = viewNodes;
+ const parent = first.parentNode;
+
+ if (parent === null) {
+ return;
+ }
+
+ let current: Node | null = first;
+
+ while (current !== null) {
+ const next: Node | null = current.nextSibling;
+ const isLast = current === last;
+
+ parent.removeChild(current);
+
+ if (isLast) {
+ return;
+ }
+
+ current = next;
+ }
+}
+
/**
* Sink function for DOMAspect.content bindings (text content interpolation).
* Handles two cases:
@@ -127,8 +180,12 @@ function updateContent(
controller.hydrationStage !== HydrationStage.hydrated
) {
const viewNodes = controller.bindingViewBoundaries[this.targetNodeId];
+ delete controller.bindingViewBoundaries[this.targetNodeId];
view = value.hydrate(viewNodes.first, viewNodes.last);
} else {
+ // The prerendered nodes cannot be adopted, so reconcile them away
+ // before rendering the value on the client.
+ discardUnadoptedContent(this, controller);
view = value.create();
}
} else {
@@ -163,15 +220,23 @@ function updateContent(
// If there is a view and it's currently composed into
// the DOM, then we need to remove it.
- if (view !== void 0 && view.isComposed) {
- view.isComposed = false;
- view.remove();
-
- if (view.needsBindOnly) {
- view.needsBindOnly = false;
- } else {
- view.unbind();
+ if (view !== void 0) {
+ if (view.isComposed) {
+ view.isComposed = false;
+ view.remove();
+
+ if (view.needsBindOnly) {
+ view.needsBindOnly = false;
+ } else {
+ view.unbind();
+ }
}
+ } else {
+ // No view ever composed this target, so any prerendered nodes between the
+ // binding's boundary markers are owned by nothing. This happens when the
+ // value is falsy on hydration but the server rendered content for it -
+ // a `when` whose condition has not resolved yet, for instance.
+ discardUnadoptedContent(this, controller);
}
target.textContent = value;
diff --git a/packages/fast-html/test/fixtures/when-nested-elements/entry.html b/packages/fast-html/test/fixtures/when-nested-elements/entry.html
new file mode 100644
index 00000000000..c410043fe15
--- /dev/null
+++ b/packages/fast-html/test/fixtures/when-nested-elements/entry.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ When Nested Elements Hydration Tests
+
+
+
+
+
+
+
+
+
diff --git a/packages/fast-html/test/fixtures/when-nested-elements/fast-build.config.json b/packages/fast-html/test/fixtures/when-nested-elements/fast-build.config.json
new file mode 100644
index 00000000000..c291972cdad
--- /dev/null
+++ b/packages/fast-html/test/fixtures/when-nested-elements/fast-build.config.json
@@ -0,0 +1,6 @@
+{
+ "entry": "entry.html",
+ "state": "state.json",
+ "output": "index.html",
+ "templates": "templates.html"
+}
diff --git a/packages/fast-html/test/fixtures/when-nested-elements/index.html b/packages/fast-html/test/fixtures/when-nested-elements/index.html
new file mode 100644
index 00000000000..7283a374b4a
--- /dev/null
+++ b/packages/fast-html/test/fixtures/when-nested-elements/index.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+ When Nested Elements Hydration Tests
+
+
+
+
+ Hello
+
+
+
+
+ Hello
+
+
+
+
+ Hello
+
+
+
+
+ Hello
+
+
+
+
+ {{text}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/fast-html/test/fixtures/when-nested-elements/main.ts b/packages/fast-html/test/fixtures/when-nested-elements/main.ts
new file mode 100644
index 00000000000..14cb8103af0
--- /dev/null
+++ b/packages/fast-html/test/fixtures/when-nested-elements/main.ts
@@ -0,0 +1,123 @@
+import { attr, FASTElement, observable } from "@microsoft/fast-element";
+import { RenderableFASTElement, TemplateElement } from "@microsoft/fast-html";
+
+const RenderableElement = RenderableFASTElement(FASTElement);
+
+// Mock data source - simulating fetched data, matching the pre-rendered state.
+const mockDataSource = {
+ getData() {
+ return { show: true, text: "Hello", items: [{ text: "Hello" }] };
+ },
+};
+
+/**
+ * Renders a `defer-and-hydrate` child inside a conditional content binding. The
+ * condition is true at render time, so the pre-rendered DOM contains the child.
+ */
+export class WhenParent extends RenderableElement {
+ @observable
+ public show: boolean = false;
+
+ @observable
+ public text: string = "";
+
+ async prepare() {
+ const data = mockDataSource.getData();
+
+ this.show = data.show;
+ this.text = data.text;
+ }
+}
+
+/**
+ * The control: the same child rendered through a repeat binding instead. Swapping
+ * the surrounding directive is the only difference between this and WhenParent.
+ */
+export class RepeatParent extends RenderableElement {
+ @observable
+ public items: Array<{ text: string }> = [];
+
+ async prepare() {
+ this.items = mockDataSource.getData().items;
+ }
+}
+
+/**
+ * The condition was true when the page was rendered, so the pre-rendered DOM
+ * contains the child, but the value backing the condition has not resolved by the
+ * time the element hydrates - as happens when the condition depends on data that
+ * arrives after hydration. The pre-rendered content must be reconciled away rather
+ * than left behind, and must not be duplicated once the value does resolve.
+ */
+export class WhenStaleParent extends RenderableElement {
+ @observable
+ public show: boolean = false;
+
+ @observable
+ public text: string = "Hello";
+}
+
+/**
+ * The control for {@link WhenStaleParent}: a repeat binding reconciles the same
+ * pre-rendered/hydration mismatch correctly.
+ */
+export class RepeatStaleParent extends RenderableElement {
+ @observable
+ public items: Array<{ text: string }> = [];
+}
+
+export class DeferredChild extends RenderableElement {
+ @attr
+ public text: string = "";
+}
+
+RenderableFASTElement(WhenParent).defineAsync({
+ name: "when-parent",
+ templateOptions: "defer-and-hydrate",
+});
+
+RenderableFASTElement(RepeatParent).defineAsync({
+ name: "repeat-parent",
+ templateOptions: "defer-and-hydrate",
+});
+
+RenderableFASTElement(WhenStaleParent).defineAsync({
+ name: "when-stale-parent",
+ templateOptions: "defer-and-hydrate",
+});
+
+RenderableFASTElement(RepeatStaleParent).defineAsync({
+ name: "repeat-stale-parent",
+ templateOptions: "defer-and-hydrate",
+});
+
+RenderableFASTElement(DeferredChild).defineAsync({
+ name: "deferred-child",
+ templateOptions: "defer-and-hydrate",
+});
+
+TemplateElement.options({
+ "when-parent": {
+ observerMap: "all",
+ },
+ "repeat-parent": {
+ observerMap: "all",
+ },
+ "when-stale-parent": {
+ observerMap: "all",
+ },
+ "repeat-stale-parent": {
+ observerMap: "all",
+ },
+ "deferred-child": {
+ observerMap: "all",
+ },
+})
+ .config({
+ hydrationComplete() {
+ (window as any).hydrationCompleted = true;
+ },
+ })
+ .define({
+ name: "f-template",
+ });
diff --git a/packages/fast-html/test/fixtures/when-nested-elements/state.json b/packages/fast-html/test/fixtures/when-nested-elements/state.json
new file mode 100644
index 00000000000..92f0bfd5011
--- /dev/null
+++ b/packages/fast-html/test/fixtures/when-nested-elements/state.json
@@ -0,0 +1,5 @@
+{
+ "show": true,
+ "text": "Hello",
+ "items": [{ "text": "Hello" }]
+}
diff --git a/packages/fast-html/test/fixtures/when-nested-elements/templates.html b/packages/fast-html/test/fixtures/when-nested-elements/templates.html
new file mode 100644
index 00000000000..6a6536b2c6e
--- /dev/null
+++ b/packages/fast-html/test/fixtures/when-nested-elements/templates.html
@@ -0,0 +1,41 @@
+
+
+ {{text}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/fast-html/test/fixtures/when-nested-elements/when-nested-elements.spec.ts b/packages/fast-html/test/fixtures/when-nested-elements/when-nested-elements.spec.ts
new file mode 100644
index 00000000000..19d70d33f1b
--- /dev/null
+++ b/packages/fast-html/test/fixtures/when-nested-elements/when-nested-elements.spec.ts
@@ -0,0 +1,96 @@
+import { expect, test } from "@playwright/test";
+
+/**
+ * A `defer-and-hydrate` child rendered inside a conditional content binding must
+ * hydrate exactly as it does inside a repeat binding. Swapping the surrounding
+ * control-flow directive should be the only difference between the two cases.
+ *
+ * See https://github.com/microsoft/fast/issues/7634.
+ */
+test.describe("When Nested Elements Hydration", () => {
+ async function hydrate(page: import("@playwright/test").Page) {
+ const hydrationCompleted = page.waitForFunction(
+ () => (window as any).hydrationCompleted === true,
+ );
+ await page.goto("/fixtures/when-nested-elements/");
+ await hydrationCompleted;
+ }
+
+ for (const parent of ["when-parent", "repeat-parent"]) {
+ test.describe(parent, () => {
+ test("should hydrate the deferred child", async ({ page }) => {
+ await hydrate(page);
+
+ const child = page.locator(`${parent} deferred-child`);
+
+ await expect(child).toHaveCount(1);
+ await expect(child).not.toHaveAttribute("defer-hydration");
+ await expect(child).not.toHaveAttribute("needs-hydration");
+ });
+
+ test("should render the deferred child's bindings", async ({ page }) => {
+ await hydrate(page);
+
+ await expect(page.locator(`${parent} deferred-child .text`)).toHaveText(
+ "Hello",
+ );
+ });
+
+ test("should keep the deferred child's bindings live after hydration", async ({
+ page,
+ }) => {
+ await hydrate(page);
+
+ await page.locator(`${parent} deferred-child`).evaluate(node => {
+ (node as any).text = "Updated";
+ });
+
+ await expect(page.locator(`${parent} deferred-child .text`)).toHaveText(
+ "Updated",
+ );
+ });
+ });
+ }
+
+ /**
+ * The pre-rendered DOM contains the child, but the value backing the binding has
+ * not resolved by the time the element hydrates. Both directives must reconcile
+ * the pre-rendered content away, and neither may leave it behind as an inert,
+ * un-hydrated subtree.
+ */
+ for (const [parent, resolve] of [
+ ["when-stale-parent", (node: any) => (node.show = true)],
+ ["repeat-stale-parent", (node: any) => (node.items = [{ text: "Hello" }])],
+ ] as const) {
+ test.describe(parent, () => {
+ test("should render exactly one hydrated child once the value resolves", async ({
+ page,
+ }) => {
+ await hydrate(page);
+
+ await page.locator(parent).evaluate(resolve);
+
+ const child = page.locator(`${parent} deferred-child`);
+
+ await expect(child).toHaveCount(1);
+ await expect(child).not.toHaveAttribute("defer-hydration");
+ await expect(child).not.toHaveAttribute("needs-hydration");
+ await expect(page.locator(`${parent} deferred-child .text`)).toHaveText(
+ "Hello",
+ );
+ });
+ });
+ }
+
+ test.describe("when-stale-parent", () => {
+ test("should discard the prerendered content it cannot adopt", async ({
+ page,
+ }) => {
+ await hydrate(page);
+
+ // The conditional content binding cannot adopt the prerendered child, so it
+ // must reconcile it away rather than leave it in the DOM owned by no view.
+ await expect(page.locator("when-stale-parent deferred-child")).toHaveCount(0);
+ });
+ });
+});