Building a React Static Site Generator with Partial Hydration in <100 Lines of Code

Post Editor

Exploring partial hydration by building static site generator with React

11 min read
0 comments
post

Building a React Static Site Generator with Partial Hydration in <100 Lines of Code

Exploring partial hydration by building static site generator with React

post
post
11 min read
0 comments
0 comments

Last time I built a tiny React static site generator in roughly 20 lines of code leveraging htm to deal with the transpilation.  As expected that was a bit bare-bones.  While it was cool to get a whole React/JSX-y flow working for SSG all we could do was render content.  That's useful for a blog or marketing page perhaps but not much else.  So I wanted to explore how much work it would take to get it properly hydrated.

What is Hydration?
Link to this section

Hydration is the process by which pre-rendered content is made interactable.  Just because we rendered the html for a button does not mean the button does anything (actually if you're really cool you're progressively enhancing from html forms and so you could actually do something, but that takes a lot of discipline and may not work for everything).  In the case of a framework like React, hydration means that it starts at the root, traverses the element tree and makes sure everything matches up to what it expected.  While it does this it hooks up all the events listeners and logic.  Visually, the page is filled in from the pre-render, but in terms of actual functionality you are still almost as slow as if you client rendered.  This is "full hydration" and unfortunately this is default in many frameworks.

Partial Hydration
Link to this section

But we can do better.  As you go through building sites, particularly static ones, you might notice there are parts of the site that really are just visual and don't change.  We don't need to run a tree-diffing algorithm to see if they diverged.  Think about a site header:

<>Copy
export const SiteHeader = title => <h1>{title}</h1>

We probably don't actually change anything about that header after it rendered, so we can save time by not trying to hydrate it.  Also, in most isomorphic code architectures this component would also be included in your client bundle even if you never use it on the client side.  While this is a very tiny example you can imagine there are more larger and more complex components you might use that have the same restrictions.  If we don't need it, we shouldn't ship it.

Marking Components
Link to this section

So if we aren't doing hydration on the whole tree, we need to to hydration on several subtrees.  How do we decide which things need to be hydrated?  There's a fantastic blog post on how to do this. I'll be taking a lot of ideas from here.

The trick is that we'll use a script tag (which won't render and won't screw up the DOM too much) to mark the element root.  It looks like this:

<>Copy
<script type="application/hydration-marker" data-id="1"></script> <div><!-- Component markup to hydrate --> ... </div>

We'll search the DOM for these markers and then call hydrate on the following element.

In order to hydrate we need to know 3 things:

  1. The DOM node to be hydrated
  2. The component it's being hydrated with
  3. The props to the component it's being hydrated with

We know 1 because it's the element immediately following the marker, but what about 2 and 3?

To do this we need to make a registry system.  For each hydration marker we set an id, and from this id we can lookup the component and the props that are supposed to go there.

We'll make the WithHydration higher-order component:

<>Copy
//templates/components/_hydrator.js export function WithHydration(Component, path){ return props => html` <> <script type="application/hydration-marker" data-id="${storeHydrationData(Component, props, path)}" /> <${Component} ...${props}> </>`; }

It just renders the wrapped component with the marker.  Then we need to deal with the registry and storeHydrationData.

<>Copy
//templates/components/_hydrator.js const hydrationData = {}; const componentPaths = {}; let id = 0; export function storeHydrationData(component, props, path){ const componentName = component.displayName ?? component.name; hydrationData[id] = { props, componentName }; componentPaths[componentName] = { path, exportName: component.name }; return id++; }

This part of the module acts as a singleton that holds all of the hydration data.  Each time we register new data we bump the id so it's unique.  I also assign some data to another store called componentPaths.  This is because I want to avoid the complexity of bundling, at least for now.  Instead, we need to know where each component came from so we can import that script and the appropriate export.  This is also why the path parameter exists.  It's not a great API to have to pass in the component's script path, but necessary to make sure we have a reference to them.

Hydration Data
Link to this section

So we have a list of scripts in use.  Now we need to let the page know how it fits together.  This is done in a component called HydrationData:

<>Copy
//templates\preact\components\_hydrator.js export function HydrationData(){ return html`<script type="application/hydration-data" dangerouslySetInnerHTML=${{ __html: JSON.stringify({ componentPaths, hydrationData })}} />`; }

