CP Lepage

I like web development

Hot Module Replacement in a nutshell

Aw man. How awful is it to reload your browser or restart a process to view your changes? Most of us uses modern frameworks in which HMR is well implemented, so this is not a problem. Although, for those dragging legacy code, HMR might not be working. Even to this day, it works for specific project setups.

Since we entered the current era of ESM, the token word import is the most repeated sound in JS land. Modules are are very good for code reusability, readability and organization. They are one of the best feature to bet on to make a simple HMR implementation. Although, when starting to import from the infamous node_modules directory, stuff gets reel.

Let's dig into how HMR works

Say you have a typical JSX type of web app. You use React and some other external modules. The diagram of imports would look like something like this.

Now the browser should load everything in this precise order and reuse modules. Just like images, if the URL doesn’t change, the browser reuses. So we’ll take advantage of a query parameter ? to force the reload of a modified module. To do so, we need to:

  1. Parse all the import statements from our project files.
  2. Separate the external modules and bundle them.
  3. Transform all import statements into dynamic async import() with a global function that wraps our module name string. It will allow us to control the ?v= parameter.
import defaultExport from "./module"

becomes something like

const module0 = await import(window.getModulePath("./module"));
const defaultExport = module0.default;
  1. Run a file watcher that watches every files from our project we walked through.
  2. Start our app with an extra snippet that will connect to our watching server. Something like using a websocket.
  3. On file change event, invalidate the modified module and recursively invalidate its parents up to the app entry point.
const versions = {
    "./module": 1
}

window.getModulePath = path => path + "?v=" + versions[path];

ws.onmessage = ({data}) => {
    versions[data]++;
    await import(entryPoint + "?v=" + Date.now());
}
  1. Re-run the entry point. With the invalidated modules, those module will be reloaded to the new version and every non-affected modules will simply be reused.

With this implementation, there are many places where undesired side effects can happen, so be prepared to make a few fixtures. But it’s simple, straightforward and works with simple transformation and bundling.

Checkout this small repo implementation with esbuild

Or try it out
Open in StackBlitz

You can also try my FullStacked implementation
Open in StackBlitz