Skip to content
Merged
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": "Prevent stale server-rendered structural views from being duplicated during hydration.",
"packageName": "@microsoft/fast-element",
"email": "863023+radium-v@users.noreply.github.com",
"dependentChangeType": "none"
}
9 changes: 6 additions & 3 deletions packages/fast-element/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,11 @@ The previous `FAST.getById()` slot registry, `FASTGlobal` type, and `KernelServi
- During hydration, server-rendered markup is treated as an optimisation over the
client template rather than as a source of blank output. If a `render()`
directive has an expected binding target but no SSR view boundaries, it creates
and binds the client view at the hydrated location. `repeat()` hydrates the
overlapping SSR/client item ranges, creates client views for missing SSR
ranges, and removes extra SSR ranges when the client item count is smaller.
and binds the client view at the hydrated location. A content binding removes
an SSR structural range when its initial client value has no template, preventing
later updates from composing beside stale DOM. `repeat()` hydrates the overlapping
SSR/client item ranges, creates client views for missing SSR ranges, and removes
extra SSR ranges when the client item count is smaller.
Malformed or untargetable markers still surface structured hydration errors —
see [Hydration mismatch diagnostics](#hydration-mismatch-diagnostics) below.

Expand Down Expand Up @@ -171,6 +173,7 @@ and call `installHydrationDiagnostic` (or, more commonly, wrap your
formatter in a custom `HydrationDebugger` and pass it through
`enableHydration({ debugger })`) if you want to route diagnostics into
logging, telemetry, or a devtools panel.

- **Hydration tracking**: Hydration is opt-in via `enableHydration()` from `@microsoft/fast-element/hydration.js`, which creates a `HydrationTracker` and installs a pluggable hydration hook on `ElementController` via `ElementController.installHydrationHook()`. Until this is called, `renderTemplate()` always uses the client-side path — even if the element has a pre-existing shadow root. `HydrationTracker` manages a `Set<HTMLElement>` of pending elements and resolves the returned controller's `whenHydrated()` promise via a debounced `setTimeout(0)` after the last element finishes binding — ensuring all async template batches settle first. It also resolves tag-specific `whenHydrated(tagName)` promises when work for that FAST element tag completes. By default, the hook no-ops for later prerendered batches after hydration completes; `enableHydration({ stopHydration: StopHydration.never })` keeps the hook active for streamed Declarative Shadow DOM so new elements continue checking for an existing shadow root and hydrate instead of re-rendering it. In that mode, the global `whenHydrated()` promise intentionally remains pending.
- On `disconnect()`: calls `disconnectedCallback` on behaviors, unbinds the view.
- `onAttributeChangedCallback()` is the standard handler that processes attribute changes. During the prerendered bind, it is temporarily swapped to a no-op (see above) to avoid redundant processing of server-rendered attribute values.
Expand Down
8 changes: 4 additions & 4 deletions packages/fast-element/SIZES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Bundle sizes for `@microsoft/fast-element` exports.

| Export | Minified | Gzip | Brotli |
|--------|----------|------|--------|
| CDN Rollup Bundle | 78.61 KB | 23.40 KB | 20.77 KB |
| CDN Rollup Bundle | 78.79 KB | 23.43 KB | 20.84 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 |
Expand All @@ -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.73 KB | 8.96 KB | 8.02 KB |
| 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 |
| css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B |
| enableHydration (@microsoft/fast-element/hydration.js) | 46.53 KB | 13.91 KB | 12.49 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 62.15 KB | 19.46 KB | 17.42 KB |
| 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 |
| 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 |
14 changes: 14 additions & 0 deletions packages/fast-element/src/templating/html-binding-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "./html-directive.js";
import { HydrationStage } from "./hydration-view.js";
import { Markup } from "./markup.js";
import { removeNodeSequence } from "./view.js";

type UpdateTarget = (
this: HTMLBindingDirective,
Expand Down Expand Up @@ -179,6 +180,19 @@ function updateContent(
}
}

if (
view === void 0 &&
isHydratable(controller) &&
controller.hydrationStage !== HydrationStage.hydrated
) {
const viewNodes = controller.bindingViewBoundaries[this.targetNodeId];

if (viewNodes !== void 0) {
removeNodeSequence(viewNodes.first, viewNodes.last);
delete controller.bindingViewBoundaries[this.targetNodeId];
}
}

target.textContent = value;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ Fixtures for complex scenarios that may involve multiple features interacting to
| Fixture | Description |
|---|---|
| `duplicate-template-names` | Duplicate connected `<f-template>` publishers with the same `name` attribute keep the first template assignment for a simple bound element. |
| `nested-elements` | Nested custom elements with state propagation through shadow boundaries, event handling inside `f-repeat` with `$c.parent` context access, and `f-when` conditions within repeated content. |
| `nested-elements` | Nested custom elements with state propagation through shadow boundaries, parent-to-child property binding hydration, event handling inside `f-repeat` with `$c.parent` context access, and `f-when` conditions within repeated content. |
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<parent-element title="Single Item" :items="{{singleItem}}" category="{{category}}"></parent-element>
<test-element-repeat-event></test-element-repeat-event>
<test-when-in-repeat></test-when-in-repeat>
<parent-binding-host></parent-binding-host>
<script type="module" src="./main.ts"></script>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ <h2><!--fe:b-->Single Item<!--fe:/b--></h2>
</li>
<!--fe:/r--><!--fe:/b-->
</ul></template></test-when-in-repeat>
<parent-binding-host><template shadowrootmode="open" shadowroot="open"><!--fe:b--><!--fe:r-->
<parent-bound-child appearance="full-page" data-fe="2"><template shadowrootmode="open" shadowroot="open"><!--fe:b-->
<!--fe:b-->
<div class="progress"><!--fe:b-->20<!--fe:/b-->%</div>
<!--fe:/b-->
<!--fe:b-->
<!--fe:b--><!--fe:r-->
<button class="action" type="button"><!--fe:b-->Pause<!--fe:/b--></button>
<!--fe:/r--><!--fe:r-->
<button class="action" type="button"><!--fe:b-->Cancel<!--fe:/b--></button>
<!--fe:/r--><!--fe:/b-->
<!--fe:/b-->
<!--fe:/b--></template></parent-bound-child>
<!--fe:/r--><!--fe:/b--></template></parent-binding-host>
<f-template name="test-element-repeat-event">
<template>
<ul>
Expand All @@ -90,6 +104,31 @@ <h2><!--fe:b-->Single Item<!--fe:/b--></h2>
</ul>
</template>
</f-template>
<f-template name="parent-bound-child">
<template>
<f-when value="{{appearance == 'full-page'}}">
<f-when value="{{progress}}">
<div class="progress">{{progress.percent}}%</div>
</f-when>
<f-when value="{{actions && actions.trailing}}">
<f-repeat value="{{action in actions.trailing}}">
<button class="action" type="button">{{action.label}}</button>
</f-repeat>
</f-when>
</f-when>
</template>
</f-template>
<f-template name="parent-binding-host">
<template>
<f-repeat value="{{item in parentBoundItems}}">
<parent-bound-child
appearance="full-page"
:actions="{{item.actions}}"
:progress="{{item.progress}}"
></parent-bound-child>
</f-repeat>
</template>
</f-template>
<f-template name="grand-child-element">
<template>
<span class="category">{{category}}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import { observerMap } from "@microsoft/fast-element/observer-map.js";

(window as any).messages = [];

const parentBindingHost = document.querySelector("parent-binding-host");
const parentBoundChild =
parentBindingHost?.shadowRoot?.querySelector("parent-bound-child");
(window as any).parentBoundChildSsrCounts = {
actionButtons: parentBoundChild?.shadowRoot?.querySelectorAll("button.action").length,
progressViews: parentBoundChild?.shadowRoot?.querySelectorAll(".progress").length,
};

const hydration = enableHydration();
void hydration.whenHydrated().then(() => {
(window as any).messages.push(`Hydration complete [${performance.now()}]`);
Expand Down Expand Up @@ -165,6 +173,55 @@ TestWhenInRepeat.define(
[observerMap()],
);

interface ParentBoundAction {
label: string;
}

interface ParentBoundActionConfig {
trailing: ParentBoundAction[];
}

interface ParentBoundProgress {
percent: number;
}

interface ParentBoundItem {
actions: ParentBoundActionConfig;
progress: ParentBoundProgress;
}

export class ParentBoundChild extends FASTElement {
@attr appearance = "";
@observable actions?: ParentBoundActionConfig;
@observable progress?: ParentBoundProgress;
}
ParentBoundChild.define(
{
name: "parent-bound-child",
template: declarativeTemplate(),
},
[observerMap()],
);

export class ParentBindingHost extends FASTElement {
@observable
parentBoundItems: ParentBoundItem[] = [
{
actions: {
trailing: [{ label: "Pause" }, { label: "Cancel" }],
},
progress: { percent: 20 },
},
];
}
ParentBindingHost.define(
{
name: "parent-binding-host",
template: declarativeTemplate(),
},
[observerMap()],
);

ItemList.define(
{
name: "parent-element",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { expect, test } from "@playwright/test";
import type { ItemList, TestElementRepeatEvent, TestWhenInRepeat } from "./main.js";
import type {
ItemList,
ParentBindingHost,
ParentBoundChild,
TestElementRepeatEvent,
TestWhenInRepeat,
} from "./main.js";

test.describe("Nested Elements Hydration", () => {
test("should render nested elements correctly", async ({ page }) => {
Expand Down Expand Up @@ -59,6 +65,46 @@ test.describe("Nested Elements Hydration", () => {
await expect(categoryText).toHaveText("Updated");
}
});

test("should not duplicate child structural views when parent property bindings hydrate", async ({
page,
}) => {
const hydrationCompleted = page.waitForFunction(
() => (window as any).hydrationCompleted === true,
);
await page.goto("/fixtures/scenarios/nested-elements/");
await hydrationCompleted;

const child = page.locator("parent-bound-child");
const result = await child.evaluate(async (node: ParentBoundChild) => {
const root = node.getRootNode() as ShadowRoot;
const parent = root.host as ParentBindingHost;

return {
hydrated: {
actionButtons:
node.shadowRoot!.querySelectorAll("button.action").length,
childHydrated: await node.$fastController.isHydrated,
parentHydrated: await parent.$fastController.isHydrated,
progressViews: node.shadowRoot!.querySelectorAll(".progress").length,
},
ssr: (window as any).parentBoundChildSsrCounts,
};
});

expect(result).toEqual({
hydrated: {
actionButtons: 2,
childHydrated: true,
parentHydrated: true,
progressViews: 1,
},
ssr: {
actionButtons: 2,
progressViews: 1,
},
});
});
});

test.describe("f-repeat event binding", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,13 @@
"category": "General",
"repeatEventItems": [],
"whenRepeatItems": [{ "name": "Alpha" }, { "name": "Beta" }],
"showNames": true
"showNames": true,
"parentBoundItems": [
{
"actions": {
"trailing": [{ "label": "Pause" }, { "label": "Cancel" }]
},
"progress": { "percent": 20 }
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,31 @@
</ul>
</template>
</f-template>
<f-template name="parent-bound-child">
<template>
<f-when value="{{appearance == 'full-page'}}">
<f-when value="{{progress}}">
<div class="progress">{{progress.percent}}%</div>
</f-when>
<f-when value="{{actions && actions.trailing}}">
<f-repeat value="{{action in actions.trailing}}">
<button class="action" type="button">{{action.label}}</button>
</f-repeat>
</f-when>
</f-when>
</template>
</f-template>
<f-template name="parent-binding-host">
<template>
<f-repeat value="{{item in parentBoundItems}}">
<parent-bound-child
appearance="full-page"
:actions="{{item.actions}}"
:progress="{{item.progress}}"
></parent-bound-child>
</f-repeat>
</template>
</f-template>
<f-template name="grand-child-element">
<template>
<span class="category">{{category}}</span>
Expand Down
8 changes: 4 additions & 4 deletions sites/website/src/docs/3.x/resources/export-sizes.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Bundle sizes for `@microsoft/fast-element` exports.

| Export | Minified | Gzip | Brotli |
|--------|----------|------|--------|
| CDN Rollup Bundle | 78.61 KB | 23.40 KB | 20.77 KB |
| CDN Rollup Bundle | 78.79 KB | 23.43 KB | 20.84 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 |
Expand All @@ -30,11 +30,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.73 KB | 8.96 KB | 8.02 KB |
| 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 |
| css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B |
| enableHydration (@microsoft/fast-element/hydration.js) | 46.53 KB | 13.91 KB | 12.49 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 62.15 KB | 19.46 KB | 17.42 KB |
| 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 |
| 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 |
Loading