Developer, Hacker, CyberSecurity Enthusiast

My Cyber Security blog about CTFs and other IT related topics.

View on GitHub
2 February 2023

Fixing ChunkLoadErrors in webpack and react.js

by Peter Stolz

ChunkLoadErrors can occur after build updates and content changes. Code Splitting and dynamic imports are often the cause. These concepts delay the fetching of code until it is needed.

If the user has an older version running in the browser, and the referenced dynamic imports are no longer present on the updated server, the main JS thread crashes when the old instance tries to import something that has changed. This results in an empty grey page.

Spotting the problems

These imports usually look like this:

export const LoginPage = lazy(() => import('../pages/auth/LoginPage'));

Under the hood, JavaScript bundlers translate these import calls to a file name which often contains a hash for caching reasons. Hence, after updating, these filenames change, and the old browser instance can’t find the (now deleted) chunks.

Almost fixing it

As this post suggests, you can create a wrapper around your import statements to check for errors. Unfortunately, when using react-app-rewired and webpack, dead imports do not trigger an error.

Therefore, the suggested fix was:

const lazyRetry = function(componentImport) {
    return new Promise((resolve, reject) => {
        // try to import the component
        componentImport().then((component) => {
            resolve(component);
        }).catch((error) => {
            // TO DO
            reject(error); // there was an error
        });
    });
};

and then rewrite your imports to:

export const LoginPage = lazy(() => lazyRetry( () => import('../pages/auth/LoginPage'));

The function lazyRetry tries to run the import statement and catches any errors. In the error handling code, you could reload the page to fix the problem, as that refreshes the main JS file. To guarantee that it is actually refreshing, have proper caching set up.

In our case, there is an error in the console with the fix from above:

e._result is not defined

However, we have yet to reach the exception handling code in lazyRetry. After some debugging, it turns out that in our case, webpack just returns undefined from unresolved imports, and there is no exception. The actual error occurs deep inside react when it tries to get the component from the Module namespace object.

Knowing this, we can alter lazyRetry to work:

const lazyRetry = function(componentImport) {
    return new Promise((resolve, reject) => {
        // try to import the component
        componentImport().then((component) => {
            if(component === undefined){
              handle_import_error()
            }
            resolve(component);
        }).catch((error) => {
            handle_import_error()
            reject(error); // there was an error
        });
    });
};

While the snipped above now works, it could have some unwanted side effects depending on the implementation of handle_import_error. If the error is not resolved after reloading, the user will end up in an infinite refresh cycle.

The solution

Now depending on the error, the user could end up in an infinite reload cycle, which is arguably worse than just a grey empty page when the react main thread crashes. Hence we can build some logic into it such that we refresh each module exactly once:

const lazyRetry = (componentImport) =>
  new Promise((resolve, reject) => {
    const storageKey = `retry-lazy-refreshed\${btoa(componentImport.toString())}`;
    const hasRefreshed = JSON.parse(window.sessionStorage.getItem(storageKey) || 'false');
    componentImport()
      .then((component) => {
        window.sessionStorage.setItem(storageKey, 'false');
        if (component === undefined) {
          window.sessionStorage.setItem(storageKey, 'true');
          return window.location.reload(); // refresh the page
        }
        resolve(component);
      })
      .catch((error) => {
        if (!hasRefreshed) {
          // not been refreshed yet
          window.sessionStorage.setItem(storageKey, 'true');
          window.location.reload();
        }
        reject(error); // Default error behaviour as already tried refresh
      });
  });

Using the toString method of the import code allows us to fingerprint each module/version combination separately, as webpack translates that into something like:

function(){return Promise.all([n.e(9418),n.e(7711),n.e(3645),n.e(602)]).then(n.bind(n,80602))}

An import to say '../pages/auth/ResetPasswordPage' is translated into a different function every build (if the contents changed), so this works even when the user’s session lasts through multiple server updates.

Wrap up

In conclusion, ChunkLoadErrors can occur after updates and content changes. Code splitting and dynamic imports are commonly the cause of this problem. One approach to resolving the issue is to add a wrapper around import statements and check for errors. However, sometimes, webpack returns undefined instead of throwing an error. To handle this, a more sophisticated solution is needed, such as using the sessionStorage API to keep track of which modules have been refreshed and limiting the refresh to once per module. By implementing these fixes, you can ensure that users no longer experience grey empty pages or infinite refresh loops due to ChunkLoadErrors.`,

tags: React - ChunkLoadError - Webpack - CodeSplitting - Code Splitting - Dynamic Imports - DynamicImports