Making webpack bundled application display loading progress bar.
Tags: webdev, javascript, webpack
Published: 2020-12-21 15:49:11

---
description: In this article I share how to write tiny bootloader which loads webpack bundled application assets and calculates actual progress of loading and how to write webpack plugin to compile and inject our bootloader.
---
When the size of my single page application (SPA) got big enough to notice blank page before the app gets fully initialized, I decided to add a progress bar which would display the actual state of application loading. And I want to share my experience I got trying to implement this.

In this article I used create-react-app to generate demo app to work with. If you have different boilerplate the output it produces might be slightly different but do not worry the idea remains the same.

## Formulation of the problem
Let's take a look at index.html after webpack has compiled the app.
```html
<head>
  ...
  <link href="/static/css/main.ab7136cd.chunk.css" rel="stylesheet">
</head>
<body>
  ...
  <script src="/static/js/runtime-main.c94b6d8d.js"/>
  <script src="/static/js/2.33779fb9.chunk.js"/>
  <script src="/static/js/main.2aab9359.chunk.js"/>
</body>
```
Here are CSS files and JS files HtmlWebpackPlugin has injected, let’s call them assets. In order to get a current value of application loading progress we have to divide sum of assets that have already been loaded by total size of all assets.
```
progress = sum (loaded assets) / total size of assets
```
And there is no way to get information from the browser how many script files have been loaded and what are their sizes. I need to modify the output of HtmlWebpackPlugin the way I will know the size of each asset.

All I need to do is:

* Get the size of each asset after compilation and inject this information into index.html
* Write tiny JS bootloader which will use the prepared data from the previous step and load the assets in correct order and update the progress

The only way I could think of how to implement this is by writing a webpack plugin. Here is a very helpful webpack [documentation](https://webpack.js.org/api/compilation-hooks/) and basic webpack [plugin architecture](https://webpack.js.org/contribute/writing-a-plugin/). Let’s get to it.

## Getting and injecting assets meta data
Somehow, I need to get information about which assets will be injected into index.html and get its sizes. Poking around in the source codes of webpack and HtmlWebpackPlugin, I found out that webpack compilation instance has a property `assets: CompilationAssets`
```typescript
type CompilationAssets = {
  [assetName: string]: Source
}
```
I can get the size of an asset by its name.
```typescript
compilation.assets['static/js/main.2aab9359.chunk.js'].size();
```
and HtmlWebpackPlugin has a hook `beforeAssetTagGeneration`. First argument of the hook has a property `assets: Assets`
```typescript
type Assets = {
  publicPath: string,
  js: string[],
  css: string[],
  manifest?: string,
  favicon?: string
}
```
Fields **js**, **css** contain absolute paths to the files HtmlWebpackPlugin will inject into index.html. This is exactly what I need to create assets meta data in format that is convenient to use in bootloader. Here is the code:
```javascript
const htmlAssets = {
  js: [],
  css: []
};

compiler.hooks.thisCompilation.tap('BootloaderPlugin', (compilation) => {
  const hooks = this.htmlWebpackPlugin.getHooks(compilation);
  hooks.beforeAssetTagGeneration.tap('BootloaderPlugin', ({assets}) => {
    const collectFunc = (src, result) => {
      const scriptName = src.replace(assets.publicPath, '');
      const asset = compilation.assets[scriptName];
      // add asset record to the result
      result.push({
        file: src,
        size: asset.size()
      })
    }
    assets.js.forEach(src => collectFunc(src, htmlAssets.js));
    assets.css.forEach(src => collectFunc(src, htmlAssets.css));
  });
  hooks.alterAssetTags.tap('BootloaderPlugin', ({assetTags}) => {
    // remove all scripts and styles
    assetTags.scripts = [];
    assetTags.styles = [];
    // transform the result into javascript code and inject it
    assetTags.scripts.unshift({
      tagName: 'script',
      innerHTML: `window.$bootloader=${JSON.stringify(htmlAssets)};`,
      closeTag: true
    });
  });
});
```
And the result this code produces is:
```html
<script>
window.$bootloader = {
  js: [{
      file: "/static/js/runtime-main.c94b6d8d.js",
      size: 2368
    }, {
      file: "/static/js/2.33779fb9.chunk.js",
      size: 131272
    }, {
      file: "/static/js/main.2aab9359.chunk.js",
      size: 1242
    }
  ],
  css: [{
      file: "/static/css/main.ab7136cd.chunk.css",
      size: 943
    }
  ]
}
</script>
```
This code declares variable `$bootloader` in global scope so I can use it in the bootloader.

##Bootloader
There are two strategies how to load the files. The first one is to use native `script/link` html element mechanism. Browsers help to track the loading of scripts add css, `onload` and `onerror` events are there for that purpose.
**Pros**
* Nothing changes in the application, works like a charm.

**Cons**
* `script`, `link` html elements don't have `onprogress` event, in other words `onload` event triggers only when script has been completely downloaded. Therefore the smooth movement of the progress bar will depend on how many files you have and what size they are.

The second strategy is to use `XMLHttpRequest`.
```javascript
downloadFileWithXHR(url)
  .then(blob => {
    const tag = document.createElement("script");
    tag.type = "text/javascript";
    tag.src = URL.createObjectURL(blob); //<- here is the trick
    document.head.appendChild(tag);
  });
```
`URL.createObjectURL` gets `Blob` and creates url like `blob:http://localhost:5000/0ba54ca4-2251-4d67-aa65-b3899c61c2f8` and everything works fine. But the first problem I encountered with is the browser could not find source maps. That is because the original filename is `/static/js/main.2aab9359.chunk.js` has base url `/static/js/` and the last line in the file is
```
//# sourceMappingURL=main.2aab9359.chunk.js.map
```
That means the source map file url is `/static/js/main.2aab9359.chunk.js.map` but the browser tries to get `/main.2aab9359.chunk.js.map` because the base url has become `/`.
**Pros**

* Constantly triggers `progress` event when downloading file which causes the progress bar moving smoothly.

**Cons**

* Does not support source maps or you have to move them into the root of the homepage.
* All paths in the code have to be relative to the root of the homepage.

I have implemented both types of the bootloaders but in production use only the first one.

## Bootloader compilation and injection
I want my bootloader compile by the same compilation process as the whole application code.
```javascript
compiler.hooks.entryOption.tap('BootloaderPlugin', (context) => {
  compiler.hooks.make.tapAsync('BootloaderPlugin', (compilation, callback) => {
    const entry = SingleEntryPlugin.createDependency('./src/bootloader.js', 'bootloader');
    compilation.addEntry(context, entry, 'bootloader', callback);
  });
});
```
The code creates and adds a new entry named `bootloader` with entry point `./src/bootloader.js` that means the bootloader will have its own webpack runtime. Also webpack will try to split the bootloader code into several chunks, most likely webpack runtime and main code, and I do not need it because I want to keep my bootloader as small as possible and in one file.
```javascript
compilation.hooks.afterOptimizeChunks.tap('BootloaderPlugin', () => {
  const entrypoint = compilation.entrypoints.get('bootloader');
  if (entrypoint) {
    const newChunk = compilation.addChunk('bootloader');
    for (const chunk of Array.from(entrypoint.chunks)) {
      if (chunk === newChunk) continue;
      // move all modules to new chunk
      for (const module of chunk.getModules()) {
        chunk.moveModule(module, newChunk);
      }
      // delete empty chunk
      entrypoint.removeChunk(chunk);
      const index = compilation.chunks.indexOf(chunk);
      if (index > -1) {
        compilation.chunks.splice(index, 1);
      }
      compilation.namedChunks.delete(chunk.name);
    }
    entrypoint.pushChunk(newChunk);
    entrypoint.setRuntimeChunk(newChunk);
  }
});
```
Here I tap into `afterOptimizeChunks` hook and make all optimizations I need. First I create a new chunk named `bootloader` but most likely it was created when I added bootloader entry and therefore webpack will just return an existing one. Next I iterate over all bootloader chunks and move all modules from them to the new chunk and then remove now empty chunks. Eventually all modules will be in one chunk including webpack runtime code. It will keep the bootloader size about 4Kb.

Now I need to replace the application assets in index.html with bootloader's ones.
```javascript
const hooks = this.htmlWebpackPlugin.getHooks(compilation);
hooks.alterAssetTags.tap('BootloaderPlugin', ({assetTags}) => {
  const entrypoint = compilation.entrypoints.get('bootloader');
  if (entrypoint) {
    const bootloaderFiles = entrypoint.getFiles();
    assetTags.scripts = assetTags.scripts
        .filter(tag => this.isBootloaderScript(tag, bootloaderFiles))
        .map(tag => this.inlineScript(publicPath, compilation.assets, tag));
    assetTags.styles = assetTags.styles
        .filter(tag => this.isBootloaderStyle(tag, bootloaderFiles))
        .map(tag => this.inlineStyle(publicPath, compilation.assets, tag));
    // removing bootloader files from assets so webpack will not emit them
    bootloaderFiles.forEach(filename => delete compilation.assets[filename]);
  }
});
```
As the bootloader now loads all the application assets itself I don't need `HtmlWebpackPlugin` inject them in index.html so I filter them out and leave only bootloader files. Also I decided to build in the bootloader assets into index.html.

## Splash screen
Here you can do everything imagination is capable of. I just decided to cover the app root DOM node with splash screen with logo and progress bar.
```html
<body>
  <div id="root"></div>
  <div id="bootloader-container">
    <div class="logo">AWE <span>SOME</span> APP</div>
    <progress id="progressbar" value="0" max="1"/>
  </div>
</body>
```
And just remove `bootloader-container` when application gets ready.
```javascript
useEffect(() => {
  const container = document.getElementById("bootloader-container");
  container?.parentNode?.removeChild(container);
}, [])
```
This side effect hook is just a `componentDidMount`.

## Result
Here is the bootloader based on `script/link` `onload` event.

![Tag bootloader](https://dev-to-uploads.s3.amazonaws.com/i/wssfzf3cx9qkor2ijpt6.gif)

After loading 3 small files, the progress bar freezes and waits until the last largest file is loaded. If your application has more files about the same size the movement of the progress bar will be more even.

This is how `XHR` bootloader works like.

![XHR bootloader](https://dev-to-uploads.s3.amazonaws.com/i/ebydgi3e4e27poby4te8.gif)

It works much nicer but as I said before has its shortcomings.

Source code is available at:
{% github rasentry/spa-bootloader no-readme %}

**Please comment if you have any feedback or suggestions**