Skip to content
Draft
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": "Improve hydration fallback for render and repeat mismatch cases",
"packageName": "@microsoft/fast-element",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
7 changes: 7 additions & 0 deletions packages/fast-element/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ 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.
- 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.
Malformed or untargetable markers still surface structured hydration errors.
- **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.
- 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
158 changes: 158 additions & 0 deletions packages/fast-element/src/components/hydration.pw.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,162 @@ test.describe("The prerendered content optimization", () => {

expect(result).toEqual(["start", "complete", "start", "complete"]);
});

test("should client render when render hydration boundaries are empty", async ({
page,
}) => {
await page.goto("/");

const result = await page.evaluate(async () => {
// @ts-expect-error: Client module.
const {
enableHydration,
FASTElement,
FASTElementDefinition,
html,
render,
uniqueElementName,
} = await import("/main.js");

enableHydration();
const name = uniqueElementName();

class TestElement extends FASTElement {
value = "client rendered";

static definition = {
name,
template: html<TestElement>`
${render(x => x.value, html<string>`<span>${x => x}</span>`)}
`,
};
}

await (await FASTElementDefinition.compose(TestElement)).define();

const container = document.createElement("div");
document.body.appendChild(container);
(container as any).setHTMLUnsafe(
`<${name}><template shadowrootmode="open"><!--fe:b--><!--fe:/b--></template></${name}>`,
);

const element = container.firstElementChild as any;
await element.$fastController.isHydrated;
await new Promise(resolve => requestAnimationFrame(resolve));

return {
isHydrated: await element.$fastController.isHydrated,
text: element.shadowRoot?.textContent?.trim() ?? "",
spanCount: element.shadowRoot?.querySelectorAll("span").length ?? 0,
};
});

expect(result.isHydrated).toBe(true);
expect(result.text).toBe("client rendered");
expect(result.spanCount).toBe(1);
});

test("should create missing repeat views when SSR rendered fewer items", async ({
page,
}) => {
await page.goto("/");

const result = await page.evaluate(async () => {
// @ts-expect-error: Client module.
const {
enableHydration,
FASTElement,
FASTElementDefinition,
html,
repeat,
uniqueElementName,
} = await import("/main.js");

enableHydration();
const name = uniqueElementName();

class TestElement extends FASTElement {
items = ["one", "two", "three"];

static definition = {
name,
template: html<TestElement>`
${repeat(x => x.items, html<string>`<span>${x => x}</span>`)}
`,
};
}

await (await FASTElementDefinition.compose(TestElement)).define();

const container = document.createElement("div");
document.body.appendChild(container);
(container as any).setHTMLUnsafe(
`<${name}><template shadowrootmode="open"><!--fe:b--><!--fe:r--><span><!--fe:b-->server-one<!--fe:/b--></span><!--fe:/r--><!--fe:/b--></template></${name}>`,
);

const element = container.firstElementChild as any;
await element.$fastController.isHydrated;
await new Promise(resolve => requestAnimationFrame(resolve));

return {
text: element.shadowRoot?.textContent?.replace(/\s+/g, "") ?? "",
spanCount: element.shadowRoot?.querySelectorAll("span").length ?? 0,
};
});

expect(result.text).toBe("onetwothree");
expect(result.spanCount).toBe(3);
});

test("should remove extra repeat ranges when SSR rendered more items", async ({
page,
}) => {
await page.goto("/");

const result = await page.evaluate(async () => {
// @ts-expect-error: Client module.
const {
enableHydration,
FASTElement,
FASTElementDefinition,
html,
repeat,
uniqueElementName,
} = await import("/main.js");

enableHydration();
const name = uniqueElementName();

class TestElement extends FASTElement {
items = ["one"];

static definition = {
name,
template: html<TestElement>`
${repeat(x => x.items, html<string>`<span>${x => x}</span>`)}
`,
};
}

await (await FASTElementDefinition.compose(TestElement)).define();

const container = document.createElement("div");
document.body.appendChild(container);
(container as any).setHTMLUnsafe(
`<${name}><template shadowrootmode="open"><!--fe:b--><!--fe:r--><span><!--fe:b-->server-one<!--fe:/b--></span><!--fe:/r--><!--fe:r--><span><!--fe:b-->server-two<!--fe:/b--></span><!--fe:/r--><!--fe:/b--></template></${name}>`,
);

const element = container.firstElementChild as any;
await element.$fastController.isHydrated;
await new Promise(resolve => requestAnimationFrame(resolve));

return {
text: element.shadowRoot?.textContent?.replace(/\s+/g, "") ?? "",
spanCount: element.shadowRoot?.querySelectorAll("span").length ?? 0,
};
});

expect(result.text).toBe("one");
expect(result.spanCount).toBe(1);
});
});
7 changes: 4 additions & 3 deletions packages/fast-element/src/templating/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type ViewBehaviorFactory,
type ViewController,
} from "./html-directive.js";
import { HydrationStage } from "./hydration-view.js";
import { Markup } from "./markup.js";
import {
type CaptureType,
Expand All @@ -29,7 +30,6 @@ import {
type TemplateValue,
ViewTemplate,
} from "./template.js";
import { HydrationStage } from "./hydration-view.js";

type ComposableView = ContentView & {
isComposed?: boolean;
Expand Down Expand Up @@ -85,10 +85,11 @@ export class RenderBehavior<TSource = any> implements ViewBehavior, Subscriber {
if (viewNodes) {
this.view = this.template.hydrate(viewNodes.first, viewNodes.last);
this.bindView(this.view);
return;
}
} else {
this.refreshView();
}

this.refreshView();
}

/**
Expand Down
82 changes: 62 additions & 20 deletions packages/fast-element/src/templating/repeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,15 @@ import {
type ViewBehaviorFactory,
type ViewController,
} from "./html-directive.js";
import { HydrationStage, type HydrationView } from "./hydration-view.js";
import { Markup } from "./markup.js";
import type {
CaptureType,
HydratableSyntheticViewTemplate,
SyntheticViewTemplate,
ViewTemplate,
} from "./template.js";
import {
HydrationStage,
type HydrationView,
} from "./hydration-view.js";
import {
HTMLView,
type SyntheticView,
} from "./view.js";
import { HTMLView, type SyntheticView } from "./view.js";

/**
* Options for configuring repeat behavior.
Expand Down Expand Up @@ -81,6 +75,34 @@ function isCommentNode(node: Node): node is Comment {
return node.nodeType === Node.COMMENT_NODE;
}

interface HydrationRepeatRange {
start: Node;
end: Node;
startMarker: Comment;
endMarker: Comment;
}

function removeNodeRange(first: Node, last: Node): void {
const parentNode = first.parentNode;

if (parentNode === null) {
return;
}

let current: Node | null = first;

while (current !== null) {
const next = current.nextSibling;
parentNode.removeChild(current);

if (current === last) {
break;
}

current = next;
}
}

export class HydrationRepeatError extends Error {
constructor(
/**
Expand Down Expand Up @@ -408,12 +430,13 @@ export class RepeatBehavior<TSource = any> implements ViewBehavior, Subscriber {
return;
}

const itemCount = this.items.length;
this.views = new Array(itemCount);
const items = this.items;
const itemCount = items.length;
const views = (this.views = new Array(itemCount));

// First pass: collect all repeat marker pairs by walking backward.
// Each entry is { start: Node, end: Node } for the item content range.
const itemRanges: { start: Node; end: Node }[] = [];
// Each entry tracks both the item content range and its SSR markers.
const itemRanges: HydrationRepeatRange[] = [];
let current: Node | null = this.location.previousSibling;

while (current !== null) {
Expand All @@ -422,9 +445,9 @@ export class RepeatBehavior<TSource = any> implements ViewBehavior, Subscriber {
continue;
}

// Found repeat end marker
current.data = "";
const end = current.previousSibling;
const endMarker = current;
endMarker.data = "";
const end = endMarker.previousSibling;
if (!end) {
throw new Error(
`Error when hydrating inside "${
Expand All @@ -445,12 +468,17 @@ export class RepeatBehavior<TSource = any> implements ViewBehavior, Subscriber {
const startMarker = start;
startMarker.data = "";
current = startMarker.previousSibling;
const itemStart = startMarker.nextSibling!;
const itemStart = startMarker.nextSibling ?? endMarker;

// Empty item: start and end markers are adjacent.
const itemEnd = end === startMarker ? itemStart : end;

itemRanges.push({ start: itemStart, end: itemEnd });
itemRanges.push({
start: itemStart,
end: itemEnd,
startMarker,
endMarker,
});
break;
}
depth--;
Expand All @@ -472,11 +500,25 @@ export class RepeatBehavior<TSource = any> implements ViewBehavior, Subscriber {
itemRanges.reverse();

// Hydrate each SSR item at its correct index (0-based from start).
for (let i = 0; i < itemRanges.length && i < itemCount; i++) {
const hydrationCount = Math.min(itemRanges.length, itemCount);

for (let i = 0; i < hydrationCount; i++) {
const { start, end } = itemRanges[i];
const view = template.hydrate(start, end);
this.views[i] = view;
this.bindView(view, this.items, i, this.controller);
views[i] = view;
this.bindView(view, items, i, this.controller);
}

for (let i = hydrationCount; i < itemCount; i++) {
const view = template.create();
views[i] = view;
this.bindView(view, items, i, this.controller);
view.insertBefore(this.location);
}

for (let i = itemCount, ii = itemRanges.length; i < ii; i++) {
const { startMarker, endMarker } = itemRanges[i];
removeNodeRange(startMarker, endMarker);
}
}
}
Expand Down