Duplicated ESM and CJS package in bundle
February 18, 2024
February 18, 2024
Note: for context we’re in a Next.js TypeScript project, using Webpack as a bundler, but I could see this happening with similar tools.
The problem occurred with the firebase
package, but again that could happen with other packages.
So we upgrade the Firebase SDK by a few minor versions, and suddenly, our JS bundle size blows up. Like, 50 kB more of (gzipped) JS shipped on every page. Not good.
Luckily we have tests to catch this kind of thing.
Further investigation
shown that we were shipping @firebase/app and @firebase/auth twice. 🤔
We use next-firebase-auth
to integrate Firebase Auth with Next.js. next-firebase-auth imports
specifically firebase/app and firebase/auth.
In our own code, we use import to import our dependencies:
import { getApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'
But next-firebase-auth, while they do the same in their TypeScript source code, is actually bundled down (also with Webpack) to a CJS file.
The code is minified, but you can see it uses require:
324:e=>{e.exports=require("firebase/app")},610:e=>{e.exports=require("firebase/auth")}
The problem is that the version of the Firebase SDK we upgraded to
contains this PR,
that makes @firebase/auth export both ESM and CJS variants of their
browser bundle, whereas before they only exposed the ESM version for
the browser.
Concretely, this means that before this PR, the package.json of
@firebase/auth looked like:
{
"exports": {
".": {
"default": "./dist/esm2017/index.js"
}
}
}
And after:
{
"exports": {
".": {
"default": "./dist/esm2017/index.js",
"browser": {
"require": "./dist/browser-cjs/index.js",
"import": "./dist/esm2017/index.js"
}
}
}
}
Because initially there was no browser entry, Webpack picked the
default value for both import and require, which turns out to be
the ESM bundle.
However after that change, we now have a different bundle configured
depending if it’s imported with import or require. As
documented
Webpack will map import calls to the file under import in the
package.json, and require to the require field, which makes sense.
However this is a problem for us as we saw earlier, we use import in
our own codebase, but the distribution bundle of next-firebase-auth
(like probably many other packages in the ecosystem) only comes with a
CJS file using require.
This means our own code will use @firebase/auth/dist/esm2017/index.js,
while next-firebase-auth will use @firebase/auth/dist/browser-cjs/index.js.
Not only this increases our bundle size unnecessarily, but it breaks the Firebase SDK as it depends on shared global state, and now different parts of the codebase point to a different, isolated version of the SDK.
import and require.import and require calls to the matching field in package.json.It’s just a result of the giant fracture in the ecosystem between CJS and ESM imports. It’s probably for the best, and I look forward to ESM being widespread enough that we don’t encounter those problems, but the transition is long and painful. It’s been 3-4 years I’m dealing with this kind of issues as a package maintainer, and they tend to be particularly time consuming, and takes away time to fix real problems or implement new features.
As far as I’m concerned, for that particular instance of this problem,
the solution was to configure Webpack to alias firebase/app and
firebase/auth (the parts of the Firebase SDK used by
next-firebase-auth) to their ESM bundle, so this same bundle gets used
regardless if imported with import or require.
In the Webpack config:
module.exports = {
resolve: {
alias: {
'firebase/app': require.resolve('firebase/app').replace('index.cjs.js', 'index.mjs'),
'firebase/auth': require.resolve('firebase/auth').replace('index.cjs.js', 'index.mjs')
}
}
}
It’s something we’ll have to maintain as we update the Firebase SDK, if
they were to change the layout of their distribution files, since this
doesn’t bother parsing the package.json exports field, but it’s good
enough.
For reference, a related GitHub issue and discussion.
I’ve also tried using
resolve.conditionNames
as follows, as a more generic fix to force all packages to use the ESM
build if present:
module.exports = {
resolve: {
conditionNames: ['import', 'default']
}
}
This would have been great as it would prevent similar (but maybe less
noticeable) duplication issues to happen in the dependency graph,
however, as you can expect, this will break some packages (in my case
some @babel/runtime imports), so I couldn’t go with that.