-
Notifications
You must be signed in to change notification settings - Fork 43
Deep imports for dual packages presents a problem for transpiling for dual-packages #352
Comments
I don't have an immediate solution, but ftr we are purposely avoiding the cases where Something we were considering before was allowing |
So to restate the request (please correct me if I’m missing something), you want to:
Potential use cases for 1 and 2 are obvious: you want to publish a package that can be used as ESM natively in Node 12+, without dropping support for Node <12. But what exactly is the use case for 3? Why couldn’t your package have only CommonJS dependencies? From the other thread I think the answer for why you want your package to itself be importing the ESM versions of dual packages is because you want to provide maximum support for tree shaking. Correct me if I’m wrong, but isn’t that only relevant for creating a bundle for browsers? And if so, couldn’t bundlers do this now? If all of your package’s dual-ESM/CommonJS dependencies each have a We’re not advocating for the deprecation of the So I think the overall answer is, you would write your package as ESM, and all your This all gets simpler once you can drop CommonJS support. Then your original source files can use |
I've considered this, and I don't think it's that simple. If I'm not mistake, to get the benefits of tree shaking, you have to name your imports. import {foo} from 'bar'; But if I try to import a CJS module in this way, it doesn't seem to work.
For reference, this is the CJS code I tried to import (babel output). "use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.foo = void 0;
var foo = 'bar';
exports.foo = foo; So either I name my imports and it only works with browser bundlers, or I support node and kill off tree shaking. |
Yes, and if you continue reading the rest of my comment, I describe how to support both Node and bundlers that will do tree shaking. |
Sorry, I thought you were suggesting I could just sidestep the issue with using the CommonJS module in Node. I'm not sure I follow your suggestion. Are you saying I should publish 3 versions of the code? CommonJS, ESM with deep imports, and ESM without deep imports? If so, and the |
First, are my assumptions (the numbers 1, 2 and 3 in my comment above) correct? You’re trying to support the following consumers:
All correct so far? If so, it’s the CommonJS mixed into this that makes things complicated. Consider if you only needed to support Node ESM and bundlers, and all of your dependencies were Node-compatible ESM packages. All of your
You raise a good point, I think this is an oversight in my suggestion above. Since you aim to support CommonJS consumers, But that’s only necessary if it’s impossible for the same ESM code to be usable by both Node and bundlers. If we can write ESM code that’s usable by both, then it’s possible to make do with just Let’s say that // pkg/package.json
{
"name": "pkg",
"type": "module",
"main": "index.cjs",
"module": "module.js"
} The Node runtime itself ignores // package.json
{
"name": "alexs-package",
"type": "module",
"main": "./dist-commonjs/index.js",
"module": "./dist-module/index.js"
}
// README
Use `import stuff from 'alexs-package/dist-module/index.js';` to use in ESM in Node!
// src/index.ts
import { foo } from 'pkg/module.js';
// dist-module/index.js
import { foo } from 'pkg/module.js';
// dist-commonjs/index.js
const { foo } = require('pkg'); To generate To generate This answers your “Use case 1 (just transforming the CJS, using ESM as-is)”. As for your use case 2, which is essentially the reverse of this (transforming CommonJS to ESM), when would that ever need to be supported? As far as I know no one’s yet created a CommonJS-to-ESM transpiler, so it seems theoretical; and I don’t think it’s too much to require that if you want to support both ESM and CommonJS, you need to write your original source as ESM. |
There’s no tree shaking for CommonJS. It’s theoretically possible but no one has implemented a bundler that does it, as far as I know. In the example from my last comment, you would write such an import like this: import commonjsOnlyPackage from 'commonjs-only-package';
const { foo } = commonjsOnlyPackage; This would work in both Node ESM and in your bundler as is, and for CommonJS the transpiler would convert the first line into |
I think you follow.
As of now, TypeScript would not play nice with such a deep import. Maybe some logic could be cooked up to figure out where the appropriate |
Pinging @weswigham as I’m no expert on TypeScript. I would think that TypeScript should be able to handle any type of deep import, whether it’s CommonJS or ESM (and especially if it’s ESM).
There’s already a tremendous amount of complexity heaped onto transpilers. 😄 Like I wrote above, this is only so complex because you’re trying to support so many consumers, and because you’re trying to support tree shaking while also supporting CommonJS. It’s not easy to support both of those last two at once; and that complexity is exactly the kind of thing that’s best bottled up into a transpiler, rather than requiring package authors to need to get it right. The goal for the transpiler author should be that package authors only need to write source that evaluates correctly as ESM in Node, and the transpiler should be able to handle all the rest (which in my example here, I think is achieved). |
I thought something like this was a simpler option all around: #273 I'm not sure why it's desirable to have |
They are both first class module systems, neither is legacy, and both will be around for the foreseeable future. |
#273 is basically rejected because of #273 (comment) / #273 (comment). I assume you mean that ESM feels second-class because of the suggestion that |
I'm not sure what the exact reasoning is behind those comments. I really don't see how that's any better to be honest. One of the package systems gets second-rate support, when really both will be in use for the foreseeable future. Some packages will be one way, some the other, and developers are going to have to keep track of it all. |
You mean the reasoning of why we can’t just do what was proposed in #273? Basically, it’s because the ESM and CommonJS versions of a dual package aren’t actually interchangeable; and they’re definitely not the same objects. Say we merged in #273; or you’re using Because those statements actually reference different files on disk, they’re not the same variables in memory. In other words: import esmPkg from 'pkg';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const cjsPkg = require('pkg');
esmPkg === cjsPkg; // false
cjsPkg.foo = 3;
esmPkg.foo; // undefined This becomes a major problem for packages that are singletons. Imagine if your package is a store like Redux or Vuex. You don’t want This isn’t a theoretical concern. It’s actually already happened in the real world, in Node 11 Also there’s no guarantee that the CommonJS and ESM versions of packages are interchangeable. Transpilers aren’t perfect, and there are subtle differences introduced between source ESM and output CommonJS JavaScript, and those differences might matter. There’s not even any guarantee that the CommonJS version was generated via a transpiler; it could be completely different code. |
Is that a real concern? Are there any packages that actually use both ESM and CJS in this way in a single module? Or do you mean cross-module? Because if the versions listed in the package.json file aren't compatible then there was really never a guarantee of a single version of any given module (NPM will just give one module a different version to load). Of course there could some subtle differences since it's using the same module system you are using, but I'm not sure I would call that a bad thing. Also, all of these will still exist with deep imports, right? |
Transpilers have no possible way to smooth over a dependency whose entry point may or may not be esm at runtime under the current model - it's impossible unless you're like webpack and packing the entire world of JS at a single point of time into a single file (or known bundles) and locking everything down. ❤️ Anything based on compile-time package traversal is ultimately untenable since the runtime may be entirely different. The best we can do is allowing people to choose how they import/require things and leave the problem to the ecosystem. Right now a given specifier can only resolve under one module system - that's a good first step, but to make it much better, we need a specifier to resolve to the exact same module under either resolver entrypoint, this way a user doesn't need to worry about which (of |
@AlexanderOMara sorry for the delayed reply.
Yes, this has come up in the real world: graphql/graphql-js#1479 (comment) and https://github.com/Pokute/graphql-esm-bug. @jkrems and I created a repo to illustrate the issue and explain it through both text and examples: https://github.com/jkrems/singleton-issue
The same issue would still exist with deep imports if extensions were optional. In other words, just as Hence this is why file extensions are required in the current |
In the case of the That's not exactly a new issue. That's a known problem with trying to use Older versions of NPM which didn't maximize flatness made this happen even more often. For the
But what if I do that magic where my CJS modules |
True. Though while I’d imagine that
Within one’s own app or package, yes, it’s contrived. By I think as an exported path within a package, it’s very plausible. I can easily imagine a package like
No, the issues aren’t present when the specifiers differ between CommonJS and ESM. In this example, if your ESM code had |
It may be a common pattern; the ecosystem solution for this (and any duplication in the graph) is peerDependencies - which would prevent the problem from occurring with ESM as well. |
this appears to be solved now thanks to "exports", closing but feel free to re-open. |
Yep, conditional exports solves this issue very nicely! |
It was recommended I start a separate issue for the issue here.
Currently the documentation suggests that dual packages which offer CJS and ESM modules could have a deep import like
pkg/module.mjs
which users could import if they want to use ESM.If the consuming package is ESM only, that's not the worst thing ever (although I wouldn't call it ideal), but if the consuming package is also a dual package, that presents a pain-point for transpiling.
Somehow, you would need to output something like the following two files (although the actual CJS code would be more complicated):
module.mjs
index.js
Essentially the transpiler would need to be smart enough to be able to rewrite import paths for 3rd party modules, with knowledge of the file structure. Additionally, that file structure needs to remain consistent across versions, as the transpiler will only see the version installed at time of transpiling.
Use case 1 (just transforming the CJS, using ESM as-is):
How should the transpiler know that
import 'pkg/module.mjs'
needs to be rewritten torequire('pkg')
?Use case 2 (TypeScript, etc.):
How should the transpiler know that
import 'pkg'
needs to be rewritten toimport 'pkg/module.mjs'
?Presumably both of those use-cases would need to be supported.
I think it would be much simpler if there was a
package.json
property (like"main"
CJS) and/or a default file (likeindex.js
for CJS) which would be be loaded based on if the CJS or ESM module loader was being used.Prior to the addition of the
--es-module-specifier-resolution
switch it was possible to simply have an extension-less"main"
field and it would simply load either the.js
or.mjs
file based on the module loader being used (this behavior is still possible with--es-module-specifier-resolution=node
).The text was updated successfully, but these errors were encountered: