Skip to content

Commit

Permalink
Adds support for FASTElement hydration (#6977)
Browse files Browse the repository at this point in the history
Adds support for FASTElement hydration

# Pull Request

## 📖 Description

This PR consolidates Server-Side Rendering (SSR) related changes made by @nicholasrice from a Microsoft internal fork. Below is the commit description from the original PR.

---

This PR enables hydration of FAST custom elements from HTML emitted by SSR processes.

**This PR has three core areas:**

1. I've added a SSR example application to `/examples` to aid in debugging. This code was ported from external FAST.
2. I've updated `fast-ssr` to accept a `emitHydratableMarkup` attribute. Configuring this option causes markers to be emitted during template rendering. These markers manifest either as element attributes (for attribute bindings / directives) and HTML comments (for content bindings / directives). These markers are used by `fast-element` to identify which elements belong to which views and bindings.
3. `fast-element` has been updated to support hydrating views, details below.

**fast-element changes:**

1. Updated `HydratableElementController` to invoke `hydrate` on it's template during connection, also refactored `ElementController` base class to allow better direct extension by making `private` properties `protected`.
2. Added a `HydrationView` class. An instance of this class represents a region of DOM created by a template during the SSR process. When this class binds for the first time, it walks the DOM tree between it's first and last nodes looking for binding markers emitted during `fast-ssr` rendering. When it finds binding markers, it associates the template binding to that DOM location. After all the nodes have been walked and the bindings targeted, behaviors for those bindings are created and bound, hooking up observable behavior.
3. Updated bindings and directives to handle hydration scenarios. Unfortunately, I was not able to identify a good way to separate the hydration behavior from the CSR binding behavior without implementing a large amount of code duplication. Instead, I added a lightweight `isHydratable()` test to determine whether bindings should go through hydration flows. Bindings and directives were then updated to not affect DOM during hydration flows.


### 🎫 Issues

* #5182
* #5577

## 👩‍💻 Reviewer Notes

<!---
Provide some notes for reviewers to help them provide targeted feedback and testing.

Do you recommend a smoke test for this PR? What steps should be followed?
Are there particular areas of the code the reviewer should focus on?
-->

## 📑 Test Plan

<!---
Please provide a summary of the tests affected by this work and any unique strategies employed in testing the features/fixes.
-->

## ✅ Checklist

### General

<!--- Review the list and put an x in the boxes that apply. -->

- [x] I have included a change request file using `$ yarn change`
- [x] I have added tests for my changes.
- [x] I have tested my changes.
- [ ] I have updated the project documentation to reflect my changes.
- [ ] I have read the [CONTRIBUTING](https://github.com/microsoft/fast/blob/master/CONTRIBUTING.md) documentation and followed the [standards](https://github.com/microsoft/fast/blob/master/CODE_OF_CONDUCT.md#our-standards) for this project.
  • Loading branch information
prabhujayapal authored Jul 5, 2024
1 parent 65affee commit 882eded
Show file tree
Hide file tree
Showing 59 changed files with 3,369 additions and 783 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "Adds support for FASTElement hydration",
"packageName": "@microsoft/fast-element",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "update FAST DOM shim for Playwright tests",
"packageName": "@microsoft/fast-foundation",
"email": "[email protected]",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "Adds support for FASTElement hydration",
"packageName": "@microsoft/fast-ssr",
"email": "[email protected]",
"dependentChangeType": "patch"
}
101 changes: 87 additions & 14 deletions packages/web-components/fast-element/docs/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,25 +279,41 @@ export class ElementController<TElement extends HTMLElement = HTMLElement> exten
constructor(element: TElement, definition: FASTElementDefinition);
addBehavior(behavior: HostBehavior<TElement>): void;
addStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void;
// (undocumented)
protected behaviors: Map<HostBehavior<TElement>, number> | null;
// (undocumented)
protected bindObservables(): void;
connect(): void;
// (undocumented)
protected connectBehaviors(): void;
get context(): ExecutionContext;
readonly definition: FASTElementDefinition;
disconnect(): void;
// (undocumented)
protected disconnectBehaviors(): void;
emit(type: string, detail?: any, options?: Omit<CustomEventInit, "detail">): void | boolean;
static forCustomElement(element: HTMLElement): ElementController;
get isBound(): boolean;
get isConnected(): boolean;
get mainStyles(): ElementStyles | null;
set mainStyles(value: ElementStyles | null);
// (undocumented)
protected needsInitialization: boolean;
onAttributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
onUnbind(behavior: {
unbind(controller: ExpressionController<TElement>): any;
}): void;
removeBehavior(behavior: HostBehavior<TElement>, force?: boolean): void;
removeStyles(styles: ElementStyles | HTMLStyleElement | null | undefined): void;
// (undocumented)
protected renderTemplate(template: ElementViewTemplate | null | undefined): void;
static setStrategy(strategy: ElementControllerStrategy): void;
readonly source: TElement;
get sourceLifetime(): SourceLifetime | undefined;
// Warning: (ae-forgotten-export) The symbol "Stages" needs to be exported by the entry point index.d.ts
//
// (undocumented)
protected stage: Stages;
get template(): ElementViewTemplate<TElement> | null;
set template(value: ElementViewTemplate<TElement> | null);
readonly view: ElementView<TElement> | null;
Expand Down Expand Up @@ -523,41 +539,33 @@ export interface HTMLDirectiveDefinition<TType extends Constructable<HTMLDirecti
// @public
export interface HTMLTemplateCompilationResult<TSource = any, TParent = any> {
createView(hostBindingTarget?: Element): HTMLView<TSource, TParent>;
// (undocumented)
readonly factories: CompiledViewBehaviorFactory[];
}

// @public
export type HTMLTemplateTag = (<TSource = any, TParent = any>(strings: TemplateStringsArray, ...values: TemplateValue<TSource, TParent>[]) => ViewTemplate<TSource, TParent>) & {
partial(html: string): InlineTemplateDirective;
};

// Warning: (ae-forgotten-export) The symbol "DefaultExecutionContext" needs to be exported by the entry point index.d.ts
//
// @public
export class HTMLView<TSource = any, TParent = any> implements ElementView<TSource, TParent>, SyntheticView<TSource, TParent>, ExecutionContext<TParent> {
export class HTMLView<TSource = any, TParent = any> extends DefaultExecutionContext<TParent> implements ElementView<TSource, TParent>, SyntheticView<TSource, TParent>, ExecutionContext<TParent> {
constructor(fragment: DocumentFragment, factories: ReadonlyArray<CompiledViewBehaviorFactory>, targets: ViewBehaviorTargets);
appendTo(node: Node): void;
bind(source: TSource, context?: ExecutionContext<TParent>): void;
context: ExecutionContext<TParent>;
dispose(): void;
static disposeContiguousBatch(views: SyntheticView[]): void;
get event(): Event;
eventDetail<TDetail>(): TDetail;
eventTarget<TTarget extends EventTarget>(): TTarget;
firstChild: Node;
index: number;
insertBefore(node: Node): void;
isBound: boolean;
get isEven(): boolean;
get isFirst(): boolean;
get isInMiddle(): boolean;
get isLast(): boolean;
get isOdd(): boolean;
lastChild: Node;
length: number;
// (undocumented)
onUnbind(behavior: {
unbind(controller: ViewController<TSource, TParent>): any;
unbind(controller: ViewController<TSource, TParent>): void;
}): void;
readonly parent: TParent;
readonly parentContext: ExecutionContext<TParent>;
remove(): void;
source: TSource | null;
readonly sourceLifetime: SourceLifetime;
Expand All @@ -566,6 +574,43 @@ export class HTMLView<TSource = any, TParent = any> implements ElementView<TSour
unbind(): void;
}

// @beta
export class HydratableElementController<TElement extends HTMLElement = HTMLElement> extends ElementController<TElement> {
// (undocumented)
connect(): void;
// (undocumented)
disconnect(): void;
// (undocumented)
static install(): void;
protected needsHydration?: boolean;
}

// @public (undocumented)
export interface HydratableView<TSource = any, TParent = any> extends ElementView, SyntheticView, DefaultExecutionContext<TParent> {
// (undocumented)
[Hydratable]: symbol;
// Warning: (ae-forgotten-export) The symbol "ViewNodes" needs to be exported by the entry point index.d.ts
//
// (undocumented)
readonly bindingViewBoundaries: Record<string, ViewNodes>;
// Warning: (ae-forgotten-export) The symbol "HydrationStage" needs to be exported by the entry point index.d.ts
//
// (undocumented)
readonly hydrationStage: keyof typeof HydrationStage;
}

// @public (undocumented)
export class HydrationBindingError extends Error {
constructor(
message: string | undefined,
factory: ViewBehaviorFactory,
fragment: DocumentFragment,
templateString: string);
readonly factory: ViewBehaviorFactory;
readonly fragment: DocumentFragment;
readonly templateString: string;
}

// @public
export class InlineTemplateDirective implements HTMLDirective {
constructor(html: string, factories?: Record<string, ViewBehaviorFactory>);
Expand Down Expand Up @@ -696,6 +741,32 @@ export class RefDirective extends StatelessAttachedAttributeDirective<string> {
targetNodeId: string;
}

// @public
export function render<TSource = any, TItem = any, TParent = any>(value?: Expression<TSource, TItem> | Binding<TSource, TItem> | {}, template?: ContentTemplate | string | Expression<TSource, ContentTemplate | string | Node, TParent> | Binding<TSource, ContentTemplate | string | Node, TParent>): CaptureType<TSource, TParent>;

// @public
export class RenderBehavior<TSource = any> implements ViewBehavior, Subscriber {
constructor(directive: RenderDirective);
bind(controller: ViewController): void;
// @internal (undocumented)
handleChange(source: any, observer: ExpressionObserver): void;
unbind(controller: ViewController): void;
}

// @public
export class RenderDirective<TSource = any> implements HTMLDirective, ViewBehaviorFactory, BindingDirective {
constructor(dataBinding: Binding<TSource>, templateBinding: Binding<TSource, ContentTemplate>, templateBindingDependsOnData: boolean);
createBehavior(): RenderBehavior<TSource>;
createHTML(add: AddViewBehaviorFactory): string;
// (undocumented)
readonly dataBinding: Binding<TSource>;
targetNodeId: string;
// (undocumented)
readonly templateBinding: Binding<TSource, ContentTemplate>;
// (undocumented)
readonly templateBindingDependsOnData: boolean;
}

// @public
export function repeat<TSource = any, TArray extends ReadonlyArray<any> = ReadonlyArray<any>, TParent = any>(items: Expression<TSource, TArray, TParent> | Binding<TSource, TArray, TParent> | ReadonlyArray<any>, template: Expression<TSource, ViewTemplate<any, TSource>> | Binding<TSource, ViewTemplate<any, TSource>> | ViewTemplate<any, TSource>, options?: RepeatOptions): CaptureType<TSource, TParent>;

Expand Down Expand Up @@ -922,6 +993,8 @@ export interface ViewController<TSource = any, TParent = any> extends Expression
// @public
export class ViewTemplate<TSource = any, TParent = any> implements ElementViewTemplate<TSource, TParent>, SyntheticViewTemplate<TSource, TParent> {
constructor(html: string | HTMLTemplateElement, factories?: Record<string, ViewBehaviorFactory>, policy?: DOMPolicy | undefined);
// @internal (undocumented)
compile(): HTMLTemplateCompilationResult<TSource, TParent>;
create(hostBindingTarget?: Element): HTMLView<TSource, TParent>;
static create<TSource = any, TParent = any>(strings: string[], values: TemplateValue<TSource, TParent>[], policy?: DOMPolicy): ViewTemplate<TSource, TParent>;
readonly factories: Record<string, ViewBehaviorFactory>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("The ElementController", () => {
const cssB = "class-b { color: blue; }";
const stylesB = css`${cssB}`;

function createController(
function createController<T extends ElementController = ElementController>(
config: Omit<PartialFASTElementDefinition, "name"> = {},
BaseClass = FASTElement
) {
Expand All @@ -33,7 +33,7 @@ describe("The ElementController", () => {
).define();

const element = document.createElement(name);
const controller = ElementController.forCustomElement(element);
const controller = ElementController.forCustomElement(element) as T;

return {
name,
Expand Down Expand Up @@ -548,6 +548,41 @@ describe("The ElementController", () => {
controller.disconnect();
expect(behavior.disconnectedCallback).to.have.been.called();
});

it("should not connect behaviors more than once without first disconnecting the behavior", () => {
class TestController extends ElementController {
public connectBehaviors() {
super.connectBehaviors();
}

public disconnectBehaviors() {
super.disconnectBehaviors();
}
}

ElementController.setStrategy(TestController);
const behavior: HostBehavior = {
connectedCallback: chai.spy(),
disconnectedCallback: chai.spy()
};
const { controller } = createController<TestController>({styles: css``.withBehaviors(behavior)});
controller.connect();
controller.connectBehaviors();

expect(behavior.connectedCallback).to.have.been.called.once;

controller.disconnect();
controller.disconnectBehaviors();
expect(behavior.disconnectedCallback).to.have.been.called.once;

controller.connect();
controller.connectBehaviors();

expect(behavior.connectedCallback).to.have.been.called.twice;

ElementController.setStrategy(ElementController);
});

it("should add behaviors added by a stylesheet when added and remove them the stylesheet is removed", () => {
const behavior: HostBehavior = {
addedCallback: chai.spy(),
Expand Down
Loading

0 comments on commit 882eded

Please sign in to comment.