SSR Rehydration

Summary

Whereas server-side rendering (SSR) allows a web application to be rendered into HTML on the server, rehydration involves receiving that HTML on the client and reusing the resulting DOM during initial client-side rendering (CSR).

This document proposes an approach to supporting SSR rehydration in LWC.

Problem description

Motivation

The motivations for SSR are well documented and can be boiled down to the following two points:

However, rendering on the server can itself introduce performance issues, working against the original intent. Namely, if DOM that was generated from SSR HTML is not preserved during the initial CSR, this will result in potentially expensive DOM manipulation and unwanted visual artifacts.

The aim of rehydration is to avoid the client-side performance issues of a naive SSR implementation, reusing original DOM wherever possible.

The happy path

With regard to SSR, there exists a happy path (Fig 1a) where the rendering output for a given request is identical on both the server and the client; identical VDOM is generated for the entire document in both environments.

Depending upon the actual implementation, exceptions to this happy path include:

Fig. 1
Fig.1: the happy path and special case exceptions

Design

Several pieces need to come together to unlock rehydration. The general flow is illustrated below in figure 2.

Fig. 2
Fig.2: the proposed implementation


In roughly chronological order with respect to a single page request, the following steps shall be taken as part of rehydration:

Note: We will not serialize @api or @track state on the server and rehydrate on the client. State will only be accepted for the root component via hydrateElement, and it is beholden on downstream developers to ensure their server-side and client-side state is identical.

Bailing out

There is a spectrum of choices possible when encountering an unexpected mismatch during rehydration. The following is a sampling from that spectrum:

  1. Bail out of rehydration entirely when a mismatch is detected, throw away the original HTML, and force a full re-render.
  2. Handle easy mismatches (text nodes, certain attrs, etc.) falling back to number 1 for anything more extreme.
  3. Bail out of rehydration for subtrees, rooted at the component where the mismatch occurred.
  4. Bail out of rehydration for subtrees, rooted at the mismatch itself.
  5. Try to repair as much of the SSR-derived DOM as possible, throwing nothing away explicitly.
  6. Assume no mismatches exist, and break silently.

The approach that we propose for adoption is #3. Specifically:

If we determine that this behavior has undesirable performance characteristics, we will pivot to approach #2.

Recovering from lifecycle errors

It might happen that an error is thrown while invoking the connectedCallback or other lifecycle method. This situation could occur because:

  1. The userland lifecycle method has a bug.
  2. The state extracted from the SSR DOM is incorrect for some reason, leading to unexpected inputs and behavior in the lifecycle method.

We will want to recover from #2. And because it won't be possible to differentiate #1 and #2 at runtime, a catch-all solution must be adopted.

It is proposed that, should an error be thrown during connectedCallback, we should assume that an unexpected mismatch has occurred, throw away the SSR DOM, and replace it with CSR VDOM. The connectedCallback will then be invoked again (connectedCallback can fire more than once), this time for the CSR DOM.

If an error occurs again, we can assume its proximate cause is in userland and not in LWC.

hydrateElement

In a typical LWC web application, a developer might mount their app using the following pattern:

import { createElement } from 'lwc';
import MyApp from './my-app';

document
  .querySelector("#root")
  .appendChild(createElement('my-app', { is: MyApp }));

After the proposed change, if a developer wants to take advantage of rehydration, a developer would instead do:

import { hydrateElement } from 'lwc';
import MyApp from './my-app';

hydrateElement(document.querySelector('#root'), MyApp, { some: props }); 

hydrateElement accepts three arguments:

  1. The already-existing DOM node where the root component should be attached.
  2. The LWC class intended for use as the root component.
  3. Any props that should be supplied to that root component as part of the initial client-side render.

Constructable stylesheets

The introduction of constructable stylesheets to LWC presents a small wrinkle. Since support for this browser API is not yet ubiquitous, we cannot know during SSR whether it will be supported on the client. For that reason, constructable stylesheets are disabled during SSR.

From there, we have two options if support for constructable stylesheets is detected during rehydration:

  1. Swap in constructable stylesheets for vanilla stylesheets.
  2. Disable constructable stylesheets during hydration, for the lifetime of the component instance.

It is believed that swapping in constructable stylesheets could obviate the performance wins that constructable stylesheets are intended to bring. Furthermore, any theoretical benefit is outweighed by the implementation complexity of swapping in constructable stylesheets.

For these reasons, it is proposed that constructable stylesheets be disabled during hydration, and during the lifetime of the component instance.

Server-only components

Addressing Special Case B will involve a solution roughly comparable to React server components. While the bulk of this can be implemented in user-land, at a level of abstraction above LWC, the hydrateElement patch implementation will need to support the use-case.

However, we consider support for this case to be out-of-scope for the initial implementation. We will explore further as part of a separate RFC.

SSR placeholders

Implementation of placeholder components, as described in Special Case A should require no extra effort, so long as unexpected subtree mismatches (Special Case C) are dealt with properly.

The following is an example of how this might be accomplished, although it is important to note that any such implementation is outside the scope of LWC and should be handled downstream.

Example:

Implementation in user-land is possible with simple runtime environment detection:

import { LightningElement } from 'lwc';
import { isNodeEnv } from './my-utils';
import tmplServerPlaceholder from './templateServer.html';
import tmplClient from './templateClient.html';

export default class ElementWithSSRPlaceholder extends LightningElement {
    render() {
        return process.browser ? tmplClient : tmplServerPlaceholder;
    }
}

Alternately, if it is desirable not to ship the placeholder in the client-side bundle, one could utilize a Babel transform or @rollup/plugin-replace to make the necessary changes at build time.

import { LightningElement } from 'lwc';
import { isNodeEnv } from './my-utils';
// ↶ removed from client builds during minification
import tmplServerPlaceholder from './templateServer.html';
import tmplClient from './templateClient.html';

export default class ElementWithSSRPlaceholder extends LightningElement {
    render() {
        return process.browser
            ? tmplServerPlaceholder
            : tmplClient;
        /*
          For client builds, further minification iterations will result in:
            1. return false ? tmplServerPlaceholder : tmplClient
            2. return tmplClient
        */
    }
}

customElements.define

Without safe-guards, it might happen that customElements.define is invoked with SSR-serialized custom elements already in the DOM prior to the invocation of hydrateElement, leading to unexpected behavior. The following list enumerates the conditions that may be encountered and how we plan to handle them.

Sundry

How we teach this

Much like the new functionality itself, the documentation for SSR rehydration is purely additive. We won't be changing the behavior or API of existing features.

Beneficial documentation may include:

Survey of prior art

A handful of influential web frameworks and state management libraries were examined in relation to rehydration and adjacent concerns. The findings are summarized as follows:

0000-ssr-rehydration-fig3.png

Notes:

  1. React 17 will feature enable Suspense on the server. Functionality is currently available through a third-party library.
  2. Svelte stores information for associating DOM nodes with components in the <head>.
  3. Vue will await async data fetch at the component level, using serverPrefetch.
  4. Vue annotates root SSR nodes with HTML attrs denoting them as server rendered.
  5. Prior to React 16, rehydration could handle any mismatch between SSR and CSR VDOM. After React 16, only text nodes can mismatch.
  6. In development mode, Vue checks server-rendered DOM against client-rendered DOM. In production, this is disabled for performance reasons.
  7. It is technically possible but difficult to utilize nested stores alongside SSR, and not provided out-of-the-box.
undefined