DEV Community

ndesmic
ndesmic

Posted on • Updated on • Originally published at indepth.dev

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

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?

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

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:

export const SiteHeader = title => <h1>{title}</h1> 
Enter fullscreen mode Exit fullscreen mode

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

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: https://medium.com/@luke_schmuke/how-we-achieved-the-best-web-performance-with-partial-hydration-20fab9c808d5 . 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:

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

We'll search the DOM for these markers and then call hydrate on the preceding 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 preceding 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 component:

//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}>
        </>`;
}
Enter fullscreen mode Exit fullscreen mode

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

//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++;
}
Enter fullscreen mode Exit fullscreen mode

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

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:

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

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

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:

//templates/components/_script-manager.js
export const scripts = new Set();

export function addScript(path){
    scripts.add(path);
}
export function getScripts(){
    return [...scripts];
}
Enter fullscreen mode Exit fullscreen mode

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:

//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++;
}
Enter fullscreen mode Exit fullscreen mode

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

//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>`
}
Enter fullscreen mode Exit fullscreen mode

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:

//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));
}
Enter fullscreen mode Exit fullscreen mode

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:

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))));
};
Enter fullscreen mode Exit fullscreen mode

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

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 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

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 the 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 we can use the same concept. At first I though maybe a service worker could redirect the imports 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:

//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() + "/"));
};
Enter fullscreen mode Exit fullscreen mode

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

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

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 importmap 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 scientific 95% of cases.

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

//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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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);
});
Enter fullscreen mode Exit fullscreen mode

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:

//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>
`;
Enter fullscreen mode Exit fullscreen mode

The home page looks like this:

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>
`;
Enter fullscreen mode Exit fullscreen mode

And finally our simple counter component:

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>
    `;
};
Enter fullscreen mode Exit fullscreen mode

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 here: https://github.com/ndesmic/react-ssg/tree/v0.2

Ok, but what about React?

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 I decide to add bundling maybe that avenue opens up again.

Top comments (2)

Collapse
 
maxkoretskyi profile image
Max

great article! how can I get in touch with you?

Collapse
 
ndesmic profile image
ndesmic

It depends on the purpose. If you have a question or something about my content you may post it here. You can also reach me at ndesmic at gmail as long as it's not recruitment spam.