Skip to content
Open
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": "prerelease",
"comment": "Add hydration configuration for streamed Declarative Shadow DOM.",
"packageName": "@microsoft/fast-element",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
2 changes: 1 addition & 1 deletion packages/fast-element/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ The previous `FAST.getById()` slot registry, `FASTGlobal` type, and `KernelServi
- Rendering is split into two modular paths. Hydration is pluggable: `enableHydration()` from `@microsoft/fast-element/hydration.js` installs a hook via `ElementController.installHydrationHook()`, keeping zero hydration imports in the core controller:
- **Prerendered**: The hydration hook (installed by `enableHydration()`) 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.
- **Client-side**: `renderClientSide()` clones the compiled fragment, binds, and appends to the host β€” the standard path with no prerender logic.
- **Static 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, fires global callbacks (`hydrationStarted`, `hydrationComplete`), and fires `hydrationComplete` via a debounced `setTimeout(0)` after the last element finishes binding β€” ensuring all async template batches settle first. Per-element hydration callbacks (`elementWillHydrate`, `elementDidHydrate`) are stored on the `FASTElementDefinition.lifecycleCallbacks` and fired directly by the hydration hook.
- **Static 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, fires global callbacks (`hydrationStarted`, `hydrationComplete`), and fires `hydrationComplete` via a debounced `setTimeout(0)` after the last element finishes binding β€” ensuring all async template batches settle first. By default, the hook no-ops for later prerendered batches after hydration completes; `enableHydration({ noopAfterHydrationComplete: false })` 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. Per-element hydration callbacks (`elementWillHydrate`, `elementDidHydrate`) are stored on the `FASTElementDefinition.lifecycleCallbacks` and fired directly by the hydration hook.
- 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.
- Exposes `addBehavior` / `removeBehavior` for dynamic `HostBehavior` management (used by `ElementStyles`).
Expand Down
10 changes: 10 additions & 0 deletions packages/fast-element/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,16 @@ enableHydration({
});
```

By default, hydration handles the initial prerendered batch and then no-ops
after `hydrationComplete` fires. If your app streams Declarative Shadow DOM
after the initial batch, keep the hydration hook active:

```typescript
enableHydration({
noopAfterHydrationComplete: false,
});
```

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:

- **`isPrerendered: Promise<boolean>`** β€” resolves `true` when the element had a declarative shadow root (DSD) at connect time, regardless of whether hydration ran.
Expand Down
2 changes: 1 addition & 1 deletion packages/fast-element/SIZES.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Bundle sizes for `@microsoft/fast-element` exports.
| html (@microsoft/fast-element/html.js) | 25.92 KB | 8.50 KB | 7.61 KB |
| repeat (@microsoft/fast-element/repeat.js) | 29.57 KB | 9.41 KB | 8.48 KB |
| css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B |
| enableHydration (@microsoft/fast-element/hydration.js) | 43.27 KB | 13.19 KB | 11.88 KB |
| enableHydration (@microsoft/fast-element/hydration.js) | 43.62 KB | 13.27 KB | 11.95 KB |
| declarativeTemplate (@microsoft/fast-element/declarative.js) | 58.77 KB | 18.45 KB | 16.46 KB |
| ArrayObserver (@microsoft/fast-element/array-observer.js) | 12.51 KB | 4.45 KB | 4.01 KB |
| observerMap (@microsoft/fast-element/observer-map.js) | 20.41 KB | 7.24 KB | 6.52 KB |
Expand Down
11 changes: 8 additions & 3 deletions packages/fast-element/docs/declarative-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ sequenceDiagram
participant PerEl as TemplateLifecycleCallbacks
participant Global as HydrationOptions

App->>Global: enableHydration(globalCallbacks) [optional]
App->>Global: enableHydration(options) [optional]
App->>FER: await MyElement.define({name:'my-el', template: declarativeTemplate(callbacks)}, [attributeMap(), observerMap()])
note over FER: definition composed; resolver waits for template

Expand Down Expand Up @@ -650,8 +650,13 @@ sequenceDiagram
| `elementDidDefine(name)` | `declarativeTemplate(callbacks)` | After platform registration completes. |
| `elementWillHydrate(source)` | `declarativeTemplate(callbacks)` | Before `ElementController` hydrates a prerendered instance; only after `enableHydration()`. |
| `elementDidHydrate(source)` | `declarativeTemplate(callbacks)` | After an instance is fully hydrated; only after `enableHydration()`. |
| `hydrationStarted()` | `enableHydration(options)` | Once, when the first prerendered element begins hydrating. |
| `hydrationComplete()` | `enableHydration(options)` | Once, after all prerendered elements have completed hydration. |
| `hydrationStarted()` | `enableHydration(options)` | Once per active hydration batch, when the first prerendered element begins hydrating. |
| `hydrationComplete()` | `enableHydration(options)` | Once per active hydration batch, after all prerendered elements have completed hydration. |

By default, hydration no-ops for later prerendered batches after
`hydrationComplete()` fires. Set
`enableHydration({ noopAfterHydrationComplete: false })` when Declarative Shadow
DOM may be streamed into the page after the initial hydration batch.

For usage examples see
[declarative-rendering-lifecycle.md](./declarative-rendering-lifecycle.md).
Expand Down
10 changes: 10 additions & 0 deletions packages/fast-element/docs/declarative-html.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ enableHydration({
});
```

The hydration hook no-ops for new prerendered elements after the first
`hydrationComplete` callback by default. Streaming scenarios that append
hydratable Declarative Shadow DOM later can keep the hook active:

```typescript
enableHydration({
noopAfterHydrationComplete: false,
});
```

Pass per-element lifecycle callbacks directly to `declarativeTemplate()`:

```typescript
Expand Down
12 changes: 8 additions & 4 deletions packages/fast-element/docs/declarative-rendering-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,13 @@ The lifecycle callbacks are split between two APIs:
- `elementDidHydrate(source: HTMLElement)` - Called after an element completes hydration

**Global hydration callbacks** β€” passed to `enableHydration()`:
- `hydrationStarted()` - Called once when the first prerendered element begins hydrating
- `hydrationComplete()` - Called once after all prerendered elements have completed hydration
- `hydrationStarted()` - Called when a prerendered hydration batch begins
- `hydrationComplete()` - Called after all prerendered elements in a hydration batch complete

The `hydrationComplete` callback fires only after every prerendered element has finished binding.
By default, hydration no-ops for later prerendered batches after this callback.
Set `noopAfterHydrationComplete: false` in `enableHydration()` when streaming
Declarative Shadow DOM should continue hydrating after the initial batch.

### Callback Execution Order

Expand All @@ -186,12 +189,12 @@ Template Processing Phase (asynchronous):
5. elementDidDefine(name)

Hydration Phase (per element, only when enableHydration() has been called):
6. hydrationStarted() [once, on first element]
6. hydrationStarted() [once per active hydration batch]
7. elementWillHydrate(source)
8. [Hydration occurs]
9. elementDidHydrate(source)

Completion (called once for all elements):
Completion (called once per active hydration batch):
10. hydrationComplete()
```

Expand All @@ -208,6 +211,7 @@ import { enableHydration } from "@microsoft/fast-element/hydration.js";

// Global hydration events
enableHydration({
noopAfterHydrationComplete: false,
hydrationStarted() {
console.log("Hydration started");
},
Expand Down
2 changes: 2 additions & 0 deletions packages/fast-element/docs/hydration/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export class HydrationBindingError extends Error {
export interface HydrationOptions {
hydrationComplete?(): void;
hydrationStarted?(): void;
noopAfterHydrationComplete?: boolean;
}

// @public (undocumented)
Expand All @@ -184,6 +185,7 @@ export class HydrationTracker {
add(element: HTMLElement): void;
mergeOptions(incoming: HydrationOptions): void;
remove(element: HTMLElement): void;
get shouldHydrate(): boolean;
}

// @beta
Expand Down
25 changes: 15 additions & 10 deletions packages/fast-element/src/components/enable-hydration.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Mutable } from "../interfaces.js";
import { ensureHydrationRuntime } from "../hydration/runtime.js";
import type { Mutable } from "../interfaces.js";
import { SourceLifetime } from "../observation/observable.js";
import type { ViewController } from "../templating/html-directive.js";
import type { HydratableElementViewTemplate } from "../templating/template.js";
Expand Down Expand Up @@ -28,19 +28,24 @@ let hookInstalled = false;
*
* Safe to call multiple times β€” the hydration hook is installed once
* and subsequent calls merge their options into the shared tracker.
* By default, the hook stops hydrating new prerendered elements after
* the global `hydrationComplete` callback. Set
* `noopAfterHydrationComplete` to `false` for streaming scenarios that
* append hydratable Declarative Shadow DOM after the initial batch.
*
* @example
* ```ts
* import { enableHydration } from "@microsoft/fast-element/hydration.js";
*
* enableHydration({
* noopAfterHydrationComplete: false,
* hydrationComplete() {
* console.log("hydration complete");
* },
* });
* ```
*
* @param options - Optional callbacks for global hydration events.
* @param options - Optional global hydration callbacks and behavior.
* @public
*/
export function enableHydration(options?: HydrationOptions): void {
Expand All @@ -51,9 +56,8 @@ export function enableHydration(options?: HydrationOptions): void {
hookInstalled = true;
const activeTracker = tracker;

ElementController.installHydrationHook(
(controller, template, element, host) => {
if (!isHydratable(template)) {
ElementController.installHydrationHook((controller, template, element, host) => {
if (!activeTracker.shouldHydrate || !isHydratable(template)) {
return false;
}

Expand All @@ -70,9 +74,11 @@ export function enableHydration(options?: HydrationOptions): void {
const firstChild = host.firstChild!;
const lastChild = host.lastChild!;

const view = (
template as HydratableElementViewTemplate
).hydrate(firstChild, lastChild, element);
const view = (template as HydratableElementViewTemplate).hydrate(
firstChild,
lastChild,
element,
);

(controller as any).view = view;

Expand Down Expand Up @@ -103,8 +109,7 @@ export function enableHydration(options?: HydrationOptions): void {
}

return true;
},
);
});
} else if (options && tracker) {
// Merge options into existing tracker for subsequent calls
tracker.mergeOptions(options);
Expand Down
49 changes: 41 additions & 8 deletions packages/fast-element/src/components/hydration-tracker.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
/**
* Options for configuring global hydration lifecycle events.
* Options for configuring global hydration lifecycle events and behavior.
* @public
*/
export interface HydrationOptions {
/** Called once when the first prerendered element begins hydrating. */
/** Called when a prerendered hydration batch begins. */
hydrationStarted?(): void;
/** Called after all prerendered elements have completed hydration. */
/** Called after all prerendered elements in a hydration batch complete. */
hydrationComplete?(): void;
/**
* Indicates whether the hydration hook should stop handling new
* prerendered elements after hydration completes.
*
* @defaultValue true
*/
noopAfterHydrationComplete?: boolean;
}

type HydrationCallbacks = Pick<
HydrationOptions,
"hydrationStarted" | "hydrationComplete"
>;

/**
* Tracks prerendered elements through the hydration lifecycle and
* fires global callbacks at start and completion. Per-element callbacks
Expand All @@ -20,19 +32,35 @@ export interface HydrationOptions {
export class HydrationTracker {
private elements: Set<HTMLElement> = new Set();
private started = false;
private completed = false;
private checkTimer: ReturnType<typeof setTimeout> | null = null;
private callbacks: HydrationCallbacks;
private noopAfterHydrationComplete: boolean;

constructor(private options: HydrationOptions) {}
constructor(options: HydrationOptions) {
this.callbacks = options;
this.noopAfterHydrationComplete = options.noopAfterHydrationComplete ?? true;
}

/**
* Indicates whether the hydration hook should attempt to hydrate
* prerendered elements.
*/
public get shouldHydrate(): boolean {
return !this.completed || this.noopAfterHydrationComplete === false;
}

/**
* Registers an element as pending hydration.
* Fires `hydrationStarted` on the first call.
*/
public add(element: HTMLElement): void {
this.completed = false;

if (!this.started) {
this.started = true;
try {
this.options.hydrationStarted?.();
this.callbacks.hydrationStarted?.();
} catch {
// A lifecycle callback must never prevent hydration.
}
Expand Down Expand Up @@ -60,11 +88,12 @@ export class HydrationTracker {

if (this.elements.size === 0) {
try {
this.options.hydrationComplete?.();
this.callbacks.hydrationComplete?.();
} catch {
// A lifecycle callback must never prevent post-hydration cleanup.
} finally {
this.started = false;
this.completed = true;
}
}
}, 0);
Expand All @@ -76,8 +105,8 @@ export class HydrationTracker {
* callbacks so both the original and new callbacks fire.
*/
public mergeOptions(incoming: HydrationOptions): void {
const prev = this.options;
this.options = {
const prev = this.callbacks;
this.callbacks = {
hydrationStarted: chainCallback(
prev.hydrationStarted,
incoming.hydrationStarted,
Expand All @@ -87,6 +116,10 @@ export class HydrationTracker {
incoming.hydrationComplete,
),
};

if (incoming.noopAfterHydrationComplete !== void 0) {
this.noopAfterHydrationComplete = incoming.noopAfterHydrationComplete;
}
}
}

Expand Down
Loading
Loading