loading...
Cover image for Prefresh, Fast-refresh for Preact

Prefresh, Fast-refresh for Preact

jovidecroock profile image Jovi De Croock ・4 min read

Hot module reloading is a great way to improve developer experience, hitting save and seeing the output with the snap of a finger is great.
HMR by default can't really assume how a front-end framework works so it isn't able to just work out of the box, here solutions like react-hot-loader, react-fast-refresh and prefresh come into play.

In this article we'll mainly talk about prefresh and react-fast-refresh since the philosophy used by react-fast-refresh is the base for prefresh.

Well how does it work? This is a three-parter, we'll have the code-transformation part (Babel), the bundler part (we'll use webpack) and the actual runtime.

Code-transformation

In prefresh we use react-refresh/babel to perform this transformation for us. This will insert two methods:

  • register
  • sign

register will be inserted after every Component and will tell us what functions are declared within a file as well as their name.

Imagine the following code fragment:

const App = () => {
  const [count, setCount] = useState();
  return (<p>{count}</p>)
}

Here the babel plugin would insert register(App, 'App'). This helps us build up a registry of components which we can identify by file, ...

The sign function is a higher-order-function which will be used to create an instance for every component within the file. This instance will be used to calculate a unique implementation signature for a component or custom-hook.

So for instance a Component with a custom-hook will create a signature for that custom-hook and will also sign that custom-hook. This way we can see when changes happen to either of these.
The component changes arguments it passes to the custom-hook? The signature has changed.
The implementation of the custom-hook changes? The signature changed.

When the signature changes drastically we can't preserve the state of the component which is being swapped out, this could result in undeterministic behavior.

Here's an example illustrating this transformation.

Bundler

In the code-transformation part we saw that we utilised two functions: sign and register, these aren't just magically available. We need to provide them to our modules, this is the responsibility of the bundler. The bundler has an additional responsibility and that's hot-module-reloading itself, this is mostly available in dev-servers like webpack-dev-sever or the webpack HMRPlugin.

To achieve providing sign and register we'll have to inject code into every module, this code has to safely reset itself so we don't leak into other modules.

const prevRefreshReg = self.$RefreshReg$;
const prevRefreshSig = self.$RefreshSig$;

self.$RefreshSig$ = () => {
  return (type, key, forceReset, getCustomHooks) => {
    // Call runtime with signed component
  };
};

self.$RefreshReg$ = (type, id) => {
  // Register Component in runtime
};

try {
  // Here's your code, your bundler will wrap the module you provided it with.
} finally {
  // Restore to prevent leaking into the next module.
  self.$RefreshReg$ = prevRefreshReg;
  self.$RefreshSig$ = prevRefreshSig;
}

Now we've ensured that the code injected by the babel-plugin actually calls a valid function.

There's a bit more that we need to do inside of this plugin and that's react to hot-updates. In our case we only want to have files that contain Components hot-reload since these are the only ones our runtime will be able to react to.

This comes down to injecting:

    if (module.hot && hasComponents(module)) {
        const previousHotModuleExports =
            module.hot.data && module.hot.data.moduleExports;

        if (previousHotModuleExports) {
            try {
                    runtime.flushUpdates();
            } catch (e) {
                    self.location.reload();
            }
        }

        module.hot.dispose(function(data) {
            data.moduleExports = __prefresh_utils__.getExports(module);
        });

        module.hot.accept(function errorRecovery() {
            require.cache[module.id].hot.accept(errorRecovery);
        });
    }

You might wonder why we aren't wrapping custom-hooks in these HMR-boundaries, this is because HMR has a concept of bubbling. When we save on a custom-hook it will bubble up, we only use hooks inside of components so this will bubble up to all Components importing this custom-hook (or to nested custom-hooks and up to Components using that).

This connects the dots from our HMR to the runtime, but what does this runtime actually do. How does the virtual-dom allow us to manipulate HMR?

Runtime

Now that we're getting to the final part we're straying away a bit from how React handles this runtime. This runtime is specific to Preact and won't be a 1:1 mapping with how React does it.

A first thing to understand is that the Components we've been wrapping in the above examples don't map to one virtual-node, they map to several since a component can be used more than once. This means that inside of our runtime we need a way to track which Component maps to which virtual dom-nodes.

In Preact specifically we have a concept of option hooks (yes Marvin the secret is out). In our case we can use the vnode option which will fire every time Preact creates a virtual dom-node. All of these nodes have a property called type which represents a function signature and this function signature is what we've been wrapping in all of the above, the Component. This means that now we have a way to map a Component to an array of Virtual dom-nodes.

This actually means that we already have a lot since every time we hot-reload we'll see a set of register calls, these calls imply modules that are being hot-reloaded. All that's left at this point is a flush.

A flush means that we'll observe all these register calls, get the Components. All of these Components map to a set of Virtual dom-nodes, we can iterate over these and swap out their current .type for the new one, this ensures the vnode will use the new component-code. When we've swapped these old implementations out we can check whether or not this component has changed signature and reset the hooks-state accordingly. Finally we'll call the infamous forceUpdate method and see the new result on our screen.

Concluding

I hope you've enjoyed this insight into fast-refresh, please ask any questions you like on Twitter or here in the comments.

You can find all Prefresh integrations here.

Posted on by:

jovidecroock profile

Jovi De Croock

@jovidecroock

Belgium - 1995 - Preact & urql core-team - maker of Prefresh - JavaScript/TypeScript - OSS enthusiast

Discussion

pic
Editor guide