We can add this to the layout.  All it does is keep track of the JSON serialized list of components and the info to hydrate them.

Emitting Scripts
Link to this section

The original site generation didn't handle scripts at all.  So even if we manually wrote script tags, they wouldn't work because only html is ever output.  We need to fix this.  What would be best is if we could only output the things that we know we're going to need and not all the scripts that make up the site.  To do so, we need to keep track of which scripts are actually being used, and I do that in a small module:

<>Copy
//templates/components/_script-manager.js export const scripts = new Set(); export function addScript(path){ scripts.add(path); } export function getScripts(){ return [...scripts]; }

This is also a singleton store.  We can use it where we generate the hydration data as we know that script is necessary for hydration:

<>Copy
//templates/components/_hydrator.js export function storeHydrationData(component, props, path){ const componentName = component.displayName ?? component.name; hydrationData[id] = { props, componentName }; componentPaths[componentName] = { path, exportName: component.name }; addScript(path); //here return id++; }

I think it would also be useful for users to add scripts directly too:

<>Copy
//templates/components/_script.js import { html } from "htm/preact/index.mjs"; import { addScript } from "./_script-manager.js"; export function Script({ src }){ addScript(src); return html`<script src=${src} type="module"></script>` }

You can use this like <${Script} src="./my-script.js" />.  Just like a normal script, but it will register it for output.

Now we can go to htm-preact-renderer.js and augment it to copy over the scripts that were marked for use:

<>Copy
//renderers/htm-preact-render.js import { getScripts } from "../templates/preact/components/_script-manager.js"; //at the very end after html files have been written //export scripts in use for(const script of getScripts()){ const outputPath = fileURLToPath(new URL(script, outputUrl)); await ensure(outputPath) .then(() => fs.copyFile(fileURLToPath(new URL(script, templatesUrl)), outputPath)); }

We get the scripts and we copy them over so they can be available from the output folder.  I originally tried to do this with Promise.all and it didn't work out so great as the ensure calls will encounter race conditions when writing directories.

We still need the Preact scripts so let's add those too:

<>Copy
//renders/htm-preact-render.js const preactScripts = ["./node_modules/preact/dist/preact.mjs", "./node_modules/preact/hooks/dist/hooks.mjs", "./node_modules/htm/preact/dist/index.mjs"]; for(const script of preactScripts){ const outputPath = fileURLToPath(new URL(script, outputUrl)); await ensure(outputPath) .then(() => fs.copyFile(fileURLToPath(new URL(script, pathToFileURL(process.cwd() + "/"))), fileURLToPath(new URL(script, outputUrl)))); };

This is suboptimal at least as far as exports go, I'm just hardcoding the ones I know are in use.  If we didn't have any hydrated components we don't need Preact at all, or maybe we don't need all of them.  But to figure that out is not easy so I'm going to skip it.  Since we'll be using dynamic imports we won't pay a runtime cost at least.

Isomorphic Imports
Link to this section

So maybe you can mentally plot where we're going next.  We have all the scripts available, and we have list on the client-side of everything we need to hydrate the component: the script path to the component, the component export name, and the props.  So, just stitch it together right?  Unfortunately, there's a big rock in our path which is isomorphic imports. On the node side import { html } from "htm/preact/index.mjs"; is handled easily.  Even though we need to add the suffix for ESM imports to work this is not enough to make the import isomorphic because node is still resolving the bare import.  What does path htm mean in the browser?  It's simply not supported and you'll get an error.

I touch on this a little bit in my Best Practice Tips for Writing Your JS Modules.  You may think you could re-write the import like this: import { html } from "../../../node_modules/htm/preact/index.mjs";. That doesn't work either because inside of index.mjs it references preact as a bare import, and we didn't write that.

Screenshot 2020-12-06 142756
Uncaught (in promise) TypeError: Failed to resolve module specifier "preact". Relative references must start with either "/", "./" or "../".

This is typically where a bundler needs to be added, just to fix this one tiny little issue.  It's sad and in my opinion a failure of the ecosystem. Even very future forward libraries like htm suffer from it.

So what are our options?

  1. Introduce a bundler
  2. Import Maps

I don't want to do 1 just yet, because I want this to remain fairly simple for right now.  2 doesn't have support in browsers...or does it?

