diff --git a/change/@microsoft-fast-element-ccc364c3-3d9a-4477-a877-812d7fc72bff.json b/change/@microsoft-fast-element-ccc364c3-3d9a-4477-a877-812d7fc72bff.json new file mode 100644 index 00000000000..a5c35292f03 --- /dev/null +++ b/change/@microsoft-fast-element-ccc364c3-3d9a-4477-a877-812d7fc72bff.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Address hydration mismatches when a template is provided but no boundaries exist, this will skip creation of a new view.", + "packageName": "@microsoft/fast-element", + "email": "hello@mohamedmansour.com", + "dependentChangeType": "none" +} diff --git a/change/@microsoft-fast-html-1cbbfa63-0099-407f-9cd2-36fe3f44f3c3.json b/change/@microsoft-fast-html-1cbbfa63-0099-407f-9cd2-36fe3f44f3c3.json new file mode 100644 index 00000000000..f49387e66c7 --- /dev/null +++ b/change/@microsoft-fast-html-1cbbfa63-0099-407f-9cd2-36fe3f44f3c3.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Address hydration mismatches when a template is provided but no boundaries exist, this will skip creation of a new view.", + "packageName": "@microsoft/fast-html", + "email": "hello@mohamedmansour.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 48d466624db..8120ebd1eb4 100644 --- a/packages/fast-element/src/templating/html-binding-directive.ts +++ b/packages/fast-element/src/templating/html-binding-directive.ts @@ -106,6 +106,18 @@ function updateContent( // If the value has a "create" method, then it's a ContentTemplate. if (isContentTemplate(value)) { + // During hydration, if a template is provided but no view boundaries + // exist and the target text node is empty, the server did not render + // this content. Skip creating a new view to avoid a hydration mismatch. + if ( + isHydratable(controller) && + controller.hydrationStage !== HydrationStage.hydrated && + controller.bindingViewBoundaries[this.targetNodeId] === undefined && + !target.nodeValue + ) { + return; + } + target.textContent = ""; let view = target.$fastView as ComposableView; diff --git a/packages/fast-html/src/components/utilities.ts b/packages/fast-html/src/components/utilities.ts index edadc85d941..d6caae89dbb 100644 --- a/packages/fast-html/src/components/utilities.ts +++ b/packages/fast-html/src/components/utilities.ts @@ -1033,7 +1033,43 @@ export function resolveWhen( level, schema ); - return (x: boolean, c: any) => binding(x, c); + + // Raw value resolver for the expression's primary property path. + // Used during hydration to distinguish "property doesn't exist on + // the client" (undefined, server-only) from "property is explicitly falsy." + const rawBinding = !expression.expression.leftIsValue + ? pathResolver( + expression.expression.left as string, + parentContext, + level, + schema.getSchema(rootPropertyName as string) as JSONSchema + ) + : null; + + let hydrationDone = false; + return (x: boolean, c: any) => { + const result = binding(x, c); + if (result) return result; + + // During hydration, trust the server-rendered state only when the + // condition references a property not defined on the client element + // (raw value is undefined). Return true so the inner template is + // hydrated and its bindings (event listeners, etc.) are properly + // attached to the existing DOM. + // When the property IS defined but explicitly falsy (e.g. false, 0), + // respect the client value to avoid a hydration mismatch. + if (!hydrationDone) { + if (c?.hydrationStage === "hydrated") { + hydrationDone = true; + } else if (c?.hydrationStage && rawBinding) { + const rawValue = rawBinding(x, c); + if (rawValue === undefined) { + return true; + } + } + } + return result; + }; } type DataType = "array" | "object" | "primitive"; diff --git a/packages/fast-html/test/fixtures/when-event/index.html b/packages/fast-html/test/fixtures/when-event/index.html new file mode 100644 index 00000000000..04f48a9d612 --- /dev/null +++ b/packages/fast-html/test/fixtures/when-event/index.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/fast-html/test/fixtures/when-event/main.ts b/packages/fast-html/test/fixtures/when-event/main.ts new file mode 100644 index 00000000000..3eef1eedc88 --- /dev/null +++ b/packages/fast-html/test/fixtures/when-event/main.ts @@ -0,0 +1,34 @@ +import { RenderableFASTElement, TemplateElement } from "@microsoft/fast-html"; +import { FASTElement, observable } from "@microsoft/fast-element"; + +// This element intentionally does NOT define "serverOnly" as a property. +// The f-when condition references "serverOnly" which only exists in server state, +// simulating the real-world scenario where uses server-only data. +class TestElement extends FASTElement { + public clickCount: number = 0; + + public handleClick = (): void => { + this.clickCount++; + console.log("clicked:" + this.clickCount); + }; +} +RenderableFASTElement(TestElement).defineAsync({ + name: "test-element", + templateOptions: "defer-and-hydrate", +}); + +// This element explicitly defines "someprop" as false. +// The server rendered with someprop=true (from JSON), so SSR content is present. +// During hydration, the client value is false, which should be respected. +class TestElementFalse extends FASTElement { + @observable + someprop: boolean = false; +} +RenderableFASTElement(TestElementFalse).defineAsync({ + name: "test-element-false", + templateOptions: "defer-and-hydrate", +}); + +TemplateElement.define({ + name: "f-template", +}); diff --git a/packages/fast-html/test/fixtures/when-event/when-event.html b/packages/fast-html/test/fixtures/when-event/when-event.html new file mode 100644 index 00000000000..60a51f65aa8 --- /dev/null +++ b/packages/fast-html/test/fixtures/when-event/when-event.html @@ -0,0 +1 @@ + diff --git a/packages/fast-html/test/fixtures/when-event/when-event.json b/packages/fast-html/test/fixtures/when-event/when-event.json new file mode 100644 index 00000000000..c85a2e1c39d --- /dev/null +++ b/packages/fast-html/test/fixtures/when-event/when-event.json @@ -0,0 +1,3 @@ +{ + "serverOnly": true +} diff --git a/packages/fast-html/test/fixtures/when-event/when-event.spec.ts b/packages/fast-html/test/fixtures/when-event/when-event.spec.ts new file mode 100644 index 00000000000..93c2e859e8b --- /dev/null +++ b/packages/fast-html/test/fixtures/when-event/when-event.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; + +test.describe("f-when with event binding", async () => { + test("event binding inside f-when should fire after hydration", async ({ page }) => { + await page.goto("/fixtures/when-event/"); + + const customElement = page.locator("#when-event-show"); + + // Button should be visible (SSR rendered, condition true) + const button = customElement.locator("button"); + await expect(button).toHaveText("Click me"); + + // Click the button - event binding should work + await button.click(); + + // Verify the click handler fired + await expect(customElement).toHaveJSProperty("clickCount", 1); + + // Click again to confirm repeated clicks work + await button.click(); + await expect(customElement).toHaveJSProperty("clickCount", 2); + }); + + test("f-when with false condition should not create content during hydration", async ({ page }) => { + await page.goto("/fixtures/when-event/"); + + const customElement = page.locator("#when-event-hide"); + + // No button should exist (SSR condition was false) + const button = customElement.locator("button"); + await expect(button).toHaveCount(0); + + // Element should hydrate without errors + await expect(customElement).toHaveJSProperty("clickCount", 0); + }); + + test("should respect client-side false value even when server rendered content", async ({ + page, + }) => { + await page.goto("/fixtures/when-event/"); + + const element = page.locator("#false-prop"); + + // The property is explicitly false on the client element class + await expect(element).toHaveJSProperty("someprop", false); + + // After hydration, content should NOT be visible because someprop is false. + // The server rendered "anything, really" because it had someprop=true, + // but the client value is false, so the content should be removed. + await expect(element).not.toHaveText("anything, really"); + }); +});