Skip to content

Instantly share code, notes, and snippets.

What would you like to do?

Adding an ES Module entry point to your node module is potentially a breaking change

If you've published a bunch of node modules to npm like I have, this note is for you.

tl;dr: If you're adding an ES Module entry point to your node module ("module" in package.json), and you previously exported a single thing from the CommonJS entry point (module.exports = …), you need to mark the change as a major version bump in semver, or you'll get a lot of angry webpack users.

Okay, so, recently I've been doing some minor maintainance to my little constellation of node modules, and one of the things I was doing was adding a "module" entry point to package.json, and using microbundle to expose an ES Module alongside CommonJS and UMD bundles. This leads to a bunch of fun benefits linked in the aforementioned blog post.

My gut was that this addition to the modules constituted a minor bump to their versions - I try to follow semver. Minor bumps are for new, backwards compatible functionality. Per the spec:

Minor version Y (x.Y.z | x > 0) MUST be incremented if new, backwards compatible functionality is introduced to the public API.

Unfortunately, the whole endeavor of telling the difference between a breaking change (a non backwards-compatible change) and a non-breaking change is more of a philosophical and introspective one than, say, science. For example, what if you use in a module that hasn't used it yet - is it a breaking change it might break IE7 when IE7 was previously supported? The pedantic answer is that you also document the full range of browsers you support, but who does that, and who has such a robust rig that they know when one breaks? What about introducing new functionality that might make the module incompatible with one of the hobbled browser environments, like a Service Worker? And don't get me started about patch releases, which are intended to be used as 'bugfixes'.

Anyway, in my thought experiment, I assumed that bundlers and things that were consuming my modules using the main entry point would continue to, and if someone uses import, then they can now consume the modules via that and the ES Module entry point.

Unfortunately I was wrong.

I was quite peeved with Webpack when I found out for the first time, but I was very wrong. Webpack 4 uses the module entry point even if someone's using require(), as long as they're using the babel loader. This is a very common configuration. It's what Create React App does.

Now, this might be okay in some cases. Webpack runs a bunch of magic to translate between ES Module and CommonJS exports. Unfortunately, the magic is incomplete. If your CommonJS module looks like:

module.exports = () => 42;

Then it's likely people are requiring it and running it:

let fn = require('your-module');

And then you add a module entry point to package.json that looks like:

export default () => 42;

What webpack can't do is handle this case. What people will get, instead of a function they can call, is an object that looks like:

  default: () => 42

And they'll be angry. This is especially bad news because Webpack's preference for ES Modules applies to dependencies of your dependencies.

So, moral of the story is that adding an ES Module entry point to your npm modules is probably a major version bump in semver.

Why does webpack do this? Mostly because otherwise it'll lead to multiple copies of the same module being bundled in the same application bundle. Which is a totally reasonable and good goal. Many thanks to Tobias Keppers, the author of webpack, for gently explaining the rationale to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.