While it is true no browsers support import maps (you can use them in Chrome behind a flag as of this writing) we can use the same concept.  At first I thought maybe a service worker could redirect the import fetch but bare imports are actually syntax error, which means we must do script re-writing.  This can also be done in a service worker but we have access to the script source at render time so it's much easier and performant to do it there.  I'm going to re-write what we just did in the renderer to do just that.  Here's the whole thing:

<>Copy
//renders/htm-preact-renderer.js import { promises as fs } from "fs"; import { fileURLToPath, pathToFileURL } from "url"; import yargs from "yargs"; import render from "preact-render-to-string"; import { getScripts } from "../templates/preact/components/_script-manager.js"; import { ensure, readJson } from "../utilities/utils.js"; const args = yargs(process.argv.slice(2)).argv; const templatesUrl = pathToFileURL(`${process.cwd()}/${args.t ?? "./templates/"}`); const outputUrl = pathToFileURL(`${process.cwd()}/${args.o ?? "./output/"}`); const files = await fs.readdir(fileURLToPath(templatesUrl)); await ensure(fileURLToPath(outputUrl)); const importMap = await readJson("./importmap.json"); const patchScript = src => src.replace(/(?<=\s*import(.*?)from\s*\")[^\.\/](.*?)(?=\")/g, v => importMap.imports[v] ?? `Bare import ${v} not found`); async function emitScript(path, base){ const outputPath = fileURLToPath(new URL(path, outputUrl)); await ensure(outputPath) const src = await patchScript(await fs.readFile(fileURLToPath(new URL(path, base)), "utf-8")); await fs.writeFile(fileURLToPath(new URL(path, outputUrl)), src); } for (const file of files) { if (/^_/.test(file) || !/\.js$/.test(file)) continue; const outfile = new URL(file.replace(/\.js$/, ".html"), outputUrl); const path = new URL(file, templatesUrl); const { title: pageTitle, body: pageBody, layout: pageLayout } = await import(path); const body = typeof (pageBody) === "function" ? await pageBody() : pageBody; const { layout } = await import(new URL(pageLayout ?? "_layout.js", templatesUrl)); const output = render(layout({ title: pageTitle, body })); await fs.writeFile(fileURLToPath(outfile), output); } //export scripts in use const scripts = getScripts(); for(const script of scripts){ await emitScript(script, templatesUrl); } const preactScripts = ["./node_modules/preact/dist/preact.mjs", "./node_modules/preact/hooks/dist/hooks.mjs", "./node_modules/htm/preact/index.mjs", "./node_modules/htm/dist/htm.mjs"]; for(const script of preactScripts){ await emitScript(script, pathToFileURL(process.cwd() + "/")); };

Same as above but the code was simplified and I added the import rewriter emitScript. Let's zoom in on that:

<>Copy
//renders/htm-preact-renderer.js const patchScript = src => src.replace(/(?<=\s*import(.*?)from\s*\")[^\.\/](.*?)(?=\")/g, v => importMap.imports[v] ?? `Bare import ${v} not found`);

This fancy/hacky regex finds strings that look like import {something} from "library" (any module name not preceded by . or /), takes "library" and then does a lookup into the import map and replaces it.  As you might imagine it's not bulletproof, it might replace instances in strings for example. To do it properly, we need a parser but that's well beyond the scope of this project so a regex will do, it works for a scientific 95% of cases.

importmap.json exists at the root and contains a valid import map per the current spec:

<>Copy
//importmap.json { "imports": { "preact" : "/output/preact/node_modules/preact/dist/preact.mjs", "htm/preact/index.mjs" : "/output/preact/node_modules/htm/preact/index.mjs", "htm": "/output/preact/node_modules/htm/dist/htm.mjs", "preact/hooks/dist/hooks.mjs": "/output/preact/node_modules/preact/hooks/dist/hooks.mjs" } }

So now each script's imports are rewritten if they are a bare import (relative paths are passed through).  In fact, we probably don't even need to keep the node_modules as part of the path since we have full control, but there's a lot of cleanup I won't be doing this round.

Hydration
Link to this section

The final piece of the puzzle is the script to hydrate everything:

<>Copy
import { render, h } from "preact"; const componentData = JSON.parse(document.querySelector("script[type='application/hydration-data']").innerHTML); document.querySelectorAll("script[type='application/hydration-marker']").forEach(async marker => { const id = marker.dataset.id; const { props, componentName } = componentData.hydrationData[id]; const { path, exportName } = componentData.componentPaths[componentName]; const { [exportName]: component } = await import(new URL(path, window.location.href)); render(h(component, props), marker.parentElement, marker.nextElementSibling); });

