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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions