Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

doc: esm: improve dual package hazard docs #30345

Closed
wants to merge 9 commits into from
133 changes: 76 additions & 57 deletions doc/api/esm.md
Expand Up @@ -423,9 +423,6 @@ thrown:

### Dual CommonJS/ES Module Packages
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved

_These patterns are currently experimental and only work under the
`--experimental-conditional-exports` flag._

Prior to the introduction of support for ES modules in Node.js, it was a common
pattern for package authors to include both CommonJS and ES module JavaScript
sources in their package, with `package.json` `"main"` specifying the CommonJS
Expand All @@ -434,61 +431,35 @@ This enabled Node.js to run the CommonJS entry point while build tools such as
bundlers used the ES module entry point, since Node.js ignored (and still
ignores) the top-level `"module"` field.

Node.js can now run ES module entry points, and using [Conditional Exports][]
with the `--experimental-conditional-exports` flag it is possible to define
separate package entry points for CommonJS and ES module consumers. Unlike in
the scenario where `"module"` is only used by bundlers, or ES module files are
Node.js can now run ES module entry points, and a package can contain both
CommonJS and ES module entry points (either via separate specifiers such as
`'pkg'` and `'pkg/module'`, or both at the same specifier via [Conditional
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
Exports][] with the `--experimental-conditional-exports` flag). Unlike in the
scenario where `"module"` is only used by bundlers, or ES module files are
transpiled into CommonJS on the fly before evaluation by Node.js, the files
referenced by the ES module entry point are evaluated as ES modules.

#### Divergent Specifier Hazard
#### Dual Package Hazard

When an application is using a package that provides both CommonJS and ES module
sources, there is a risk of certain bugs if both versions of the package get
loaded (for example, because one version is imported by the application and the
other version is required by one of the application’s dependencies). Such a
package might look like this:

<!-- eslint-skip -->
```js
// ./node_modules/pkg/package.json
{
"type": "module",
"main": "./pkg.cjs",
"exports": {
"require": "./pkg.cjs",
"default": "./pkg.mjs"
}
}
```

In this example, `require('pkg')` always resolves to `pkg.cjs`, including in
versions of Node.js where ES modules are unsupported. In Node.js where ES
modules are supported, `import 'pkg'` references `pkg.mjs`.

The potential for bugs comes from the fact that the `pkg` created by `const pkg
loaded. This potential comes from the fact that the `pkg` created by `const pkg
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
= require('pkg')` is not the same as the `pkg` created by `import pkg from
'pkg'`. This is the “divergent specifier hazard,” where one specifer (`'pkg'`)
resolves to separate files (`pkg.cjs` and `pkg.mjs`) in separate module systems,
yet both versions might get loaded within an application because Node.js
supports intermixing CommonJS and ES modules.

If the export is a constructor, an `instanceof` comparison of instances created
by the two returns `false`, and if the export is an object, properties added to
one (like `pkg.foo = 3`) are not present on the other. This differs from how
`import` and `require` statements work in all-CommonJS or all-ES module
environments, respectively, and therefore is surprising to users. It also
differs from the behavior users are familiar with when using transpilation via
tools like [Babel][] or [`esm`][].

Even if the user consistently uses either `require` or `import` to refer to
`pkg`, if any dependencies of the application use the other method the hazard is
still present.

The `--experimental-conditional-exports` flag should be set for modern Node.js
for this behavior to work out. If it is not set, only the ES module version can
be used in modern Node.js and the package will throw when accessed via
`require()`.
'pkg'` (or an alternative main path like `'pkg/module'`). This is the “dual
package hazard,” where two versions of the same package can be loaded within the
same runtime environment. While it is unlikely that an application or package
would intentionally load both versions directly, it is common for an application
to load one version while a dependency of the application loads the other
version. This hazard can happen because Node.js supports intermixing CommonJS
and ES modules, and can lead to unexpected behavior.

If the package main export is a constructor, an `instanceof` comparison of
instances created by the two versions returns `false`, and if the export is an
object, properties added to one (like `pkg.foo = 3`) are not present on the
other. This differs from how `import` and `require` statements work in
all-CommonJS or all-ES module environments, respectively, and therefore is
surprising to users. It also differs from the behavior users are familiar with
when using transpilation via tools like [Babel][] or [`esm`][].

#### Writing Dual Packages While Avoiding or Minimizing Hazards

Expand Down Expand Up @@ -524,8 +495,14 @@ following conditions:

Write the package in CommonJS or transpile ES module sources into CommonJS, and
create an ES module wrapper file that defines the named exports. Using
[Conditional Exports][], the ES module wrapper is used for `import` and the
CommonJS entry point for `require`.
[Conditional Exports][] via the `--experimental-conditional-exports` flag, the
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
ES module wrapper is used for `import` and the CommonJS entry point for
`require`.

> Note: While `--experimental-conditional-exports` is flagged, a package
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
> using this pattern will throw when loaded via `require()` in modern
> Node.js, unless package consumers use the `--experimental-conditional-exports`
> flag.

<!-- eslint-skip -->
```js
Expand Down Expand Up @@ -581,17 +558,37 @@ This approach is appropriate for any of the following use cases:
* The package stores internal state, and the package author would prefer not to
refactor the package to isolate its state management. See the next section.

A variant of this approach would add an export, e.g. `"./module"`, to point to
an all-ES module-syntax version the package. This could be used via `import
A variant of this approach not requiring `--experimental-conditional-exports`
for consumers could be to add an export, e.g. `"./module"`, to point to an
all-ES module-syntax version the package. This could be used via `import
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
'pkg/module'` by users who are certain that the CommonJS version will not be
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
loaded anywhere in the application, such as by dependencies; or if the CommonJS
version can be loaded but doesn’t affect the ES module version (for example,
because the package is stateless).
because the package is stateless):

<!-- eslint-skip -->
```js
// ./node_modules/pkg/package.json
{
"type": "module",
"main": "./index.cjs",
"exports": {
".": "./index.cjs",
"./module": "./wrapper.mjs"
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
}
}
```
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved

If the `--experimental-conditional-exports` flag is dropped and therefore
[Conditional Exports][] become available without a flag, this variant could be
easily updated to use conditional exports by adding conditions to the `"."`
path; while keeping `"./module"` for backward compatibility.

##### Approach #2: Isolate State
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved

The most straightforward `package.json` would be one that defines the separate
CommonJS and ES module entry points directly:
CommonJS and ES module entry points directly (requires
`--experimental-conditional-exports`):

<!-- eslint-skip -->
```js
Expand Down Expand Up @@ -672,6 +669,28 @@ This approach is appropriate for any of the following use cases:
Even with isolated state, there is still the cost of possible extra code
execution between the CommonJS and ES module versions of a package.

As with the previous approach, a variant of this approach not requiring
`--experimental-conditional-exports` for consumers could be to add an export,
e.g. `"./module"`, to point to an all-ES module-syntax version the package:
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved

<!-- eslint-skip -->
```js
// ./node_modules/pkg/package.json
{
"type": "module",
"main": "./index.cjs",
"exports": {
".": "./index.cjs",
"./module": "./index.mjs"
GeoffreyBooth marked this conversation as resolved.
Show resolved Hide resolved
}
}
```

If the `--experimental-conditional-exports` flag is dropped and therefore
[Conditional Exports][] become available without a flag, this variant could be
easily updated to use conditional exports by adding conditions to the `"."`
path; while keeping `"./module"` for backward compatibility.

## <code>import</code> Specifiers

### Terminology
Expand Down