Skip to content

Suggestion: Bundling in v17, ESM, CJS, and the dual package hazard #4062

Open
@phryneas

Description

Submitting here as an issue to reference it in the next WG agenda.

Some historical context puzzle pieces:

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 and require - they don't care.
  • xstate.cjs.js is a CommonJS module as you might expect - this would be picked up by require calls in node.
  • 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 for import 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.

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions