Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
21 changes: 19 additions & 2 deletions packages/fast-html/src/components/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,8 +538,24 @@ class TemplateElement extends FASTElement {
parentContext,
level
);
const attributeBinding = (x: any, c: any) =>
binding(x, c).bind(x)(
const attributeBinding = (x: any, c: any) => {
const fn = binding(x, c);
// When inside a repeat (level > 0), the method was
// resolved from an ancestor context (e.g. c.parent).
// Bind `this` to that ancestor, not to the repeat
// item `x`, so the handler can access the host
// element's properties.
let owner = x;
if (level > 0) {
let ctx = c;
for (let i = 0; i < level; i++) {
ctx = i < level - 1 ? ctx?.parentContext : ctx?.parent;
}
if (ctx) {
owner = ctx;
}
}
return fn.bind(owner)(
...(arg === "e" ? [c.event] : []),
...(arg !== "e" && arg !== ""
? [
Expand All @@ -556,6 +572,7 @@ class TemplateElement extends FASTElement {
]
: [])
);
};
values.push(attributeBinding);
} else {
const propName = innerHTML.slice(
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
27 changes: 27 additions & 0 deletions packages/fast-html/test/fixtures/repeat-event/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<title></title>
<script type="module" src="./main.ts"></script>
</head>
<body>
<f-template name="repeat-event-element">
<template>
<ul>
<f-repeat value="{{item in items}}">
<li>
<span class="name">{{item.name}}</span>
<button class="toggle" data-name="{{item.name}}" @click="{onItemClick(e)}">Toggle</button>
<f-when value="{{item.expanded}}">
<span class="detail">Details for {{item.name}}</span>
</f-when>
</li>
</f-repeat>
</ul>
</template>
</f-template>
<!-- No SSR: let FAST render everything client-side -->
<repeat-event-element></repeat-event-element>
</body>
</html>
47 changes: 47 additions & 0 deletions packages/fast-html/test/fixtures/repeat-event/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { RenderableFASTElement, TemplateElement } from "@microsoft/fast-html";
import { FASTElement, observable } from "@microsoft/fast-element";

interface ListItem {
name: string;
expanded: boolean;
}

/**
* Minimal repro for two issues with event bindings inside f-repeat:
*
* 1. @click on a <button> inside <f-repeat> does not fire after hydration.
* 2. <f-when> inside <f-repeat> does not re-evaluate when item properties
* change via array replacement (e.g. immutable update pattern).
*/
export class RepeatEventElement extends FASTElement {
@observable
items: ListItem[] = [
{ name: "Alpha", expanded: false },
{ name: "Beta", expanded: false },
{ name: "Charlie", expanded: false },
];

public lastClicked: string = "";

public onItemClick(e: Event): void {
const target = e.currentTarget as HTMLElement;
const name = target.dataset.name ?? "";
this.lastClicked = name;
console.log("onItemClick fired:", name);

// Immutable update: replace array with new objects toggling expanded.
this.items = this.items.map((item) => {
if (item.name !== name) return item;
return { ...item, expanded: !item.expanded };
});
}
}

RenderableFASTElement(RepeatEventElement).defineAsync({
name: "repeat-event-element",
templateOptions: "defer-and-hydrate",
});

TemplateElement.define({
name: "f-template",
});
11 changes: 11 additions & 0 deletions packages/fast-html/test/fixtures/repeat-event/repeat-event.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<ul>
<f-repeat value="{{item in items}}">
<li>
<span class="name">{{item.name}}</span>
<button class="toggle" data-name="{{item.name}}" @click="{onItemClick(e)}">Toggle</button>
<f-when value="{{item.expanded}}">
<span class="detail">Details for {{item.name}}</span>
</f-when>
</li>
</f-repeat>
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"items": [
{ "name": "Alpha", "expanded": false },
{ "name": "Beta", "expanded": false },
{ "name": "Charlie", "expanded": false }
]
}
135 changes: 135 additions & 0 deletions packages/fast-html/test/fixtures/repeat-event/repeat-event.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { expect, test } from "@playwright/test";
import type { RepeatEventElement } from "./main.js";

test.describe("f-repeat with event bindings and inner f-when", () => {
test("@click on button inside f-repeat should fire after hydration", async ({
page,
}) => {
await page.goto("/fixtures/repeat-event/");

const element = page.locator("repeat-event-element");
const buttons = element.locator("button.toggle");

// All 3 buttons should be rendered from SSR
await expect(buttons).toHaveCount(3);

// Debug: inspect how the event binding resolved
const debug = await element.evaluate((el) => {
const sr = el.shadowRoot!;
const btn = sr.querySelector("button.toggle") as any;
// Collect all keys stored on the button by FAST
const fastKeys = Object.keys(btn).filter(
(k) => k.startsWith("fast") || k.startsWith("__") || k.length > 20,
);
// Check for the controller reference stored by event binding
const allKeys = Object.getOwnPropertyNames(btn);
const controllerKeys = allKeys.filter((k) => {
try {
return btn[k] && typeof btn[k] === "object" && "source" in btn[k];
} catch {
return false;
}
});
return {
fastKeys,
controllerKeys,
allNonStandardKeys: allKeys.filter(
(k) =>
!k.startsWith("on") &&
!HTMLButtonElement.prototype.hasOwnProperty(k) &&
k !== "constructor",
).slice(0, 10),
};
});
console.log("Button FAST state:", JSON.stringify(debug));

// Click the first button — event binding should work
await buttons.nth(0).click();

// Check for console messages
const messages: string[] = [];
page.on("console", (msg) => messages.push(msg.text()));

await buttons.nth(0).click();
await page.waitForTimeout(200);

console.log("Console messages after click:", messages);

// Verify the click handler fired
const lastClicked = await element.evaluate(
(el: RepeatEventElement) => el.lastClicked,
);
console.log("lastClicked:", lastClicked);
expect(lastClicked).toBe("Alpha");
});

test("clicking button should toggle expanded and show detail via f-when", async ({
page,
}) => {
await page.goto("/fixtures/repeat-event/");

const element = page.locator("repeat-event-element");
const buttons = element.locator("button.toggle");
const details = element.locator("span.detail");

// Initially no details visible (all expanded=false)
await expect(details).toHaveCount(0);

// Click Alpha toggle
await buttons.nth(0).click();

// f-when should re-evaluate: Alpha's detail should appear
await expect(details).toHaveCount(1);
await expect(details.nth(0)).toHaveText("Details for Alpha");

// Verify the item's expanded state
await expect(
element.evaluate((el: RepeatEventElement) => {
return el.items.map((i) => ({
name: i.name,
expanded: i.expanded,
}));
}),
).resolves.toEqual([
{ name: "Alpha", expanded: true },
{ name: "Beta", expanded: false },
{ name: "Charlie", expanded: false },
]);
});

test("clicking same button twice should toggle detail off", async ({
page,
}) => {
await page.goto("/fixtures/repeat-event/");

const element = page.locator("repeat-event-element");
const buttons = element.locator("button.toggle");
const details = element.locator("span.detail");

// Click Alpha on
await buttons.nth(0).click();
await expect(details).toHaveCount(1);

// Click Alpha off
await buttons.nth(0).click();
await expect(details).toHaveCount(0);
});

test("clicking different buttons should show multiple details", async ({
page,
}) => {
await page.goto("/fixtures/repeat-event/");

const element = page.locator("repeat-event-element");
const buttons = element.locator("button.toggle");
const details = element.locator("span.detail");

// Expand Alpha and Charlie
await buttons.nth(0).click();
await buttons.nth(2).click();

await expect(details).toHaveCount(2);
await expect(details.nth(0)).toHaveText("Details for Alpha");
await expect(details.nth(1)).toHaveText("Details for Charlie");
});
});
24 changes: 24 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,24 @@
<!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>
</body>
</html>
22 changes: 22 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,22 @@
import { RenderableFASTElement, TemplateElement } from "@microsoft/fast-html";
import { FASTElement } 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",
});

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>
Loading
Loading