Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add defer-hydration to ssr renderer #6492

Merged
merged 12 commits into from
Nov 1, 2022
28 changes: 28 additions & 0 deletions packages/web-components/fast-ssr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,34 @@ ElementRenderer.disable("my-element");
templateRenderer.render(html`<my-element></my-element>`);
```

### Hydration
#### `defer-hydration` Attribute
The `defer-hydration` attribute is an attribute added to each custom-element rendered by the `ElementRenderer`. The presence of the attribute indicates to client-side code that the element should not hydrate it's view. When the attribute is removed, the element is free to hydrate itself.

This attribute is added automatically during custom element rendering to all FAST custom elements. If your app does not require orchestrating element hydration, emission of this attribute can be prevented by configuring the renderer:
```ts
const { templateRenderer } = fastSSR({deferHydration: false});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should flip the default on this. Seems like most folks working with the library will want to hydrate automatically, yes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went back and forth on it. I think most applications of a reasonable size will want to defer hydration because they'll have services and contexts that will need to get set up, so hydration order will matter. We can still make it opt-in though, if that is a more predictable interface

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think switching the default to be false is probably more what's expected, especially if someone is getting started.

```

> For more information on this community-protocol, see https://github.com/webcomponents-cg/community-protocols/pull/15
#### Configuring FAST-Element
`@microsoft/fast-element` must be configured to respect the `defer-hydration` attribute. To do this, simply import the install code into the client-side application before defining the custom elements:
```ts
import "@microsoft/fast-element-hydration";
nicholasrice marked this conversation as resolved.
Show resolved Hide resolved

// Define custom elements
```

Alternatively, the `HydratableElementController` can be imported and installed manually:

```ts
import { HydratableElementController } from "@microsoft/fast-element/element-hydration";

HydratableElementController.install();
```

After you do this, `@microsoft/fast-element` will wait until the `defer-hydration` attribute is removed (if present during connection) before doing connection work like rendering templates, applying stylesheets, and binding behaviors.

### Configuring the RenderInfo Object
`TemplateRenderer.render()` must be invoked with a `RenderInfo` object. Its purpose is to provide different element renderers to the process, as well as metadata about the rendering process. It can be used to render custom elements from different templating libraries in the same process. By default, `TemplateRenderer.render()` will create a `RenderInfo` object for you, but you can also easily construct your own using `TemplateRenderer.createRenderInfo()`:

Expand Down
18 changes: 14 additions & 4 deletions packages/web-components/fast-ssr/docs/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ function fastSSR(): {
ElementRenderer: ConstructableFASTElementRenderer<SyncFASTElementRenderer>;
};

// Warning: (ae-incompatible-release-tags) The symbol "fastSSR" is marked as @public, but its signature references "SSRConfiguration" which is marked as @beta
//
// @public (undocumented)
function fastSSR(config: Omit<SSRConfiguration, "renderMode">): {
templateRenderer: TemplateRenderer;
ElementRenderer: ConstructableFASTElementRenderer<SyncFASTElementRenderer>;
};

// @beta (undocumented)
function fastSSR(config: SSRConfiguration & Record<"renderMode", "sync">): {
templateRenderer: TemplateRenderer;
Expand Down Expand Up @@ -124,8 +132,8 @@ export const RequestStorageManager: Readonly<{

// @beta
export interface SSRConfiguration {
// (undocumented)
renderMode: "sync" | "async";
deferHydration?: boolean;
renderMode?: "sync" | "async";
}

// @beta
Expand Down Expand Up @@ -160,8 +168,10 @@ export interface ViewBehaviorFactoryRenderer<T extends ViewBehaviorFactory> {

// Warnings were encountered during analysis:
//
// dist/dts/exports.d.ts:18:5 - (ae-forgotten-export) The symbol "SyncFASTElementRenderer" needs to be exported by the entry point exports.d.ts
// dist/dts/exports.d.ts:28:5 - (ae-forgotten-export) The symbol "AsyncFASTElementRenderer" needs to be exported by the entry point exports.d.ts
// dist/dts/exports.d.ts:33:5 - (ae-forgotten-export) The symbol "SyncFASTElementRenderer" needs to be exported by the entry point exports.d.ts
// dist/dts/exports.d.ts:36:5 - (ae-incompatible-release-tags) The symbol "templateRenderer" is marked as @public, but its signature references "TemplateRenderer" which is marked as @beta
// dist/dts/exports.d.ts:37:5 - (ae-incompatible-release-tags) The symbol "ElementRenderer" is marked as @public, but its signature references "ConstructableFASTElementRenderer" which is marked as @beta
// dist/dts/exports.d.ts:47:5 - (ae-forgotten-export) The symbol "AsyncFASTElementRenderer" needs to be exported by the entry point exports.d.ts
// dist/dts/request-storage.d.ts:32:5 - (ae-forgotten-export) The symbol "getItem" needs to be exported by the entry point exports.d.ts

// (No @packageDocumentation comment for this package)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
Compiler,
ElementController,
ElementStyles,
Updates,
ViewBehaviorFactory,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ test.describe("FASTElementRenderer", () => {
test(`should render stylesheets as 'style' elements by default`, () => {
const { templateRenderer } = fastSSR();
const result = consolidate(templateRenderer.render(html`<styled-element></styled-element>`));
expect(result).toBe("<styled-element><template shadowroot=\"open\"><style>:host { display: block; }</style><style>:host { color: red; }</style></template></styled-element>");
expect(result).toBe("<styled-element defer-hydration><template shadowroot=\"open\"><style>:host { display: block; }</style><style>:host { color: red; }</style></template></styled-element>");
});
test.skip(`should render stylesheets as 'fast-style' elements when configured`, () => {
const { templateRenderer } = fastSSR(/* Replace w/ configuration when fast-style work is complete{useFASTStyle: true}*/);
Expand All @@ -85,7 +85,7 @@ test.describe("FASTElementRenderer", () => {
<host-binding-element></host-binding-element>
`));
expect(result).toBe(`
<host-binding-element attr="attr" bool-attr><template shadowroot=\"open\"></template></host-binding-element>
<host-binding-element attr="attr" bool-attr defer-hydration><template shadowroot=\"open\"></template></host-binding-element>
`);
});

