Skip to content

Commit 5817453

Browse files
janechuCopilot
andcommitted
feat: add isHydrated promise and fix isPrerendered semantics
Split isPrerendered into two separate promises: - isPrerendered: resolves true when DSD shadow root existed at connect - isHydrated: resolves true only when hydration actually ran Fix templates.html formatting that broke declarative bindings. Update DESIGN.md, README.md, DECLARATIVE_RENDERING_LIFECYCLE.md, migration guide, and lifecycle-callbacks docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b87a4ca commit 5817453

24 files changed

Lines changed: 272 additions & 91 deletions

packages/fast-element/DECLARATIVE_RENDERING_LIFECYCLE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,11 @@ Once the template is attached to the partial definition, the element completes i
9696
When custom elements are instantiated in the DOM, the following occurs:
9797

9898
1. **Element Creation**: The platform creates instances of the custom element
99-
2. **Prerendered Content Detection**: `ElementController` detects the existing shadow root from SSR and sets `isPrerendered = true`
100-
3. **Concrete Template Ready**: Because `declarativeTemplate()` resolved during
99+
2. **Prerendered Content Detection**: `ElementController` detects the existing shadow root from SSR — `isPrerendered` resolves `true`
100+
3. **Hydration Check**: If `enableHydration()` was called and the template is hydratable, the element hydrates — `isHydrated` resolves `true`. Otherwise it falls back to client-side rendering.
101+
4. **Concrete Template Ready**: Because `declarativeTemplate()` resolved during
101102
definition, `connect()` starts with the final template already attached.
102-
4. **Hydration**: `ElementController` uses `template.hydrate()` to create a
103+
5. **Hydration**: `ElementController` uses `template.hydrate()` to create a
103104
`HydrationView` that maps existing DOM nodes to binding targets using `fe:b`
104105
/ `fe:/b` markers
105106

packages/fast-element/DESIGN.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ registry.
9393
- Extends `PropertyChangeNotifier` so the element itself participates in the observable system.
9494
- Holds the element's `FASTElementDefinition` (name, template, styles, observed attributes).
9595
- Manages a `Stages` state machine: `disconnected → connecting → connected → disconnecting → disconnected`.
96-
- Exposes `isPrerendered: Promise<boolean>` which resolves to `true` after prerendered content has been hydrated, or `false` when the component is client-side rendered. The `ViewController` interface also exposes `isPrerendered` as `Promise<boolean>` for custom directives. Attribute-skip logic during the hydration bind uses an internal `_skipAttrUpdates` flag that is never exposed as a public boolean.
96+
- Exposes `isPrerendered: Promise<boolean>` which resolves to `true` when the element had a declarative shadow root (DSD) at connect time, regardless of whether hydration ran. Exposes `isHydrated: Promise<boolean>` which resolves to `true` only when hydration actually ran successfully. The `ViewController` interface also exposes both `isPrerendered` and `isHydrated` as `Promise<boolean>` for custom directives. Attribute-skip logic during the hydration bind uses an internal `_skipAttrUpdates` flag that is never exposed as a public boolean.
9797
- On `connect()`: restores pre-upgrade observable values, calls `connectedCallback` on all `HostBehavior`s, renders the current template into the shadow root when one is available, and applies styles.
9898
- Rendering is split into two modular paths via `renderPrerendered()` and `renderClientSide()`:
9999
- **Prerendered**: `renderPrerendered()` is only reachable when hydration has been explicitly enabled via `enableHydration()`. It registers the element in the static hydration tracker, fires the definition's `elementWillHydrate` callback, swaps `onAttributeChangedCallback` to a no-op so the upgrade-time burst of callbacks is discarded, hydrates the existing DOM via `template.hydrate()`, fires `elementDidHydrate`, then restores the standard handler and removes the element from the tracker. The entire method is wrapped in `try/finally` to guarantee cleanup even if an error occurs during hydration. After this point, all future attribute changes flow through the real handler with zero overhead.
@@ -378,13 +378,13 @@ flowchart TD
378378
379379
CONN[connectedCallback] --> STAGE[stage = connecting]
380380
STAGE --> PRERENDER{Existing shadow root\nfrom SSR/DSD?}
381-
PRERENDER -->|yes| SETFLAG[isPrerendered = true]
382-
PRERENDER -->|no| NORMAL[isPrerendered = false]
381+
PRERENDER -->|yes| SETFLAG[isPrerendered = true\nisHydrated = pending]
382+
PRERENDER -->|no| NORMAL[isPrerendered = false\nisHydrated = false]
383383
SETFLAG --> OBS[Restore pre-upgrade observable values]
384384
NORMAL --> OBS
385385
OBS --> BEHAV[Connect HostBehaviors]
386386
BEHAV --> RENDER{isPrerendered AND\ntemplate is hydratable?}
387-
RENDER -->|yes| HYDRATE[template.hydrate → HydrationView\nmaps existing DOM to binding targets]
387+
RENDER -->|yes| HYDRATE[template.hydrate → HydrationView\nmaps existing DOM to binding targets\nisHydrated = true]
388388
RENDER -->|no| CLONE[ViewTemplate.render → HTMLView.appendTo shadow root]
389389
HYDRATE --> STYLES[Apply ElementStyles to shadow root]
390390
CLONE --> STYLES

