Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 12 additions & 0 deletions packages/fast-element/src/templating/html-binding-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
38 changes: 37 additions & 1 deletion packages/fast-html/src/components/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
34 changes: 34 additions & 0 deletions packages/fast-html/test/fixtures/when-event/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<title></title>
<script type="module" src="./main.ts"></script>
</head>
<body>
<!-- Template uses "serverOnly" which is NOT a property on test-element.
This simulates <if condition="serverOnly"> where the server state has serverOnly=true
but the client class doesn't know about it. -->
<f-template name="test-element">
<template><f-when value="{{serverOnly}}"><button @click="{handleClick()}">Click me</button></f-when></template>
</f-template>
<!-- SSR: condition was true, so button is rendered with hydration markers -->
<test-element id="when-event-show">
<template shadowrootmode="open"><!--fe-b$$start$$0$$MRl5Rw6tl3$$fe-b--><button data-fe-b-0>Click me</button><!--fe-b$$end$$0$$MRl5Rw6tl3$$fe-b--></template>
</test-element>
<!-- SSR: condition was false, so no content between markers -->
<test-element id="when-event-hide">
<template shadowrootmode="open"><!--fe-b$$start$$0$$MRl5Rw6tl3$$fe-b--><!--fe-b$$end$$0$$MRl5Rw6tl3$$fe-b--></template>
</test-element>

<!-- test-element-false: someprop is explicitly false on the client,
but the server rendered with someprop=true, so SSR content is present.
After hydration the client value should win and content should be removed. -->
<f-template name="test-element-false">
<template><f-when value="{{someprop}}">anything, really</f-when></template>
</f-template>
<test-element-false id="false-prop">
<template shadowrootmode="open"><!--fe-b$$start$$0$$MRl5Rw6tl3$$fe-b-->anything, really<!--fe-b$$end$$0$$MRl5Rw6tl3$$fe-b--></template>
</test-element-false>
</body>
</html>
34 changes: 34 additions & 0 deletions packages/fast-html/test/fixtures/when-event/main.ts
Original file line number Diff line number Diff line change
@@ -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 <if condition="..."> 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",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<f-when value="{{serverOnly}}"><button @click="{handleClick()}">Click me</button></f-when>
3 changes: 3 additions & 0 deletions packages/fast-html/test/fixtures/when-event/when-event.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"serverOnly": true
}
52 changes: 52 additions & 0 deletions packages/fast-html/test/fixtures/when-event/when-event.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading