Suggestion: Bundling in v17, ESM, CJS, and the dual package hazard #4062
Description
Submitting here as an issue to reference it in the next WG agenda.
Some historical context puzzle pieces:
- deploy full ESM with "type": "module" in "latest-esm" npm tag #3361 painted a way towards a
graphql-esm
package and alatest-esm
tag on thegraphql
package itself - Switch NPM package to support only ESM #3552 - based on GraphQL-JS-WG decision graphql/graphql-js-wg@main/agendas/2022-03-30.md the approach from deploy full ESM with "type": "module" in "latest-esm" npm tag #3361 was then adopted for
graphql
v17. - I am probably missing a bit of history here, because from 17.0.0-alpha.1 to 17.0.0-alpha.2, the CJS<->ESM split seems to have been reintroduced by merging deploy full ESM with "type": "module" in "latest-esm" npm tag #3361 and going "two packages" again.
There was some discussion in Provide a clear path on how to support graphql-js 16 and 17 at the same time as a esm and cjs library author #3603
Unfortunately, there isn't a lot of documented discussion and I couldn't find any meeting notes for the above graphql-js-wg meeting.
Status Quo
With many tools like Vitest that try to use ESM (but also support CJS), the dual-package hazard still seems to be a real problem.
Many packages depending on graphql
are still CJS only, so as soon as you try to start in an ESM world (which Vitest does) and import the wrong dependency, you end up in a mix of CJS and ESM, and the instanceof
checks break on you.
That makes graphql
very hard to use in modern setups.
Proposed solution
I want to suggest a bundling/publishing scheme that @Andarist is successfully using for many packages he maintains, e.g. XState
or react-textarea-autosize
.
Here's an example entrypoint from https://unpkg.com/browse/[email protected]/package.json
{
"main": "dist/xstate.cjs.js",
"module": "dist/xstate.esm.js",
".": {
"types": {
"import": "./dist/xstate.cjs.mjs",
"default": "./dist/xstate.cjs.js"
},
"development": {
"module": "./dist/xstate.development.esm.js",
"import": "./dist/xstate.development.cjs.mjs",
"default": "./dist/xstate.development.cjs.js"
},
"module": "./dist/xstate.esm.js",
"import": "./dist/xstate.cjs.mjs",
"default": "./dist/xstate.cjs.js"
},
}
Let's zoom in for a second:
{
".": {
"module": "./dist/xstate.esm.js",
"import": "./dist/xstate.cjs.mjs",
"default": "./dist/xstate.cjs.js"
}
}
Here, module
would be picked up by bundlers, and import
/default
would be picked up by runtimes like node
.
- xstate.esm.js is an ESM module. It will be picked up by bundlers for both
import
andrequire
- they don't care. - xstate.cjs.js is a CommonJS module as you might expect - this would be picked up by
require
calls innode
. - xstate.cjs.mjs is the actually interesting one. It's ESM, but it only re-exports the CommonJS module. It will be picked up in
node
forimport
calls and its contents are:
export {
Actor,
// ...snip...
waitFor
} from "./xstate.cjs.js";
This ensures that bundlers can pick up a modern ESM build, but in runtime environments like node
where the dual-package hazard exists, only one variant of the package exists. With current limitations, that is the CJS build.
I would suggest that we go forward with a packaging scheme like this for v17, as it could solve the dual package hazard, not lock out CJS users and enable gradual adoption of ESM (currently this is very painful and probably holding back a bunch of users from making the switch).
Once ESM is more of an option, v18 could still be ESM only.
From my understanding, the exports
field has not been highly adopted when the current decisions around ESM were made, but by now, it is pretty widely supported.
- I would volunteer to implement this solution.
PS:
A nice side effect of introducing an exports
field would be that we could also use development
and production
conditions, making a process.env.NODE_ENV
check necessary only in a fallback import that would be used if these conditions are not supported by a consumer.
That would be follow-up work, though.