Prerendering static, hydratable fragments with Svelte

ND

Nicolás Delfino / October 03, 2020

7 min read

Prerendering Static, Hydratable Fragments with Svelte

In my prior blog post Introduction to micro-frontends and SCS I wrote about certain rules you want to follow when leveraging a composition strategy using fragments.

TLDR:

Fragments are decoupled UI blocks, have no external dependencies (markup, styling and behavior) and are in charge of their own caching strategies.

In this post we’ll look at some of the different options we have when writing fragments, how they stand in terms on DX and take a deeper view into prerendering with Svelte.

Authoring Fragments - DX?#

Out of the three resources a fragment needs - markup, styling and behavior, the one where it's most apparent that you want to have good DX is when you need to do some sort of markup templating.

Example
A customer adds 3 items to the cart and you need to update your fragment
The question is - how will you visualize this change?

Options:#

Here are some options rated on DX and general "best practices" of SCS

#1 - Manually update divs using vanilla JS, handling templating yourself

// example:

const cartItems = `<ul>${renderItemListElements()}</ul>`;

Pro: Light weight / No dependencies

Trade-off: Bad DX / Verbose

#2 - Using a frontend library that handles the templating for you

// example:

const cartItems = ...
ReactDOM.render(
  <ul>cartItems.map(item => (<li key={item}>name: {item}</li></ul>)),
  document.getElementById('root');
);

Pro: Good DX

Trade-off: Runtime dependency / Possible versioning conflicts between fragments

#3 - Doing templating on the server (Razor / EJS) and transclusion on the client (e.g via refreshing an h-include element). Behavior bundling on the side.

// example:

<h-include src="..."></h-include>; // <- HTML

const element = document.getElementsByTagName('h-include')[0];
element.refresh();

Pro: Good DX / Transclusion

Trade-off: Multiple dev environments

#4 - Templating on the server and progressive enhancement on the client

Server -> transclusion on the client -> progressive enhance behavior

Pro: Good DX / Transclusion

Trade-off: Runtime dependency to progressive enhancement library


All valid options in their own way, but how can we keep the benefit of avoiding runtime dependencies, improve DX and introduce templating at build time?

#5 - Prerendering#

Prerendering is basically about hydrating static content, so instead of using SSR (Server Side Rendering) where the page gets rendered on the server and then hydrated on the client, we swap out the server part by prerendering (statically generating) the page at build time and keep the hydrating part.

There's this great user discussion at github (Svelvet repo) - "prerendering html files and hydrating" where you can read more in depth about some of the community efforts regarding prerendering, but in its most simplest form - you achieve prerendering by combining the output of a SSR build and a build for the browser.

Before we look at the configuration files, let's have a look at the fragment we're building.


Our Cart Fragment#

// Event from fragment consumer host
document.dispatchEvent(new CustomEvent('buyEvent'));
<script>
  // Svelte component

  let promise;
  document.addEventListener('buyEvent', () => {
    // post mock
    promise = new Promise((resolve) => {
      setTimeout(() => {
        resolve([
          { name: 'red ball' },
          { name: 'blue ball' },
          { name: 'green ball' }
        ]);
      }, 2000);
    });
  });
</script>
<main>
  <h1>Cart</h1>
  {#await promise}
  <span>Updating cart...</span>
  {:then data} {#if data}
  <ul>
    {#each data as item}
    <li>{item.name}</li>
    {/each}
  </ul>
  {:else} Cart is empty {/if} {:catch error}
  <blockquote>{error}</blockquote>
  {/await}
</main>

Svelte's amazing templating engine makes the logic in this component pretty self explanatory, but to recap what's happening:

The component listens for a custom event, talks to an API and renders the response.

Writing this logic "vanilla" as in option #1 is ofc also doable but can get quite messy the more features you add, and chances are you lure yourself into writing custom abstractions that you may not want to own per fragment.

Rollup Config#

The following Rollup config and prerender.js (further down) are modified versions of akaSybe's svelte-prerender-example to fit the requirement of creating fragment resources.

... imports
import FConfig from './fragment.config.json'; // <- fragment config

const production = !process.env.ROLLUP_WATCH;
const { dist, name } = FConfig;

export default [
  {
    /*
    first pass:
    */

    input: 'src/main.js',
    output: {
      format: 'iife',
      name: 'app',
      file: `${dist}/${name}.js`
    },
    plugins: [
      svelte({
        dev: !production,
        hydratable: true
      }),
      resolve({
        browser: true,
        dedupe: (importee) =>
          importee === 'svelte' || importee.startsWith('svelte/')
      }),
      commonjs()
    ]
  },
  {
    /*
    second pass:
    */

    input: 'src/App.svelte',
    output: {
      format: 'cjs',
      file: `${dist}/.temp/ssr.js`
    },
    plugins: [
      svelte({
        dev: !production,
        generate: 'ssr'
      }),
      resolve({
        browser: true,
        dedupe: (importee) =>
          importee === 'svelte' || importee.startsWith('svelte/')
      }),
      commonjs(),
      execute('node src/prerender.js') // <-
    ]
  }
];

fragment.config.json#

{
  "name": "fragmentName",
  "dist": "dist"
}

prerender.js#

When prerender.js is executed, it renders the application and grabs the html and css by using the CJS output of the second pass - .temp/ssr.js.

We save our CSS & HTML resources (JS resource is created at first pass) and generate the inclusion html files.

... imports
const FConfig = require('../fragment.config.json');

const { dist, name } = FConfig;

const App = require(path.resolve(process.cwd(), `${dist}/.temp/ssr.js`));

const baseTemplate = fs.readFileSync(
  path.resolve(process.cwd(), 'src/template.html'),
  'utf-8'
);

/*
base template:
<div id="fragment">
  <!-- fragment-markup -->
</div>
*/

const { html, css } = App.render({ name: 'test prop' });

const minifiedHtml = minify(html, {
  collapseWhitespace: true
});

const markup = baseTemplate.replace('<!-- fragment-markup -->', minifiedHtml);

// css
const cssInclude = `<link rel="stylesheet" href="/${name}.css" />`;
fs.writeFileSync(path.resolve(process.cwd(), `${dist}/${name}.css`), css.code);
fs.writeFileSync(
  path.resolve(process.cwd(), `${dist}/${name}.css.html`),
  cssInclude
);

// js
const jsInclude = `<script type="text/javascript" src="/${name}.js"></script>`;
fs.writeFileSync(
  path.resolve(process.cwd(), `${dist}/${name}.js.html`),
  jsInclude
);

// markup
fs.writeFileSync(path.resolve(process.cwd(), `${dist}/${name}.html`), markup);

rimraf.sync(path.resolve(process.cwd(), `${dist}/.temp`));

Build Output#

  • fragmentName.css
  • fragmentName.js
  • fragmentName.html
  • fragmentName.js.html
  • fragmentName.css.html

Our output consists of two types of resources - fragment resources, e.g:

<!-- 
- fragmentName.css
- fragmentName.js
- fragmentName.html -->

<div id="fragment">
  <main>
    <h1 class="svelte-1ucbz36">Cart</h1>
    Cart is empty
  </main>
</div>

And inclusion files for your endpoints serving the generated resources.

This is where caching comes to play, for instance - your fragment consumers only need to worry about requesting /fragments/cart/cart.js.html for the behavior part of the cart fragment since the caching for that file is handled by your team.

<!-- fragmentName.js.html -->
<script type="text/javascript" src="/fragmentName.js"></script>

<!-- fragmentName.css.html -->
<link rel="stylesheet" href="/fragmentName.css" />

Summary#

There's a lot more to write about when it comes to fragment composition, an upcoming post will be a more in depth look into the fourth option I mentioned earlier where we can utilize the server for templating and progressively enhance behavior client side.

Again, I encourage you to check out the discussion on github to get a feel where this is going. Adding Typescript to the mix seems to work to which should improve DX even more. Also - the same author behind the prerendering concept mentioned in this post has released a Rollup plugin called rollup-plugin-svelte-ssr which he states does the same thing but is easier to use.

For those of you interested, my fork demoing the fragment stuff we covered in this post (with Typescript) can be found here.