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');
);
// 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();
// 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
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'));
// 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>
<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') // <-
]
}
];
... 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"
}
{
"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`));
... 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>
<!--
- 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" />
<!-- fragmentName.js.html -->
<script type="text/javascript" src="/fragmentName.js"></script>
<!-- fragmentName.css.html -->
<link rel="stylesheet" href="/fragmentName.css" />