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": "patch",
"comment": "fix: reconcile prerendered content that a content binding does not adopt while hydrating, so content inside a when() whose condition has not resolved is no longer left in the DOM unowned and then duplicated once it does.",
"packageName": "@microsoft/fast-element",
"email": "144495202+AKnassa@users.noreply.github.com",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "test: add a hydration fixture covering a deferred child rendered inside a conditional content binding.",
"packageName": "@microsoft/fast-html",
"email": "144495202+AKnassa@users.noreply.github.com",
"dependentChangeType": "none"
}
81 changes: 73 additions & 8 deletions packages/fast-element/src/templating/html-binding-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,59 @@ function isContentTemplate(value: any): value is ContentTemplate {
return value.create !== undefined;
}

/**
* Discards the prerendered nodes of a content binding that the binding did not adopt.
*
* A content binding hydrates by adopting the nodes the server rendered between its
* boundary markers. When it cannot adopt them - because the value is no longer a
* template, or because the view has already hydrated - those nodes must be reconciled
* away, just as `repeat` discards the prerendered item views it does not adopt.
*
* Left in place they are owned by no view, so nothing ever binds them: their bindings
* are never wired to the source, and once the value does resolve, a second,
* client-rendered copy is composed alongside them.
*
* The boundary is consumed, so a range is discarded at most once.
*/
function discardUnadoptedContent(
directive: HTMLBindingDirective,
controller: ViewController,
): void {
if (!isHydratable(controller)) {
return;
}

const viewNodes = controller.bindingViewBoundaries[directive.targetNodeId];

if (viewNodes === undefined) {
return;
}

delete controller.bindingViewBoundaries[directive.targetNodeId];

const { first, last } = viewNodes;
const parent = first.parentNode;

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

let current: Node | null = first;

while (current !== null) {
const next: Node | null = current.nextSibling;
const isLast = current === last;

parent.removeChild(current);

if (isLast) {
return;
}

current = next;
}
}

/**
* Sink function for DOMAspect.content bindings (text content interpolation).
* Handles two cases:
Expand Down Expand Up @@ -127,8 +180,12 @@ function updateContent(
controller.hydrationStage !== HydrationStage.hydrated
) {
const viewNodes = controller.bindingViewBoundaries[this.targetNodeId];
delete controller.bindingViewBoundaries[this.targetNodeId];
view = value.hydrate(viewNodes.first, viewNodes.last);
} else {
// The prerendered nodes cannot be adopted, so reconcile them away
// before rendering the value on the client.
discardUnadoptedContent(this, controller);
view = value.create();
}
} else {
Expand Down Expand Up @@ -163,15 +220,23 @@ function updateContent(

// If there is a view and it's currently composed into
// the DOM, then we need to remove it.
if (view !== void 0 && view.isComposed) {
view.isComposed = false;
view.remove();

if (view.needsBindOnly) {
view.needsBindOnly = false;
} else {
view.unbind();
if (view !== void 0) {
if (view.isComposed) {
view.isComposed = false;
view.remove();

if (view.needsBindOnly) {
view.needsBindOnly = false;
} else {
view.unbind();
}
}
} else {
// No view ever composed this target, so any prerendered nodes between the
// binding's boundary markers are owned by nothing. This happens when the
// value is falsy on hydration but the server rendered content for it -
// a `when` whose condition has not resolved yet, for instance.
discardUnadoptedContent(this, controller);
}

target.textContent = value;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>When Nested Elements Hydration Tests</title>
</head>
<body>
<when-parent></when-parent>
<repeat-parent></repeat-parent>
<when-stale-parent></when-stale-parent>
<repeat-stale-parent></repeat-stale-parent>
<script type="module" src="./main.ts"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"entry": "entry.html",
"state": "state.json",
"output": "index.html",
"templates": "templates.html"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>When Nested Elements Hydration Tests</title>
</head>
<body>
<when-parent><template shadowrootmode="open" shadowroot="open"><div class="container">
<!--fe-b$$start$$0$$when-0$$fe-b-->
<deferred-child text="Hello" data-fe-c-0-1><template shadowrootmode="open" shadowroot="open"><span class="text"><!--fe-b$$start$$0$$text-0$$fe-b-->Hello<!--fe-b$$end$$0$$text-0$$fe-b--></span></template></deferred-child>
<!--fe-b$$end$$0$$when-0$$fe-b-->
</div></template></when-parent>
<repeat-parent><template shadowrootmode="open" shadowroot="open"><div class="container">
<!--fe-b$$start$$0$$repeat-0$$fe-b--><!--fe-repeat$$start$$0$$fe-repeat-->
<deferred-child text="Hello" data-fe-c-0-1><template shadowrootmode="open" shadowroot="open"><span class="text"><!--fe-b$$start$$0$$text-0$$fe-b-->Hello<!--fe-b$$end$$0$$text-0$$fe-b--></span></template></deferred-child>
<!--fe-repeat$$end$$0$$fe-repeat--><!--fe-b$$end$$0$$repeat-0$$fe-b-->
</div></template></repeat-parent>
<when-stale-parent><template shadowrootmode="open" shadowroot="open"><div class="container">
<!--fe-b$$start$$0$$when-0$$fe-b-->
<deferred-child text="Hello" data-fe-c-0-1><template shadowrootmode="open" shadowroot="open"><span class="text"><!--fe-b$$start$$0$$text-0$$fe-b-->Hello<!--fe-b$$end$$0$$text-0$$fe-b--></span></template></deferred-child>
<!--fe-b$$end$$0$$when-0$$fe-b-->
</div></template></when-stale-parent>
<repeat-stale-parent><template shadowrootmode="open" shadowroot="open"><div class="container">
<!--fe-b$$start$$0$$repeat-0$$fe-b--><!--fe-repeat$$start$$0$$fe-repeat-->
<deferred-child text="Hello" data-fe-c-0-1><template shadowrootmode="open" shadowroot="open"><span class="text"><!--fe-b$$start$$0$$text-0$$fe-b-->Hello<!--fe-b$$end$$0$$text-0$$fe-b--></span></template></deferred-child>
<!--fe-repeat$$end$$0$$fe-repeat--><!--fe-b$$end$$0$$repeat-0$$fe-b-->
</div></template></repeat-stale-parent>
<f-template name="deferred-child">
<template>
<span class="text">{{text}}</span>
</template>
</f-template>
<f-template name="when-parent">
<template>
<div class="container">
<f-when value="{{show}}">
<deferred-child text="{{text}}"></deferred-child>
</f-when>
</div>
</template>
</f-template>
<f-template name="repeat-parent">
<template>
<div class="container">
<f-repeat value="{{item in items}}">
<deferred-child text="{{item.text}}"></deferred-child>
</f-repeat>
</div>
</template>
</f-template>
<f-template name="when-stale-parent">
<template>
<div class="container">
<f-when value="{{show}}">
<deferred-child text="{{text}}"></deferred-child>
</f-when>
</div>
</template>
</f-template>
<f-template name="repeat-stale-parent">
<template>
<div class="container">
<f-repeat value="{{item in items}}">
<deferred-child text="{{item.text}}"></deferred-child>
</f-repeat>
</div>
</template>
</f-template>

<script type="module" src="./main.ts"></script>
</body>
</html>
123 changes: 123 additions & 0 deletions packages/fast-html/test/fixtures/when-nested-elements/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { attr, FASTElement, observable } from "@microsoft/fast-element";
import { RenderableFASTElement, TemplateElement } from "@microsoft/fast-html";

const RenderableElement = RenderableFASTElement(FASTElement);

// Mock data source - simulating fetched data, matching the pre-rendered state.
const mockDataSource = {
getData() {
return { show: true, text: "Hello", items: [{ text: "Hello" }] };
},
};

/**
* Renders a `defer-and-hydrate` child inside a conditional content binding. The
* condition is true at render time, so the pre-rendered DOM contains the child.
*/
export class WhenParent extends RenderableElement {
@observable
public show: boolean = false;

@observable
public text: string = "";

async prepare() {
const data = mockDataSource.getData();

this.show = data.show;
this.text = data.text;
}
}