packages/fast-element/README.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,12 @@ enableHydration({
140140
});
141141
```
142142

143-
When hydration is enabled and a FAST element connects with an existing shadow root (from server-side rendering or declarative shadow DOM), `ElementController` detects this and hydrates instead of re-rendering. The `isPrerendered` property on the controller is a `Promise<boolean>` that resolves to `true` after prerendered content has been hydrated, or `false` when the component is client-side rendered. This enables several optimizations:
143+
When hydration is enabled and a FAST element connects with an existing shadow root (from server-side rendering or declarative shadow DOM), `ElementController` detects this and hydrates instead of re-rendering. Two properties on the controller let you inspect the result:
144+
145+
- **`isPrerendered: Promise<boolean>`** — resolves `true` when the element had a declarative shadow root (DSD) at connect time, regardless of whether hydration ran.
146+
- **`isHydrated: Promise<boolean>`** — resolves `true` only when hydration actually ran successfully.
147+
148+
This enables several optimizations:
144149

145150
- **Hydration instead of re-render**: The template uses `hydrate()` to map existing DOM nodes to binding targets rather than cloning new DOM.
146151
- **Declarative template resolution**: `declarativeTemplate()` waits for the
@@ -167,17 +172,19 @@ MyComponent.define({
167172
});
168173
```
169174

170-
Component authors can await the promise to know when hydration is complete:
175+
Component authors can await both promises to distinguish prerendered content from successful hydration:
171176

172177
```typescript
173-
this.$fastController.isPrerendered.then(prerendered => {
174-
if (!prerendered) {
175-
this.fetchData();
176-
}
177-
});
178+
const controller = this.$fastController;
179+
const prerendered = await controller.isPrerendered;
180+
const hydrated = await controller.isHydrated;
181+
182+
if (prerendered && !hydrated) {
183+
// Had DSD but hydration wasn't enabled — client-side rendered
184+
}
178185
```
179186

180-
Custom directives can also await `controller.isPrerendered` (a `Promise<boolean>` on the `ViewController` interface) to determine whether the view's content was prerendered.
187+
Custom directives can also await `controller.isPrerendered` and `controller.isHydrated` (both `Promise<boolean>` on the `ViewController` interface) to determine how the view's content was rendered.
181188

182189
## Define Extensions
183190

packages/fast-element/SIZES.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ Bundle sizes for `@microsoft/fast-element` exports.
44

55
| Export | Minified | Gzip | Brotli |
66
|--------|----------|------|--------|
7-
| CDN Rollup Bundle | 65.53 KB | 19.50 KB | 17.47 KB |
8-
| FASTElement | 25.15 KB | 7.86 KB | 7.11 KB |
7+
| CDN Rollup Bundle | 65.72 KB | 19.53 KB | 17.50 KB |
8+
| FASTElement | 25.31 KB | 7.88 KB | 7.13 KB |
99
| Updates | 3.13 KB | 1.28 KB | 1.09 KB |
1010
| Observable | 7.65 KB | 2.79 KB | 2.51 KB |
1111
| observable | 7.68 KB | 2.81 KB | 2.53 KB |
@@ -16,5 +16,5 @@ Bundle sizes for `@microsoft/fast-element` exports.
1616
| slotted | 5.46 KB | 2.12 KB | 1.87 KB |
1717
| volatile | 7.74 KB | 2.83 KB | 2.54 KB |
1818
| when | 1.99 KB | 771 B | 637 B |
19-
| html | 26.82 KB | 8.78 KB | 7.88 KB |
20-
| repeat | 30.48 KB | 9.70 KB | 8.74 KB |
19+
| html | 26.86 KB | 8.79 KB | 7.89 KB |
20+
| repeat | 30.51 KB | 9.71 KB | 8.75 KB |