Expand All @@ -96,7 +96,7 @@ test.describe("FASTElementRenderer", () => {
<bare-element attr="${x => null}"></bare-element>
`));
expect(result).toBe(`
<bare-element ><template shadowroot=\"open\"></template></bare-element>
<bare-element defer-hydration><template shadowroot=\"open\"></template></bare-element>
`);
});
test("should not render the attribute when the binding evaluates undefined", () => {
Expand All @@ -105,7 +105,7 @@ test.describe("FASTElementRenderer", () => {
<bare-element attr="${x => undefined}"></bare-element>
`));
expect(result).toBe(`
<bare-element ><template shadowroot=\"open\"></template></bare-element>
<bare-element defer-hydration><template shadowroot=\"open\"></template></bare-element>
`);
});

Expand All @@ -115,7 +115,7 @@ test.describe("FASTElementRenderer", () => {
<bare-element ?attr="${x => true}"></bare-element>
`));
expect(result).toBe(`
<bare-element attr><template shadowroot=\"open\"></template></bare-element>
<bare-element attr defer-hydration><template shadowroot=\"open\"></template></bare-element>
`);
});
});
Expand Down Expand Up @@ -184,13 +184,13 @@ test.describe("FASTElementRenderer", () => {
test("An element dispatching an event should get it's own handler fired", () => {
const { templateRenderer } = fastSSR();
const result = consolidate(templateRenderer.render(html`<test-event-dispatch listen-self></test-event-dispatch>` ));
expect(result).toBe(`<test-event-dispatch event-detail=\"listen-self-success\" listen-self><template shadowroot="open"></template></test-event-dispatch>`)
expect(result).toBe(`<test-event-dispatch event-detail=\"listen-self-success\" listen-self defer-hydration><template shadowroot="open"></template></test-event-dispatch>`)
});
test("An ancestor with a handler should get it's handler invoked if the event bubbles", () => {
const { templateRenderer } = fastSSR();

const result = consolidate(templateRenderer.render(html`<test-event-listener data="bubble-success"><test-event-dispatch></test-event-dispatch></test-event-listener>`));
expect(result).toBe(`<test-event-listener data=\"bubble-success\"><template shadowroot=\"open\"></template><test-event-dispatch event-detail=\"bubble-success\"><template shadowroot=\"open\"></template></test-event-dispatch></test-event-listener>`)
expect(result).toBe("<test-event-listener data=\"bubble-success\" defer-hydration><template shadowroot=\"open\"></template><test-event-dispatch event-detail=\"bubble-success\" defer-hydration><template shadowroot=\"open\"></template></test-event-dispatch></test-event-listener>")
});
test("Should bubble events to the document", () => {
document.addEventListener("test-event", (e) => {
Expand All @@ -199,7 +199,8 @@ test.describe("FASTElementRenderer", () => {
const { templateRenderer } = fastSSR();

const result = consolidate(templateRenderer.render(html`<test-event-dispatch></test-event-dispatch>`));
expect(result).toBe(`<test-event-dispatch event-detail=\"document-success\"><template shadowroot=\"open\"></template></test-event-dispatch>`);

expect(result).toBe(`<test-event-dispatch event-detail=\"document-success\" defer-hydration><template shadowroot=\"open\"></template></test-event-dispatch>`);
});
test("Should bubble events to the window", () => {
window.addEventListener("test-event", (e) => {
Expand All @@ -208,19 +209,19 @@ test.describe("FASTElementRenderer", () => {
const { templateRenderer } = fastSSR();

const result = consolidate(templateRenderer.render(html`<test-event-dispatch></test-event-dispatch>`));
expect(result).toBe(`<test-event-dispatch event-detail=\"window-success\"><template shadowroot=\"open\"></template></test-event-dispatch>`);
expect(result).toBe(`<test-event-dispatch event-detail=\"window-success\" defer-hydration><template shadowroot=\"open\"></template></test-event-dispatch>`);
});
test("Should not bubble an event that invokes event.stopImmediatePropagation()", () => {
const { templateRenderer } = fastSSR();

const result = consolidate(templateRenderer.render(html`<test-event-listener data="stop-immediate-propagation-failure"><test-event-dispatch stop-immediate-prop></test-event-dispatch></test-event-listener>`));
expect(result).toBe(`<test-event-listener data=\"stop-immediate-propagation-failure\"><template shadowroot=\"open\"></template><test-event-dispatch event-detail=\"stop-immediate-prop-success\" stop-immediate-prop><template shadowroot=\"open\"></template></test-event-dispatch></test-event-listener>`)
expect(result).toBe(`<test-event-listener data=\"stop-immediate-propagation-failure\" defer-hydration><template shadowroot=\"open\"></template><test-event-dispatch event-detail=\"stop-immediate-prop-success\" stop-immediate-prop defer-hydration><template shadowroot=\"open\"></template></test-event-dispatch></test-event-listener>`)
});
test("Should not bubble an event that invokes event.stopPropagation()", () => {
const { templateRenderer } = fastSSR();

const result = consolidate(templateRenderer.render(html`<test-event-listener data="stop-propagation-failure"><test-event-dispatch stop-prop></test-event-dispatch></test-event-listener>`));
expect(result).toBe(`<test-event-listener data=\"stop-propagation-failure\"><template shadowroot=\"open\"></template><test-event-dispatch event-detail=\"stop-prop-success\" stop-prop><template shadowroot=\"open\"></template></test-event-dispatch></test-event-listener>`)
expect(result).toBe(`<test-event-listener data=\"stop-propagation-failure\" defer-hydration><template shadowroot=\"open\"></template><test-event-dispatch event-detail=\"stop-prop-success\" stop-prop defer-hydration><template shadowroot=\"open\"></template></test-event-dispatch></test-event-listener>`)
});
});

Expand All @@ -245,7 +246,7 @@ test.describe("FASTElementRenderer", () => {
const template = html`<${name}></${name}>`;
const { templateRenderer } = fastSSR({renderMode: "async"});

expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved><template shadowroot="open"></template></${name}>`)
expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved defer-hydration><template shadowroot="open"></template></${name}>`)
});


Expand All @@ -269,7 +270,7 @@ test.describe("FASTElementRenderer", () => {
const template = html`<${name}></${name}>`;
const { templateRenderer } = fastSSR({renderMode: "async"});

expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-reject><template shadowroot="open"></template></${name}>`)
expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-reject defer-hydration><template shadowroot="open"></template></${name}>`)
});
test("should await multiple PendingTaskEvents", async () => {
const name = uniqueElementName();
Expand Down Expand Up @@ -297,7 +298,7 @@ test.describe("FASTElementRenderer", () => {
const template = html`<${name}></${name}>`;
const { templateRenderer } = fastSSR({renderMode: "async"});

expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved-one async-resolved-two><template shadowroot="open"></template></${name}>`)
expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved-one async-resolved-two defer-hydration><template shadowroot="open"></template></${name}>`)
});
test("should render template content only displayed after PendingTaskEvent is resolved", async () => {
const name = uniqueElementName();
Expand All @@ -322,7 +323,7 @@ test.describe("FASTElementRenderer", () => {
const template = html`<${name}></${name}>`;
const { templateRenderer } = fastSSR({renderMode: "async"});

expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name}><template shadowroot="open"><h1>Async content success</h1></template></${name}>`)
expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} defer-hydration><template shadowroot="open"><h1>Async content success</h1></template></${name}>`)
});
test("should support nested async rendering scenarios", async () => {
const name = uniqueElementName();
Expand All @@ -347,7 +348,7 @@ test.describe("FASTElementRenderer", () => {
const template = html`<${name}><${name}></${name}></${name}>`;
const { templateRenderer } = fastSSR({renderMode: "async"});

expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved><template shadowroot="open"><slot></slot></template><${name} async-resolved><template shadowroot="open"><slot></slot></template></${name}></${name}>`)
expect(await consolidateAsync(templateRenderer.render(template))).toBe(`<${name} async-resolved defer-hydration><template shadowroot="open"><slot></slot></template><${name} async-resolved defer-hydration><template shadowroot="open"><slot></slot></template></${name}></${name}>`)
});
})
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ abstract class FASTElementRenderer extends DefaultElementRenderer {
*/
public readonly element!: FASTElement;

/**
* When true, instructs the ElementRenderer to yield the `defer-hydration` attribute for
* rendered elements.
*/
protected abstract deferHydration: boolean;

/**
* The template renderer to use when rendering a component template
*/
Expand Down Expand Up @@ -129,19 +135,8 @@ export abstract class AsyncFASTElementRenderer extends FASTElementRenderer
if (this.awaiting.size) {
yield this.pauseRendering().then(() => "");
}
const { attributes } = this.element;

for (
let i = 0, name, value;
i < attributes.length && ({ name, value } = attributes[i]);
i++
) {
if (value === "" || value === undefined || value === null) {
yield ` ${name}`;
} else {
yield ` ${name}="${escapeHtml(value)}"`;
}
}

yield* renderAttributesSync.call(this);
}
}
renderShadow = renderShadow as (
Expand Down Expand Up @@ -184,6 +179,10 @@ function* renderAttributesSync(this: FASTElementRenderer): IterableIterator<stri
yield ` ${name}="${escapeHtml(value)}"`;
}
}

if (this.deferHydration) {
yield " defer-hydration";
}
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/web-components/fast-ssr/src/exports.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,36 @@
import "./install-dom-shim.js";
import fastSSR from "./exports.js";
import { test, expect } from "@playwright/test";
import { uniqueElementName } from "@microsoft/fast-element/testing";
import { FASTElement } from "@microsoft/fast-element";
import { consolidate } from "./test-utilities/consolidate.js";


test.describe("fastSSR default export", () => {
test("should return a TemplateRenderer configured to create a RenderInfo object using the returned ElementRenderer", () => {
const { templateRenderer, ElementRenderer } = fastSSR();
expect(templateRenderer.createRenderInfo().elementRenderers.includes(ElementRenderer)).toBe(true)
})

test("should render FAST elements with the `defer-hydration` attribute by default", () => {
const { templateRenderer } = fastSSR();
const name = uniqueElementName();
FASTElement.define(name);

expect(consolidate(templateRenderer.render(`<${name}></${name}>`))).toBe(`<${name} defer-hydration><template shadowroot="open"></template></${name}>`)
});
test("should render FAST elements with the `defer-hydration` attribute when deferHydration is configured to be true", () => {
const { templateRenderer } = fastSSR({deferHydration: true});
const name = uniqueElementName();
FASTElement.define(name);

expect(consolidate(templateRenderer.render(`<${name}></${name}>`))).toBe(`<${name} defer-hydration><template shadowroot="open"></template></${name}>`)
});
test("should not render FAST elements with the `defer-hydration` attribute when deferHydration is configured to be false", () => {
const { templateRenderer } = fastSSR({deferHydration: false});
const name = uniqueElementName();
FASTElement.define(name);

expect(consolidate(templateRenderer.render(`<${name}></${name}>`))).toBe(`<${name}><template shadowroot="open"></template></${name}>`)
});
});
Loading