/**
* The control: the same child rendered through a repeat binding instead. Swapping
* the surrounding directive is the only difference between this and WhenParent.
*/
export class RepeatParent extends RenderableElement {
@observable
public items: Array<{ text: string }> = [];

async prepare() {
this.items = mockDataSource.getData().items;
}
}

/**
* The condition was true when the page was rendered, so the pre-rendered DOM
* contains the child, but the value backing the condition has not resolved by the
* time the element hydrates - as happens when the condition depends on data that
* arrives after hydration. The pre-rendered content must be reconciled away rather
* than left behind, and must not be duplicated once the value does resolve.
*/
export class WhenStaleParent extends RenderableElement {
@observable
public show: boolean = false;

@observable
public text: string = "Hello";
}

/**
* The control for {@link WhenStaleParent}: a repeat binding reconciles the same
* pre-rendered/hydration mismatch correctly.
*/
export class RepeatStaleParent extends RenderableElement {
@observable
public items: Array<{ text: string }> = [];
}

export class DeferredChild extends RenderableElement {
@attr
public text: string = "";
}

RenderableFASTElement(WhenParent).defineAsync({
name: "when-parent",
templateOptions: "defer-and-hydrate",
});

RenderableFASTElement(RepeatParent).defineAsync({
name: "repeat-parent",
templateOptions: "defer-and-hydrate",
});

RenderableFASTElement(WhenStaleParent).defineAsync({
name: "when-stale-parent",
templateOptions: "defer-and-hydrate",
});

RenderableFASTElement(RepeatStaleParent).defineAsync({
name: "repeat-stale-parent",
templateOptions: "defer-and-hydrate",
});

RenderableFASTElement(DeferredChild).defineAsync({
name: "deferred-child",
templateOptions: "defer-and-hydrate",
});

TemplateElement.options({
"when-parent": {
observerMap: "all",
},
"repeat-parent": {
observerMap: "all",
},
"when-stale-parent": {
observerMap: "all",
},
"repeat-stale-parent": {
observerMap: "all",
},
"deferred-child": {
observerMap: "all",
},
})
.config({
hydrationComplete() {
(window as any).hydrationCompleted = true;
},
})
.define({
name: "f-template",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"show": true,
"text": "Hello",
"items": [{ "text": "Hello" }]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<f-template name="deferred-child">
<template>
<span class="text">{{text}}</span>
</template>
</f-template>
<f-template name="when-parent">
<template>
<div class="container">
<f-when value="{{show}}">
<deferred-child text="{{text}}"></deferred-child>
</f-when>
</div>
</template>
</f-template>
<f-template name="repeat-parent">
<template>
<div class="container">
<f-repeat value="{{item in items}}">
<deferred-child text="{{item.text}}"></deferred-child>
</f-repeat>
</div>
</template>
</f-template>
<f-template name="when-stale-parent">
<template>
<div class="container">
<f-when value="{{show}}">
<deferred-child text="{{text}}"></deferred-child>
</f-when>
</div>
</template>
</f-template>
<f-template name="repeat-stale-parent">
<template>
<div class="container">
<f-repeat value="{{item in items}}">
<deferred-child text="{{item.text}}"></deferred-child>
</f-repeat>
</div>
</template>
</f-template>
Loading