DEV Community

Cover image for Handle loading errors & fallback with HtmlWebpackPlugin
Igor Bykov
Igor Bykov

Posted on • Edited on

Handle loading errors & fallback with HtmlWebpackPlugin

TL;DR

If you use HtmlWebpackPlugin & sometimes things go wrong while your bundles are loading, we’ve got you covered.


Errors happen.

This is particularly true about programming. However, when errors happen before the code of your program even had a chance to execute, this is somewhat surprising & might be extremely tricky to handle.

This is exactly the problem I had to deal with recently, and it seems likely that this might be a very common issue in client-side web apps.

The error I have seen looked like this:

Uncaught SyntaxError: Unexpected token '<'
Enter fullscreen mode Exit fullscreen mode

After some research, it turned out that the scenario when the error happened was something like this:

  • A user’s browser caches the page on the initial visit to the website. User doesn’t visit the site again until the day X
  • We actively develop the website & make releases meanwhile
  • Each new release adds a bundle with a unique hash to the server
  • We store several latest releases on the server, however, since server resources are limited, with each new release coming in, we erase the oldest release
  • It is day X and the user with the cached version of the page happily comes in
  • User’s browser tries to fetch bundle.[too-old-hash].js but it doesn’t exist on the server since we already made several deploys and this old release was erased
  • The server subsequently responds with 404 which is an HTML page
  • JS compiler cannot parse HTML & throws SyntaxError
  • Our application would normally be rendered with React on client-side but since there is no bundle, the user sees a blank page

So, how do you handle an error that happens due to the fact that your entire application is unavailable? Below I will show a possible frontend-only solution.

If you prefer to code along, you can find sample repo with all setup but no solution implemented here.

Setup

We use webpack to compile & bundle our code and HtmlWebpackPlugin to generate an HTML page where our application will eventually live.

Our application could be whatever. This article is framework-agnostic.

Possible solutions & caveats

First of all, logically we will not be able to do anything in the bundle.[hash].js, because this file will fail to load & will be unavailable during runtime.

So, what do we do? Well, we could add some inline JS on our page. It will always be present & hence, will be able to do some handling.

Let’s create src/index.ejs which is the default place for the template used by HtmlWebpackPlugin to generate the HTML page. By creating this file, we will be able to customize the HTML skeleton of the generated page.

My first naive attempt was to add some inline JS in HtmlWebpackPlugin template to listen for error event on the app’s script tag like this:

src/index.ejs:

<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Handling script loading errors with HtmlWebpackPlugin</title>
</head>
<body>
    <h1 id="content"></h1>
    <script>
    (function(){
        function showErrorPage() {
            // Doesn't matter for now
        }

        var appScript = document.querySelector('script[src^="bundle"]');
        appScript.addEventListener('error', showErrorPage);
    })();
    </script>
    <!--
    HTMLWebpackPlugin will insert bundled script here on build.
    It will look something like this:

    <script src="bundle.foo12.js"></script>
    -->
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

However, this will not work because when inline <script>’s code is executed, <script src="bundle.foo12.js"></script> does not even exist yet in the DOM because it is located below the inline script tag and it hasn’t been parsed by the browser yet. Ok, let’s wait until the DOM is ready & once it is, let’s do the same and attach the event listener (below I will omit unchanged parts of the document for brevity):

src/index.ejs:

<script>
(function(){
    function showErrorPage() {
        // Doesn't matter for now
    }

    window.addEventListener('DOMContentLoaded', function() {
        var appScript = document.querySelector('script[src^="bundle"]');
        appScript.addEventListener('error', showErrorPage);
    });
})();
</script>
Enter fullscreen mode Exit fullscreen mode

Unfortunately, this will not work either because when the browser sees the plain script tag that tries to load our bundle, it fetches and executes the bundle straight away, then it resumes HTML parsing and once it reaches </html> it fires the DOMContentLoaded event.

Here is what it looks like graphically:

Alt Text

In this case, we attach event listener long ago fetching and execution stages are completed and our callback will never be fired.
So, it looks like we come either too early or too late.

At this stage, we could try to check the bundle’s script presence in the DOM with a very short interval or some other brute-force solution of this type.

Luckily, this is not necessary since HtmlWebpackPlugin provides us with everything to implement an elegant & efficient solution.

Solution

We clearly need to listen for loading events.
However, in order to be able to listen for loading events, we need more control over when our bundle starts to load to attach event listeners on time.

Ok, let’s overtake control.

First of all, let’s tell HtmlWebpackPlugin that we don’t want it to inject <script>‘s that we can't control into the page.

webpack.config.js:

plugins: [
    new HtmlWebpackPlugin({
        inject: false
    })
]
Enter fullscreen mode Exit fullscreen mode

Now we don’t have bundle’s <script> tag at all, so, our app will never be loaded. That’s no good, but we can create <script> tag ourselves using the information HtmlWebpackPlugin provides to us.

src/index.ejs:

<script>
(function() {
    function showErrorMessage() {
        alert('Oops, something went wrong! Please reload the page.');
    }

    // Paths of all bundles
    var bundlesSrcs = <%= JSON.stringify(htmlWebpackPlugin.files.js) %>;
    for(var i=0; i < bundlesSrcs.length; i++) {
        // Create script tag & configure it
        var scriptTag = document.createElement('script');
        scriptTag.src = bundlesSrcs[i];
        scriptTag.addEventListener('error', showErrorMessage);

        // Append script tag into body 
        document.body.appendChild(scriptTag);
    }
})();
</script>
Enter fullscreen mode Exit fullscreen mode

If you use a template HtmlWebpackPlugin will pass a variable called htmlWebpackPlugin to it. Here we access htmlWebpackPlugin.files.js which is an array that contains paths of all javascript bundles created by webpack during this run.

This strange construction “<%= … %>” is just Embedded JavaScript templating syntax for printing information into the document.
In this case, it will be resolved on the build to something like ['bundle.foo12.js'].

Once we get the array of paths, we can iterate through this array and create a <script> tag for each path.
Before inserting the newly created <script> into the document, we will attach our error listener to it. This time we attached the listener on time, so, if any error happens, it will be fired.

Note that I’m trying to use ES5-compliant syntax here because this code will not be compiled & will be shipped into the browser as it is.

Bonus: Insert images in the error message

IRL we probably want to show some nice “error page” instead of a message in browser’s alert box. Probably, we want to show an image on the error page.

There is no problem with it. Our template is flexible enough to make it possible.

First of all, let’s install file-loader that can handle images.

terminal:

npm install file-loader --save-dev
Enter fullscreen mode Exit fullscreen mode

Now let’s tell webpack to use this loader.

webpack.config.js:

module: {
    rules: [
        {
            test: /\.(png|jpe?g|gif)$/i,
            loader: 'file-loader'
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

Now we can directly require images inside our index.ejs template like this:

<%= require('./path_to_image').default %>
Enter fullscreen mode Exit fullscreen mode

Here is the full src/index.ejs file.

<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Handling script loading errors with HtmlWebpackPlugin</title>
    <style>
        html, body, h1 {
            padding: 0;
            margin: 0;
        }
        #bundleLoadingErrorContainer {
            position: fixed;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            background-color: #FFF;
            text-align: center;
            width: 100%;
            height: 100%;
        }
        .bundle_error_title {
            padding: 0 1.5%;
        }
    </style>
</head>
<body>
    <h1 id="content"></h1>
    <div id="bundleLoadingErrorContainer" style="display: none;">
        <h2 class="bundle_error_title">Oops, something went wrong. Please reload the page.</h2>
        <figure class="photo">
            <img src="<%= require('./assets/bird.jpg').default %>" width="300" height="200" alt="bird">
            <br>
            <br>
            <figcaption>
                Photo by <a href="https://unsplash.com/@photoholgic" target="_blank" rel="external noopener">Holger Link</a> on <a href="https://unsplash.com/" target="_blank" rel="external noopener">Unsplash</a>
            </figcaption>
        </figure>
    </div>
    <script>
    (function() {
        function showErrorMessage() {
            document.getElementById('bundleLoadingErrorContainer').removeAttribute('style');
        }

        var bundlesSrcs = <%= JSON.stringify(htmlWebpackPlugin.files.js) %>;
        for(var i=0; i < bundlesSrcs.length; i++) {
            var scriptTag = document.createElement('script');
            scriptTag.src = bundlesSrcs[i];
            scriptTag.addEventListener('error', showErrorMessage);

            document.body.appendChild(scriptTag);
        }
    })();
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Conclusion

Hope it was helpful! You can find all the code of the final version in this repo.

Now, we’re all done and you can test how error handler works using not so well-known Chrome’s request blocking feature.

Top comments (6)

Collapse
 
tofeeq profile image
Tofeeq Rehman • Edited

Serviceworker would be a good option to deal with such errors. Intercept the chunk-vendors call, if failed then clear the cache and reload the page by sending a msg to app. In index.html file create a message listener from serviceworker, if message is fail chunks then reload the page with clearing the cache at sw side.

Collapse
 
andreasvirkus profile image
ajv

Another option would be to use a service worker, capture the network requests (handling only the ones that failed) and sending back a message to the web app when the error matches a ChunkLoadError.

Collapse
 
igor_bykov profile image
Igor Bykov

Completely agreed, could be a good alternative 👍

Collapse
 
andreasvirkus profile image
ajv

I ended up solving it as a Webpack plugin instead (inspired by the webpack-retry-chunk-load plugin)

gist.github.com/andreasvirkus/5cc3...

PS great post! really wanted to use your solution initially but couldn't find a straight-forward way to make it work with Vue CLI

Thread Thread
 
igor_bykov profile image
Igor Bykov

Well done, really 👍

Collapse
 
mishani0x0ef profile image
mykhailo.romaniuk

Another way to handle this issue - create custom plugin that will modify results of HtmlWebpackPlugin. HtmlWebpackPlugin has tapabale hooks . So, it's possible to hook into assets tags generation and edit script tag - see my gist