We look up each marker, find the next element, import the script with the corresponding export name and add the props.  According to the Preact documentation hydrate should be used, but when I tried it, it screwed up the order of the elements. render works though.

The layout now looks like this:

<>Copy
//templates\preact\_layout.preact.js import { html } from "htm/preact/index.mjs"; import { HydrationData } from "./components/_hydrator.js"; import { Script } from "./components/_script.js"; export const layout = data => html` <html> <head> <title>${data.title}</title> </head> <body> ${data.body} <${HydrationData} /> <${Script} src="./components/_init-hydrate.js" /> </body> </html> `;

The home page looks like this:

<>Copy
import { html } from "htm/preact/index.mjs"; import { Counter } from "./components/_counter.preact.js"; import { WithHydration, HydrationData } from "./components/_hydrator.js"; export const title = "Home Preact"; export const layout = "_layout.preact.js" const Header = ({ text }) => html`<h1>${text}</h1>` export const body = html` <div> <${Header} text="Hello World!"><//> <p>A simple SSG Site with Preact</p> <${WithHydration(Counter, "./components/_counter.preact.js")} title="counter" /> </div> `;

And finally our simple counter component:

<>Copy
import { useState } from "preact/hooks/dist/hooks.mjs"; import { html } from "htm/preact/index.mjs"; export const Counter = ({ title }) => { const [value, setValue] = useState(0); function increment(){ setValue(value + 1); } function decrement(){ setValue(value - 1); } return html` <div id="foo"> <h2>${title}</h2> <div>${value}</div> <button onClick=${increment}>+</button> <button onClick=${decrement}>-</button> </div> `; };

And with that, we have partial hydration working.  Maybe not completely optimized, maybe a little hacky, maybe the project structure could use a bit more work but we have a working SSG with partial hydration by default.  Few can claim that.

Final tally:

  • _hydrator.js: ~36 lines
  • _init_hydrate: ~11 lines
  • _script_manager: ~8 lines
  • htm-preact-renderer: ~43 lines
  • 0 new dependencies! (rimraf and http-server are for dev ergonomics and not necessary at all)

We're just under 100 lines of boilerplate code (not including the pages and components themselves)!

Code available on GitHub.

Ok, but what about React?
Link to this section

The title is a tad misleading (but better for search since the ideas here are not Preact-centric).  This project started with React and Preact at parity. I know from wrestling this bear a couple times that it'll be a bit tougher due to React's continued lack of ESM and honestly, at this point, everyone should be getting the benefits of Preact instead.  Probably an easier route would be to use Preact-compat or if we were to use a bundler maybe that avenue opens up again.

Comments (0)

Be the first to leave a comment

Share

About the author

default
author_image

About the author

ndesmic

About the author

default
Looking for a JS job?
Job logo
React Native Developer

Alpine IQ

United States
Remote
$90k - $110k
Job logo
Senior React Web Developer

SunnyByte LLC

United States
Remote
$71k - $158k
Job logo
React Developer

TheeBestPS

Worldwide
Remote
$135k - $150k
Job logo
Full Stack Engineer (React JS/Node JS)

CareerKarma

Ukraine
Remote
$48k - $90k
More jobs
NxReactCli
NxReactCli
NxReactCli

Featured articles

JavaScriptpost
27 September 202130 min read
An in-depth perspective on webpack's bundling process

Webpack is a very powerful and interesting tool that can be considered a fundamental component in many of today's technologies that web developers use to build their applications. However, many people would argue it is quite a challenge to work with it, mostly due to its complexity.

JavaScriptpost
27 September 202130 min read
An in-depth perspective on webpack's bundling process

Webpack is a very powerful and interesting tool that can be considered a fundamental component in many of today's technologies that web developers use to build their applications. However, many people would argue it is quite a challenge to work with it, mostly due to its complexity.

Read more
JavaScriptpostAn in-depth perspective on webpack's bundling process

27 September 2021

30 min read

Webpack is a very powerful and interesting tool that can be considered a fundamental component in many of today's technologies that web developers use to build their applications. However, many people would argue it is quite a challenge to work with it, mostly due to its complexity.

Read more