Conditionally load multiple Polyfills using Webpack, Promises and Code Splitting

For my latest react project, I wanted to be able to polyfill certain features which are missing in older browsers, yet not include them in my final code bundle if the browser the user is using already supports that feature.

This was a great problem for webpack's code splitting feature to solve!

After a quick google, I found a post from Ian Obermiller which set me on the correct path, however the post didn't expand on how to load multiple polyfills conditionally.

For example:
  • Safari is missing the Intl api (at the time of writing) - I wanted to be able to polyfill this.
  • Slightly older versions of Firefox did include the Intl api, but are missing the newer JS Symbol data type. I should only include the Symbol polyfill in this case.

Proposal

By wrapping each browser feature check in a promise, requiring the polyfill if needed, and then waiting for all promises to resolve before initializing my main app, I would be able to get the functionality I desire.

Implementation

1. Wrap your React app in a function so it can be called after all promises have resolved:

import React from 'react';
import App from './App';

function initialize() {
  React.render(<App />, document.body);
}
2. Create an array of different features you'd like to test for

const availablePolyfills = [
  {
    test: () => !global.fetch,
    load: () => {
      return new Promise(resolve => {
        require.ensure([], () => {
          resolve({
            fetch: require('whatwg-fetch')
          });
        }, 'polyfills-fetch');
      });
    }
  },
  {
    test: () => !Object.assign,
    load: () => {
      return new Promise(resolve => {
        require.ensure([], () => {
          resolve({
            'object-assign': require('core-js/fn/object/assign')
          });
        }, 'polyfills-obj-assign');
      });
    }
  },
  {
    test: () => !global.Symbol,
    load: () => {
      return new Promise(resolve => {
        require.ensure([], () => {
          resolve({
            symbol: require('core-js/fn/symbol'),
            'symbol-iterator': require('core-js/fn/symbol/iterator')
          });
        }, 'polyfills-symbol');
      });
    }
  }
];
There are a few things going on here:

  • Each block has a tet function and a load function
  • We'll be using the test function to see if we need to download the polyfill to the browser
  • Within the load function, we're returning a Promise - We'll use this to wait for all promises to resolve before loading our main app.
  • Within each promise, we have a require.ensure block - as WebPack analyzes our code, this will tell it to split this specific code block into it's own chunk. That way it won't be included in our main app, and we can download it conditionally.
  • Finally we resolve the promise with an object of requires - the key doesn't really matter. You will have to npm install any polyfills you might want to use. Here, I'm using whatwg-fetch and core-js.

3. Test the browser for the necessary features, and wait for all promises to resolve

import Promise from 'bluebird';

export default function loadPolyfills(initialize) {
  if (availablePolyfills.some(polyfill => polyfill.test())) {
    let polyfillFns = [];

    availablePolyfills.forEach(polyfill => {
      if (polyfill.test()) {
        polyfillFns.push(polyfill.load());
      }
    });

    Promise.all(polyfillFns).then(() => initialize());
  } else {
    // load without polyfills
    initialize();
  }
};
Here, I am testing that at least one polyfill needs to be loaded. If it does, I push the promise to an array, and by using Promise.all from Bluebird, I wait for all promises in the array to resolve, before calling my initialize function (which loads the react app!)

And that's it! On compiling my code through webpack, I get my desired code splits:

               Asset       Size  Chunks             Chunk Names
 polyfills-symbol.js    14.3 kB       3  [emitted]  polyfills-symbol
  polyfills-fetch.js    12.8 kB       2  [emitted]  polyfills-fetch
  polyfills-assign.js   12.8 kB       4  [emitted]  polyfills-obj-assign
			  app.js     353 kB       1  [emitted]  app
polyfills-intl-en.js    54.3 kB       5  [emitted]  polyfills-intl-en
and my polyfills are only loaded when needed, and before I run my main app.

Let me know if you've found a better way!

Comments

Microcipcip commented on

Would this approach work with Babel Runtime promises, or it works only with bluebird?

Anuj Nair commented on

I believe it would!

Under the hood, it looks like if you are to polyfill Babel functionality, it internally references the core-js library, using the shim.js file. This shim.js file polyfills Promises, and it looks to include a function for Promise.all, as I've referenced above. You can see the function in Github here.

Microcipcip commented on

Thanks for your reply. I have successfully implemented your solution and it works great for sync tests, however I have found out that for async tests (for example APNG) it does not work properly. Do you know how I should modify your solution for working with async tests? https://github.com/Modernizr/Modernizr/blob/master/feature-detects/img/apng.js
Modernizr.on('apng', function(result) {
  if (result) {
    // supported
  } else {
    // not-supported
  }
});

Anuj Nair commented on

If you're using bluebird as I have done above, you can use the Bluebird Promisify function to turn your callback into a Promise and wait for the promise to resolve! The documentation has a few examples on how to do that on the bluebird website.

Finch commented on

Hi, there. Got question. Since you're passing empty array of dependencies into require.ensure,
how those polyfills get into bundle? When I try it I get runtime error: 'Error: Cannot find module 'core-js/fn/promise' in very old Safari (which I want to polyfill)

Anuj Nair commented on

The dependency array is a list of modules which are specifically needed to run the callback. See the Webpack documentation on code splitting for more info there.

Using require.ensure itself will split that small polyfill into its own code split point when webpack compiles our files. When your code then runs in the browser, the code will test to see if Promise is available, and if not, include the split file and run the code which is in it.

All we have to then make sure is that Promise is being set into the global namespace in the library that we are including.

If you are seeing that error, please make sure that core-js is in your node_modules directory, and that you have resolved the node_modules directory in your webpack resolve.modulesDirectories array. See here for more configuration information on how to do that.

David Gilbertson commented on

How do you go about polyfilling things like Array.prototype.includes or Object.keys()? Would you test for each of these features one-by-one? I feel like I'd have to list 100 things here and run the risk of missing something.

Anuj Nair commented on

If you have a lot of things you need to test for and potentially polyfill, you can use the babel polyfill package which runs through a load of ES6 checks, and polyfills any missing functionality. To do so, check out the Babel Polyfill docs.
Comments have been closed for this post