Server Side Rendering

Summary

This RFC defines the infrastructure pieces for the various platforms running LWC to be able to do server side rendering (SSR) without explicit coordination with components' authors.

Back Pointers

Original PR: https://github.com/salesforce/lwc/pull/1048

Motivation

The motivation for SSR is not a question at this point, we need it, every popular framework provides the full infrastructure or the pieces necessarily for consumers of the framework to implement their own mechanism. This is critical for LWC due to the performance characteristics of SSR for salesforce various platforms.

Goals of this proposal

Proposals

To support rendering a component, and all sub-components to string, we need to keep in mind that the shadow boundaries must be preserved, so when the markup is reconciliated with the runtime version of the same component instance on the client side, the diffing algorism can preserve as much Nodes as possible to avoid the annoying flickering.

Other libraries do not have this problem, since they don't rely on the shadow dom semantics to be begin with. What this really means is that the produced string will have to have special annotations to be able to distinguish between the markup produced by the different components. It is important to notice that this could change in the future if browsers implement a declarative way to define a headless shadow dom, and this is actually our first new invariant:

As a derived invariant, since synthetic shadow dom does not support a root element to have slotted content, we can also enforce the following:

In order to produce a string representation of a root component and its sub-components (from its shadow), we need to to focus on two main things:

a) how can a user generate an HTML fragment that represents the state of a component and its sub-components in a way that it can be upgraded on the client side?

b) how can a user bend the rules of the engine by disabling certain hooks?

It is very likely that both things can be done in user-land without any especial API provided by LWC, and instead we could just prepare the server side environment to look like a regular LWC environment, but wrapping all public API to achieve both. More details about this to come.

The second topic to discuss on this proposal is the "sync" nature of the engine today. When a component is inserted into the DOM, the component renders, and all sub-components are subsequently as well in a synchronous manner, while components who are in need for data, might re-render themselves by virtue of a mutation in a later tick. This will not work on the server side because we need a time-frame where the fragment is ready, and later mutations of the DOM structure is not possible.

As today, the author of the component does not have control on when the element is connected, giving the engine the ability to decide when to carry on such procedure, this helps with the server side rendering mechanism a lot, because the engine on the server side can simply wait until all wire adapters are done fetching data before attempt to connect the element, which ultimate produces the right markup. In order for us to support this, the adapter will have to provide some hint about the request being in-flight, and a notification when it is done fetching. This notification can be implicit by getting the engine to wait until all all wire adapters have produced some data. For now, we will stick to that, which requires no changes in our current infrastructure, and if needed, we can add more coordination between the wire adapter and the wire decorator.

This will work very well on the server side where the component author doesn't interact with the DOM (it is not a real DOM anyways). The engine will just wait until the adapter issue the ValueChangedEvent event to unlock the rendering cycle for the component, ultimately produce the final HTML. If the wire adapter never dispatches ValueChangedEvent, we need a way to unblock the rendering cycle on the server side.

Another piece of the puzzle is about the "upgrading" of the markup produced by the server side. First of all, we are overloading the term "rehydration", we already use it to denote the secondary rendering cycle, which should probably be called just "re-rendering". From now on, we can call "upgrade" to the process of getting some markup (generated by someone), and rendering an LWC on top of that by reusing as much as possible from the original DOM structure, and we can call "rendering" and "re-rendering" to the process of updating the shadowRoot of an LWC.

Based on this assumption, the markup generated by the server side should be reused by LWC engine when possible, the question is: how?

In principle, the only mechanism to communicate from the server to the LWC engine running on the client is the DOM markup, based on this assumption, the engine should be able to read the markup structure, attributes, and extra annotations in form of especial attribute to try to build the in-memory representation of the VNodes before applying the regular diffing mechanism.

There are several considerations:

Once we know a host element should be "upgraded", our diffing algorithm can take the necessary steps to build the virtual tree (this is equivalent to Snabbdom toVNode API, which we have removed from LWC because it didn't include the shadow boundaries detection).

Last, but not least, we need to be able to disable certain features when executing on certain environments, for example, on the server side there is no user-interaction, so, adding event listeners to the DOM is useless, or attempting to re-render due to a mutation is not useful, we should never re-render a component on the server side, only the first output is useful.

At first glance, offering the ability to replace LightningElement with something equivalent that has less capabilities is very interesting, but it is not sufficient because the super doesn't have the capabilities to prevent hooks to be inspected by the engine. This means that we will have to provide an alternative mechanism to disable such hooks.

Detailed Design

Proposal: RenderToString

The new package @lwc/ssr could provide such functionality by just wrapping the createElement API, the LightningElement abstraction, and adding a new RenderToString API to extract the innerHTML, and returning it, no changes are necessary on LWC to enable this, other than a way to disable certain features. More details about that in another section below. Here is an example of how to render a component to string:

import { createElement, RenderToString } from '@lwc/ssr';
import Todo from 'todo-mvc';
const elm = createElement(Todo, { is: Todo });
elm.foo = 1;
elm.bar = 'something';
const html = RenderToString(elm);

Open Questions:

Proposal: Blocking Rendering API

The wire protocol supports two operations today via the ValueChangedEvent: Initialization and Refreshing. Here is an example:

import { register, ValueChangedEvent } from 'wire-service';
register(getTodo, function getTodoWireAdapterFactory(eventTarget) {
    let config;

    const initialValue = 'initial value';

    eventTarget.addListener('config', (newConfig) => {
        if (config === undefined) {
            // this happens the first time, in which case we might want to provide some initial value
            eventTarget.dispatchEvent(new ValueChangedEvent(initialValue));
        }
        config = newConfig;
        const newValue = await fetchLatestValue();
        // this happens later in the future after every time the config changes
        eventTarget.dispatchEvent(new ValueChangedEvent(newValue));
    });

    // ....
});

In the example above, you can see both modes in action, the initial value dispatched vs the latest value dispatched. Of course, you can create a wire adapter that will never provide an initial value, in which case, the first time a new ValueChangedEvent is dispatched is when the adapter has some data ready.

A potential problem here is that by using a particular adapter, the component author will have to protect itself, making the component a lot more defensive because they do not control when the rendering will be called, and therefore no guarantees on when the wired data will be available. This is the case today, and it seems that honoring the ValueChangedEvent is a good first step.

The tricky part is how to configure the wire decorator and the engine to understand this cues to block the rendering process, and when to do so. A very simple mechanism will be:

This could be achieve by a simple global configuration, e.g.:

// server side configuration
LWC_config = {
    synthetic: true,
    wire: {
        block: 'always', // defaults to "never"
        timeout: 300,
    },
};

vs

// client side configuration
LWC_config = {
    synthetic: true,
    wire: {
        block: 'never', // defaults to "never"
    },
};

Proposal: Upgrade Element

This is the proposed API:

import { upgradeElement } from '@lwc';
import Todo from 'todo-mvc';
const elm = document.querySelector('x-todo');
upgradeElement(elm, { is: Todo });
elm.foo = 1;
elm.bar = 'something';

In the example above, the new upgradeElement API is going to upgrade elm reference in the next tick to give you time to reconciliate the initial state, if any, but also to match the semantics of the Web Components API where the upgrade happens on the next tick after the new tagName is registered. At this point, the elm's content will be inspected to extract the current state of the DOM from it to try to prepare the VM and its VNodes for the upcoming rendering cycle that will try to preserve as much as possible from the existing DOM structure based on the annotations.

The biggest challenge here seems to be the allocation of Text Nodes, but we might be able to mark them somehow, maybe via a comment with some flags on it. Additionally, having multiple sibling Text Nodes is cumbersome, but in that case we can treat them all as one, and let the diffing algorithm to readjust it by inserting the new nodes, and updating their text, which should not have any impact whatsoever in the user-experience. But if we decide to go with the comments, that might work better since we can mark each individual Text Node, and after the diffing algorithm finishes, the comments with the annotations will be completely removed from the DOM since they will not be part of the new VNode Tree.

Another less pressing challenge is the possibility that the element that is being upgraded required some data to be wired up. If we go with the what we have today, the client side will diff the content provided by the server side vs some empty template (this is the most common case today where a component that wire to data has a condition in the template to show a loading text until the data arrive). This will cause some flickering because by the time the upgrade process starts, the content is already visible, but the first rendering will wipe the content (via the if condition in the template), and eventually, once the data is resolved, the component gets re-rendered, this time with the proper content.

There are few ways to solve this problem, but it seems that the most common case will be that the data consumed by the server side to produce the initial markup in the first place, should be also sent to the client side and use as the dataset for the initial upgrade process to guarantee that we are showing the exact same produced by the server, and then reconciliate that with some fresh data in another tick. This of course, will require the wire adapter to recognized previously cached content.

Another alternative here is to use the same mechanism used by the server side, blocking the initial rendering until the wire decorator produces the first batch of data, this will not be critical because there is content being displayed to the user at all time.

Proposal: Disabled Features for non-browser environment

TBD

Alternatives

How we teach this ?

TBD

undefined