Solving the Prerendering Preact Problem

I am using Puppeteer to prerender my website which is powered by Preact. Recently, I came across an issue with my setup and thought it's worth a blog post. And here we are.

I have made the DP Maker some time ago. It has a few inputs controlled by Preact. They were working fine first. When I switched to hydrate (instead of render) on production, I noticed something odd: no default states were there for range and checkbox inputs.
Fortunately, I was able to find and fix the issue.

Let's say we run this code in a browser.

import { h, render } from "preact";
import { useState } from "preact/hooks";

const WhateverComponent = () => {
    const [isChecked, setIsChecked] = useState(true);

    return <input id="my-checkbox" type="checkbox" checked={checked} onInput={(event) => {
        setIsChecked(event.target.valueAsBoolean)
    } />
}

render(<WhateverComponent />, document.getElementById("root"));

And if we inspect the #root element, we will find:

<input id="my-checkbox" type="checkbox" />

We can notice that the element doesn't have a checked attribute. However, it actually is checked; the DOM will reflect that its checked. We can ensure that by running $("input#my-checkbox").checked in the console, which will return true.

However, it was causing an issue for me. In my setup, the pages are opened in Chrome and saved as HTML files by Puppeteer. As the checked state is not reflected in the HTML, the prerendered page wouldn't have the checked states. On production, the inputs wouldn't have a default state until the JavaScript is ran, which fails the whole idea of prerendering. And, recently I have started using the hydrate function by Preact (instead of render) on production, to make it a little faster. Contrary to render, hydrate just attaches the event listeners and stuff; no rerendering or diffing. Because of this behavior of hydrate, the initial states were never set on production.

Keep in mind that we won't encounter this issue when using preact-render-to-string. It only arises when we are using Puppeteer or a similar setup.

#One solution

My first approach was to set the default values for the inputs in useEffect of the DP maker page, manually. The code is like this:

// pages/tools/dp-maker.js

export default function DPMaker() {
    useEffect(() => {
        // some other stuff

        // set default for shape input
        const noShapesSelected = Array.from(
                document.querySelectorAll("input[name=shape]")
            ).every((element) => {
                return !(element as HTMLInputElement).checked;
            });

        if (noShapesSelected) {
            const defaultShape = document.querySelector(
                "input[id=squircle]"
            ) as HTMLInputElement;

            defaultShape.checked = true;
            defaultShape.setAttribute("checked", "");
        }

        // ...set default for other inputs
    }, []);

    return (
        // ...
        // the page
        // ...
    )
}

The code block adds checked attribute to the default shape when none of the shape inputs are checked.

Although the above approach provides a decent solution for the issue I was facing, it has a few drawbacks.

  1. The default value is hardcoded in the snippet (in "input[id=squircle]"). If I want to change the default value in the future, I have to change it here as well.
  2. The same must to be done to all the other inputs.
  3. If an input element in another page, this code snippet has to be copied to that page.

Of course, we can get around these drawbacks by being careful. But who are we kidding? "We will be careful" never works.

#A better solution

I was fortunate enough to discover a more effective solution — using the options hook.

Preact provides the "options" hooks which lets us hook into the lifecycle of the Preact render. It is an object with a few functions which are called from in each stage of the Preact render. We can override these functions and alter the rendering behavior of Preact. More info about it can be found on Preact's documentation.

Here's how I used it to solve my issue.

// App.js
// The main javascript file.

import { options } from "preact/hooks";
import forPuppeteer from "const:forPuppeteer";
// forPuppeteer is set by the build tool.
// true on local development and prerendering
// false on production

if (forPuppeteer) {
    // in order to continue the render, we must call this after we are done inside options.diffed.
    const oldDiffed = options.diffed;
    // not all the inputs need to be updated
    const INPUTS_TO_UPDATE = ["radio", "range"];

    options.diffed = (vnode) => {
        try {
            if (
                vnode.type === "input" &&
                INPUTS_TO_UPDATE.includes(vnode.props["type"])
            ) {
                const inputType = vnode.props["type"];
                const domElement = vnode["__e"] // the input element;
                const domId = `input#${domElement.id}`;

                if (inputType === "radio") {
                    const isJsChecked = vnode.props["checked"] as boolean;
                    const isDomChecked = domElement.hasAttribute("checked");

                    if (!isDomChecked && isJsChecked === true) {
                        console.log(`CHANGING ${domId} as checked`);
                        domElement.setAttribute("checked", "");
                    }

                    if (isDomChecked && isJsChecked === false) {
                        console.log(`CHANGING ${domId} as not checked`);
                        domElement.removeAttribute("checked");
                    }
                } else if (inputType === "range") {
                    const jsValue = vnode.props["value"].toString();
                    const isDomValueSet = domElement.hasAttribute("value");

                    if (!isDomValueSet) {
                        console.log(
                            `CHANGING input#${domId}'s value from ${domElement.value} to ${jsValue}`
                        );
                        domElement.setAttribute("value", jsValue);
                    }
                }
            }
        } catch (e) {
            consoel.error("Error in options.diffed", e);
        }

        oldDiffed(vnode);
    }
}

const App = () => {
    return (
        // ...
        // the page
        // ...
    )
}

In the above code, I am using options.diffed. This function is invoked after a vnode is rendered to the DOM. When the diffed function is invoked after a input element, I am updating the checked or the value property in the HTML attribute.

This approach has a few advantages over the former:

  • No repeated code. DRY!
  • As the code is located in the main JS file, it works for all inputs spread around all the pages. I don't have to copy the code around.
  • As the code is encapsulated around forPuppeteer check, Rollup automatically omits the code on production — final bundle sizes stay intact.

Pretty neat. The options hook is yet another reason to love Preact ❤