packages/fast-element/docs/api-report.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ export class ElementController<TElement extends HTMLElement = HTMLElement> exten
276276
protected hasExistingShadowRoot: boolean;
277277
get isBound(): boolean;
278278
get isConnected(): boolean;
279+
readonly isHydrated: Promise<boolean>;
279280
readonly isPrerendered: Promise<boolean>;
280281
get mainStyles(): ElementStyles | null;
281282
set mainStyles(value: ElementStyles | null);
@@ -557,6 +558,7 @@ export class HTMLView<TSource = any, TParent = any> extends DefaultExecutionCont
557558
firstChild: Node;
558559
insertBefore(node: Node): void;
559560
isBound: boolean;
561+
isHydrated: Promise<boolean>;
560562
isPrerendered: Promise<boolean>;
561563
lastChild: Node;
562564
// (undocumented)
@@ -1055,6 +1057,7 @@ export type ViewBehaviorTargets = {
10551057

10561058
// @public
10571059
export interface ViewController<TSource = any, TParent = any> extends ExpressionController<TSource, TParent> {
1060+
readonly isHydrated?: Promise<boolean>;
10581061
readonly isPrerendered?: Promise<boolean>;
10591062
// @internal
10601063
readonly _skipAttrUpdates?: boolean;

packages/fast-element/src/components/element-controller.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -100,26 +100,28 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
100100
private _resolvePrerendered!: (value: boolean) => void;
101101

102102
/**
103-
* A promise that resolves with `true` after prerendered content has
104-
* been hydrated, or `false` immediately when the component is
105-
* client-side rendered. Component authors can await this to know
106-
* when the element is fully interactive:
107-
*
108-
* ```typescript
109-
* connectedCallback() {
110-
* super.connectedCallback();
111-
* this.$fastController.isPrerendered.then(prerendered => {
112-
* if (!prerendered) {
113-
* this.fetchData();
114-
* }
115-
* });
116-
* }
117-
* ```
103+
* Resolves the isHydrated promise.
104+
*/
105+
private _resolveHydrated!: (value: boolean) => void;
106+
107+
/**
108+
* Resolves `true` when the element had an existing shadow root
109+
* (from SSR or declarative shadow DOM) at connect time, `false`
110+
* otherwise.
118111
*/
119112
public readonly isPrerendered: Promise<boolean> = new Promise<boolean>(resolve => {
120113
this._resolvePrerendered = resolve;
121114
});
122115

116+
/**
117+
* Resolves `true` after prerendered content has been successfully
118+
* hydrated, or `false` when the component is client-side rendered
119+
* or hydration is not enabled.
120+
*/
121+
public readonly isHydrated: Promise<boolean> = new Promise<boolean>(resolve => {
122+
this._resolveHydrated = resolve;
123+
});
124+
123125
/**
124126
* The template used to render the component.
125127
*/
@@ -761,11 +763,12 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
761763
}
762764

763765
if (template) {
766+
const hasPrerenderedContent =
767+
this.hasExistingShadowRoot && this.needsInitialization;
764768
const tracker = ElementController.hydrationTracker;
765769
const didHydrate =
770+
hasPrerenderedContent &&
766771
tracker !== null &&
767-
this.hasExistingShadowRoot &&
768-
this.needsInitialization &&
769772
isHydratable(template);
770773

771774
if (didHydrate) {
@@ -774,9 +777,11 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
774777
this.renderClientSide(template, element, host);
775778
}
776779

777-
this._resolvePrerendered(didHydrate);
780+
this._resolvePrerendered(hasPrerenderedContent);
781+
this._resolveHydrated(didHydrate);
778782
} else if (this.needsInitialization) {
779783
this._resolvePrerendered(false);
784+
this._resolveHydrated(false);
780785
}
781786
}
782787

