DEV Community

naugtur
naugtur

Posted on

Additive changes to your cjs exports are now breaking changes.

The title is a bit of a clickbait, but it's not false! :D

So you're providing a module with a very useful utility function in even.cjs version 1.0.1 (improved performance since 1.0.0!)

module.exports = {
  even: n => n % 2 === 0
};
Enter fullscreen mode Exit fullscreen mode

A faithful consumer comes in and uses it in their app.mjs

import * as utils from './even.cjs'; 
utils.even(13) // false
Enter fullscreen mode Exit fullscreen mode

Looks good. Time for feature creep.

A contributor comes and makes a pull request to add a very helpful field allowing them to look up the version at runtime. (jQuery has it, it must be good.)

module.exports = {
  version: '1.1.0',
  even: n => n % 2 === 0
};
Enter fullscreen mode Exit fullscreen mode

Lovely, thanks for the contribution! Published as v1.1.0!

1 hour later:

Issue: You broke my app! TypeError: utils.even is not a function

Wait, what?

Yeah, the named export is gone.

If we switch to named imports, the error message is more helpful:

import { even } from './even.cjs';
         ^^^^
SyntaxError: Named export 'even' not found. The requested module './even.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:

import pkg from './even.cjs';
const { even } = pkg;
Enter fullscreen mode Exit fullscreen mode

Here's the before and after for all 3 cases.

// import * as utils from './even.cjs';
    [Module: null prototype] {
      default: { even: [Function: even] },
      even: [Function: even]
    }
// import { even } from './even.cjs';
    [Function: even]
// import utils from './even.cjs';
    { even: [Function: even] }
Enter fullscreen mode Exit fullscreen mode
// import * as utils from './even.cjs';
    [Module: null prototype] {
      default: { version: '1.1.0', even: [Function: even] }
    }
// import { even } from './even.cjs';
    ERROR
// import utils from './even.cjs';
    { version: '1.1.0', even: [Function: even] }
Enter fullscreen mode Exit fullscreen mode

But why??

When you import modules, you're supposed to get the exports without actually running the code. For ESM modules the limitation to how export keyword can be used it's fairly easy and quick to provide a complete and correct list without fully parsing and tracing the execution of the entire file. It's not possible for CommonJS.

BTW, that's why it took so long to provide ESM in Node too - figuring out ESM<->CJS crossover was difficult.

So to get importing CommonJS to work at all, a good-enough pass through the cjs file is made to try and detect exports without running the code.

It ships with Node.js: https://github.com/nodejs/node/tree/fdf625bae8f8b29a25cbd7c8a5d8220af6292cea/deps/cjs-module-lexer

The readme there offers a few cases where the lexer bails out of listing exports, but I think the tests are better at documenting it precisely.

This is the exact test explaining what we're seeing here:
https://github.com/nodejs/cjs-module-lexer/blob/main/test/_unit.js#L532

Why do I know all this? Well, I'm trying to provide a consistent cjs import implementation in a different engine's import implementation. And now I have to fake the differences even though I could provide more reliable exports listings.

Oh, and guess what - this differs between Node, webpack, parcel etc.

What should a package maintainer do?

  1. Be aware of this :)
  2. Look into exports field in package.json - it might help
  3. Consider wrapping your package in a .mjs that just reexports things under the right names if you could be affected.

Top comments (1)

Collapse
 
naugtur profile image
naugtur

Bonus: running prettier on the original snippet adds braces around the argument (n) which is enough to get lexer to bail out and even is no longer a named export :)