@microsoft/fast-element 2.10.4
@microsoft/fast-html 1.0.0-alpha.54
Summary
When a component authored with @microsoft/fast-html templateOptions defer-and-hydrate
is placed inside a conditional content binding (when(...) in fast-element, i.e. an
<if condition> block in fast-html) and the page is server-side rendered and then
hydrated in the browser, the nested component never hydrates. It retains both the
defer-hydration and needs-hydration attributes indefinitely, so all of its bindings,
its own child custom elements, and any further nested <if> blocks are inert.
The same component hydrates correctly when it is instead placed under a repeat binding
(repeat(...) / <for each>). Swapping the surrounding control-flow directive — with no
other change — is sufficient to make the component work or break.
This does not reproduce with client-side-only rendering, nor with test harnesses that
hydrate the component directly (e.g. @web/test-runner fixtures, or the fast-html
dev-server), because in those flows the component is not nested behind an SSR content
binding whose boundary markers must be adopted. It reproduces only in the
SSR → browser-hydration path.
Environment / conditions to reproduce
- Template compiled by
@microsoft/fast-html with a defer-and-hydrate component.
- Full server-side render of the outer component (produces the SSR DOM including the
<if>-nested child with defer-hydration set), followed by browser hydration.
- The nested component is the content of a
when() / <if condition> binding whose
condition is true at SSR time (so the SSR DOM actually contains the child).
Steps to reproduce (minimal pattern)
Outer component template (fast-html syntax):
<!-- BROKEN: nested-view never hydrates after SSR -->
<if condition="showChild">
<nested-view defer-and-hydrate :model="{{model}}"></nested-view>
</if>
vs. the working control (only the directive differs):
<!-- WORKS: nested-view hydrates normally after SSR -->
<for each="row in oneElementList">
<nested-view defer-and-hydrate :model="{{model}}"></nested-view>
</for>
nested-view is any component registered with fast-html defineAsync /
templateOptions: 'defer-and-hydrate'.
Expected
After hydration, <nested-view> has defer-hydration / needs-hydration removed, its
bindings are live, and its own child custom elements hydrate — identical to the <for>
case.
Actual
<nested-view> keeps defer-hydration and needs-hydration. It renders its static
SSR markup but is completely inert: :model and other bindings never update, child
mai-* / custom elements stay needs-hydration, and nested <if> blocks inside it never
evaluate.
Root-cause analysis
Two things combine.
1. Deferred children wait for every ancestor to clear defer-hydration
@microsoft/fast-html components/element.js:
// element.js:28 waitForAncestorHydration walks the COMPOSED parent chain and, for each
// ancestor still carrying `defer-hydration`, awaits its removal.
async function waitForAncestorHydration(element) {
let ancestor = composedParent(element);
while (ancestor) {
if (ancestor instanceof HTMLElement &&
ancestor.hasAttribute(deferHydrationAttribute)) {
await waitForAttributeRemoval(ancestor); // MutationObserver on the ancestor
}
ancestor = composedParent(ancestor);
}
}
// element.js:63-64 the deferred element only clears its own defer-hydration AFTER
// waitForAncestorHydration resolves:
// await waitForAncestorHydration(this);
// this.deferHydration = false;
So a deferred child hydrates only once every composed ancestor has removed its own
defer-hydration. If any ancestor in the chain never removes it, the child's
waitForAncestorHydration never resolves and it stays deferred forever.
2. repeat explicitly hydrates its child views; the content binding does so only conditionally
@microsoft/fast-element templating/repeat.js unconditionally drives hydration of each
item view during the hydration stage:
// repeat.js:76-78
if (isHydratable(controller) &&
controller.hydrationStage !== HydrationStage.hydrated) {
this.hydrateViews(this.template);
}
// repeat.js:286 hydrateViews(...) -> repeat.js:331
const view = template.hydrate(start, end); // adopts SSR DOM, drives child hydration
The conditional content binding (when -> html-binding-directive.js updateContent)
hydrates its view only when a stricter set of conditions holds, and otherwise falls
back to a fresh create():
// html-binding-directive.js:32-42
if (view === void 0) {
if (isHydratable(controller) &&
isHydratable(value) &&
controller.bindingViewBoundaries[this.targetNodeId] !== undefined &&
controller.hydrationStage !== HydrationStage.hydrated) {
const viewNodes = controller.bindingViewBoundaries[this.targetNodeId];
view = value.hydrate(viewNodes.first, viewNodes.last);
} else {
view = value.create(); // fresh render — SSR deferred child not adopted/driven
}
}
The net effect: for a when/<if> content view, the outer element's hydration does not
reliably propagate hydration into the SSR-rendered deferred child the way repeat's
hydrateViews does. The child's ancestor-chain defer-hydration removal never reaches
it, so waitForAncestorHydration never resolves and the child is stranded with
defer-hydration / needs-hydration.
when itself (templating/when.js) is a thin directive that just returns the template;
all of the hydration behavior lives in the content-binding directive above.
Evidence
- In Microsoft Edge's production Build-Time-Rendering (SSR) build of
edge://extensions:
a route view component placed directly under <if> was observed via CDP to retain
both defer-hydration and needs-hydration; its child mai-dropdown was stuck at
needs-hydration (rendered collapsed, options detached). An extension-card component
rendered under <for each> on the same page hydrated correctly.
- The only change required to fix each stranded view was to move it out from under
<if> and render it via a one-element <for each> "shim" (e.g. an array of length 0/1
toggled in place of the boolean condition). This isolates the defect to the control-flow
directive's hydration handling, not the component or the page.
Impact
Any fast-html app that (a) uses SSR + hydration and (b) puts a defer-and-hydrate
component (or a component containing deferred children) under <if> / when ships an
inert subtree. It is easy to hit and hard to diagnose because CSR and unit/dev-server
hydration both mask it.
Suggested direction
Have the content-binding directive drive hydration of an SSR-rendered deferred child view
equivalently to repeat.hydrateViews — i.e. ensure that when the SSR DOM for a when
content view exists, its view is hydrate()-d (adopting the existing nodes and
propagating ancestor defer-hydration removal to nested deferred elements) rather than
falling through to create(). If the gap is that bindingViewBoundaries[targetNodeId]
is not populated for conditional content, the SSR emitter should emit those boundary
markers for when/<if> content the same way it does for the paths that hydrate.
Workaround (for consumers)
Do not place a defer-and-hydrate component directly under <if> / when. Render it via
a one-element <for each> (repeat) whose list is empty/one-element instead of a boolean
condition; repeat's hydrateViews hydrates the child correctly.
@microsoft/fast-element2.10.4@microsoft/fast-html1.0.0-alpha.54Summary
When a component authored with
@microsoft/fast-htmltemplateOptionsdefer-and-hydrateis placed inside a conditional content binding (
when(...)in fast-element, i.e. an<if condition>block in fast-html) and the page is server-side rendered and thenhydrated in the browser, the nested component never hydrates. It retains both the
defer-hydrationandneeds-hydrationattributes indefinitely, so all of its bindings,its own child custom elements, and any further nested
<if>blocks are inert.The same component hydrates correctly when it is instead placed under a repeat binding
(
repeat(...)/<for each>). Swapping the surrounding control-flow directive — with noother change — is sufficient to make the component work or break.
This does not reproduce with client-side-only rendering, nor with test harnesses that
hydrate the component directly (e.g.
@web/test-runnerfixtures, or the fast-htmldev-server), because in those flows the component is not nested behind an SSR content
binding whose boundary markers must be adopted. It reproduces only in the
SSR → browser-hydration path.
Environment / conditions to reproduce
@microsoft/fast-htmlwith adefer-and-hydratecomponent.<if>-nested child withdefer-hydrationset), followed by browser hydration.when()/<if condition>binding whosecondition is true at SSR time (so the SSR DOM actually contains the child).
Steps to reproduce (minimal pattern)
Outer component template (fast-html syntax):
vs. the working control (only the directive differs):
nested-viewis any component registered with fast-htmldefineAsync/templateOptions: 'defer-and-hydrate'.Expected
After hydration,
<nested-view>hasdefer-hydration/needs-hydrationremoved, itsbindings are live, and its own child custom elements hydrate — identical to the
<for>case.
Actual
<nested-view>keepsdefer-hydrationandneeds-hydration. It renders its staticSSR markup but is completely inert:
:modeland other bindings never update, childmai-*/ custom elements stayneeds-hydration, and nested<if>blocks inside it neverevaluate.
Root-cause analysis
Two things combine.
1. Deferred children wait for every ancestor to clear
defer-hydration@microsoft/fast-htmlcomponents/element.js:So a deferred child hydrates only once every composed ancestor has removed its own
defer-hydration. If any ancestor in the chain never removes it, the child'swaitForAncestorHydrationnever resolves and it stays deferred forever.2.
repeatexplicitly hydrates its child views; the content binding does so only conditionally@microsoft/fast-elementtemplating/repeat.jsunconditionally drives hydration of eachitem view during the hydration stage:
The conditional content binding (
when->html-binding-directive.jsupdateContent)hydrates its view only when a stricter set of conditions holds, and otherwise falls
back to a fresh
create():The net effect: for a
when/<if>content view, the outer element's hydration does notreliably propagate hydration into the SSR-rendered deferred child the way
repeat'shydrateViewsdoes. The child's ancestor-chaindefer-hydrationremoval never reachesit, so
waitForAncestorHydrationnever resolves and the child is stranded withdefer-hydration/needs-hydration.whenitself (templating/when.js) is a thin directive that just returns the template;all of the hydration behavior lives in the content-binding directive above.
Evidence
edge://extensions:a route view component placed directly under
<if>was observed via CDP to retainboth
defer-hydrationandneeds-hydration; its childmai-dropdownwas stuck atneeds-hydration(rendered collapsed, options detached). Anextension-cardcomponentrendered under
<for each>on the same page hydrated correctly.<if>and render it via a one-element<for each>"shim" (e.g. an array of length 0/1toggled in place of the boolean condition). This isolates the defect to the control-flow
directive's hydration handling, not the component or the page.
Impact
Any fast-html app that (a) uses SSR + hydration and (b) puts a
defer-and-hydratecomponent (or a component containing deferred children) under
<if>/whenships aninert subtree. It is easy to hit and hard to diagnose because CSR and unit/dev-server
hydration both mask it.
Suggested direction
Have the content-binding directive drive hydration of an SSR-rendered deferred child view
equivalently to
repeat.hydrateViews— i.e. ensure that when the SSR DOM for awhencontent view exists, its view is
hydrate()-d (adopting the existing nodes andpropagating ancestor
defer-hydrationremoval to nested deferred elements) rather thanfalling through to
create(). If the gap is thatbindingViewBoundaries[targetNodeId]is not populated for conditional content, the SSR emitter should emit those boundary
markers for
when/<if>content the same way it does for the paths that hydrate.Workaround (for consumers)
Do not place a
defer-and-hydratecomponent directly under<if>/when. Render it viaa one-element
<for each>(repeat) whose list is empty/one-element instead of a booleancondition; repeat's
hydrateViewshydrates the child correctly.