@@ -819,6 +824,7 @@ export class ElementController<TElement extends HTMLElement = HTMLElement>
819824
// DOM updates during bind, and set the public promise.
820825
(this.view as any)._skipAttrUpdates = true;
821826
(this.view as any).isPrerendered = Promise.resolve(true);
827+
(this.view as any).isHydrated = Promise.resolve(true);
822828
this.view!.bind(this.source);
823829
(this.view as any)._skipAttrUpdates = false;
824830
} finally {

packages/fast-element/src/components/hydration.pw.spec.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,17 @@ test.describe("The prerendered content optimization", () => {
3030

3131
return {
3232
isPrerendered: await element.$fastController.isPrerendered,
33+
isHydrated: await element.$fastController.isHydrated,
3334
shadowContent: element.shadowRoot?.innerHTML ?? "",
3435
};
3536
});
3637

3738
expect(result.isPrerendered).toBe(false);
39+
expect(result.isHydrated).toBe(false);
3840
expect(result.shadowContent).toContain("hello");
3941
});
4042

41-
test("should set isPrerendered to false when DSD exists but hydration is not enabled", async ({
43+
test("should detect DSD but not hydrate when hydration is not enabled", async ({
4244
page,
4345
}) => {
4446
await page.goto("/");
@@ -76,12 +78,15 @@ test.describe("The prerendered content optimization", () => {
7678
return {
7779
hasShadowRootBefore,
7880
isPrerendered: await element.$fastController.isPrerendered,
81+
isHydrated: await element.$fastController.isHydrated,
7982
};
8083
});
8184

8285
expect(result.hasShadowRootBefore).toBe(true);
83-
// Without enableHydration(), prerendered content is not hydrated
84-
expect(result.isPrerendered).toBe(false);
86+
// DSD exists, so isPrerendered is true
87+
expect(result.isPrerendered).toBe(true);
88+
// Without enableHydration(), content is not hydrated
89+
expect(result.isHydrated).toBe(false);
8590
});
8691

8792
test("should connect immediately when a template is assigned later", async ({

packages/fast-element/src/templating/html-directive.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,16 @@ export interface ViewController<TSource = any, TParent = any>
3333
readonly _skipAttrUpdates?: boolean;
3434

3535
/**
36-
* A promise that resolves with `true` after prerendered content
37-
* has been hydrated, or `false` when the view is client-side
38-
* rendered.
36+
* Resolves `true` when the view's host element had prerendered
37+
* content (existing shadow root).
3938
*/
4039
readonly isPrerendered?: Promise<boolean>;
40+
41+
/**
42+
* Resolves `true` after prerendered content has been hydrated,
43+
* `false` when client-side rendered or hydration not enabled.
44+
*/
45+
readonly isHydrated?: Promise<boolean>;
4146
}
4247

4348
/**

packages/fast-element/src/templating/view.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,12 @@ export class HTMLView<TSource = any, TParent = any>
255255
*/
256256
public isPrerendered: Promise<boolean> = Promise.resolve(false);
257257

258+
/**
259+
* Resolves `true` after prerendered content has been hydrated,
260+
* `false` when client-side rendered or hydration not enabled.
261+
*/
262+
public isHydrated: Promise<boolean> = Promise.resolve(false);
263+
258264
/**
259265
* The execution context the view is running within.
260266
*/

packages/fast-element/test/declarative/fixtures/ecosystem/declarative-no-hydration/declarative-no-hydration.spec.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,19 +75,25 @@ test.describe("Declarative Template Without Hydration", () => {
7575
expect(hydrationEvents).toHaveLength(0);
7676
});
7777

78-
test("isPrerendered should resolve false without hydration", async ({ page }) => {
78+
test("isPrerendered should resolve true with DSD, isHydrated should resolve false without enableHydration", async ({
79+
page,
80+
}) => {
7981
const allDefined = page.waitForFunction(
8082
() => (window as any).allDefined === true,
8183
);
8284
await page.goto("/fixtures/ecosystem/declarative-no-hydration/");
8385
await allDefined;
8486

85-
const isPrerendered = await page.evaluate(async () => {
87+
const result = await page.evaluate(async () => {
8688
const el = document.querySelector("basic-element") as any;
87-
return el.$fastController.isPrerendered;
89+
return {
90+
isPrerendered: await el.$fastController.isPrerendered,
91+
isHydrated: await el.$fastController.isHydrated,
92+
};
8893
});
8994

90-
expect(isPrerendered).toBe(false);
95+
expect(result.isPrerendered).toBe(true);
96+
expect(result.isHydrated).toBe(false);
9197
});
9298

9399
test("should support interactivity after client-side render", async ({ page }) => {

0 commit comments

Comments
 (0)