Skip to content

defer-and-hydrate child under when/<if> never hydrates during SSR hydration (fast-element 2.10.4 / fast-html 1.0.0-alpha.54) #7634

Description

@mcritzjam
  • @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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions