module: add __esModule to require()'d ESM - #52166
Conversation
|
Review requested:
|
|
While this approach strikes a balance between performance and compatibility, so I think we should do this for now, there are some glitches that may or may not matter: because the namespace is in the prototype chain, users can't easily spread or copy the exports. I wonder if it's possible to get V8 to allow the host (not random users) to customize the prototype of module namespace objects - doesn't seem that hard to do, implementation wise, and V8 already allows embedders to customize e.g. the global object template - then we can put |
|
IIUC, the spec requires the module exotic objects to have |
|
Could we create a synthetic module wrapper here effectively like |
That was brought up in #52134 but has the following cons:
|
There was a problem hiding this comment.
Thanks for this :) Note that I believe that this is much more important because it makes Node.js implementation compatible with the existing ecosystem, rather than because of the performance of a hypothetical feature in which every single tool in the ecosystem aligns to Node.js's non-__esModule implementation.
I wonder if it's possible to get V8 to allow the host (not random users) to customize the prototype of module namespace objects - doesn't seem that hard to do, implementation wise, and V8 already allows embedders to customize e.g. the global object template - then we can put { __esModule: true } on the prototype of the namespace object instead, which probably would be a lot more natural. But perhaps that requires a spec change and is a bit far-fetched. So this is probably as good as we can get for now.
Yeah as @aduh95 pointed out, this doesn't only require a change in V8 but also in the spec. Additionally, it has the problem that when a module is both imported and require()d you probably don't want the namespace to have the weird prototype in both cases.
While this approach strikes a balance between performance and compatibility, so I think we should do this for now, there are some glitches that may or may not matter: because the namespace is in the prototype chain, users can't easily spread or copy the exports. Object.keys() won't return the keys of the exports either. So if users want to copy the exports or get the keys (maybe to create a mock or something), they probably will end up finding their way to the namespace in the prototype chain and do what they want. I suspect it's not that rare a use case. But maybe it's inevitable to work with some quirks to support required ESM.
Oh yeah I didn't think about that when I included the object wrapping in the possible alternatives. However, it's already possible for require() to return objects with important stuff on their prototype (module.exports = new MyUtilitiesSingleton()).
I guess it's a question of how common it is to spread/keys the require()d object.
|
I wonder how important it is for |
Babel actually has a loose mode that makes it enumerable (through simple assignment, to get a smaller compiled output). It doesn't matter in most cases, except... when you spread/Object.keys/for...in, which are the same cases in which the object wrapper implemented by this PR is annoying 😬 |
|
Could you generate the new object from the namespace object, creating own enumerable getters for every export to support live bindings, and add the non-enumberable Then spread and |
|
If that is done, then I suggest that that object should also be frozen and have |
|
I am going to do some investigation of the real performance impact before proceeding with this, because this could lead to behavior changes that may be difficult to back out of in the future. Behavior-wise, I still prefer to see reference equal |
Experimenting with the idea, I noticed that if we do this, we may be able to allow CJS -> ESM cycles (because we can then lazily return the exports of a ESM), that might be desirable for UX, regardless of the (One downside of relying on this for cycles is that users might still have race conditions until we make the ESM loader fully conditionally synchronous). |
|
I think this is what people are already advocating here, but just in case and to be explicit: We have precedent for this in |
Not sure if I'm following - what's the precedent specifically? And, as long as we do want to insert |
🤔 sure? No reason comes to mind to not do that. |
The reason not to is that it's not part of the JS modules spec and once shipped could never be removed from import(). If there a way to limit the scope, like it's only added for dynamic import() within CJS modules, then it might be somewhat less impactful? Do people need |
Actually I don't think it matter that much, because |
It's not for comparison; it facilitates avoiding the dual-package hazard (and thus can vastly simplify a package's internals): const mod = require('some-package');
mod.foo++;import * as mod from 'some-package';
mod.foo++;In the above, assuming the package is done right, |
|
@JakobJingleheimer I don't think this use case needs reference equality of "the thing that gets returned by |
It's just that the example needs to be: const mod = require('some-package');
mod.incrementFoo();import * as mod from 'some-package';
mod.incrementFoo();where |
|
Right actually I don't think it matters for #52166 (comment) either since the module namespace is not mutable. And if the mutation is done via a method or on an exported object's properties, all the solutions proposed so far behave the same, they will just modify what's in the underlying module. |
|
If it's not a pointer, that'll be hell to manage. Yes we can, but surely a pointer is the most simple and desirable, no?
Ah, yes—fair point (I oversimplified). As long as they point to the same underlying state, great 😊 but hopefully there is as little in the middle as possible/necessary. |
|
Backport in #56927 |
Before this PR, trying to load real ESM from transpiled ESM would throw errors like this with
--experimental-require-moduleAfter this PR it logs 'import both'.
Tooling in the ecosystem have been using the __esModule property to
recognize transpiled ESM in consuming code. For example, a 'log'
package written in ESM:
Can be transpiled as:
The consuming code may be written like this in ESM:
Which gets transpiled to:
So to allow transpiled consuming code to recognize require()'d real ESM
as ESM and pick up the default exports, we add a __esModule property by
building a source text module facade for any module that has a default
export and add .__esModule = true to the exports. We don't do this to
modules that don't have default exports to avoid the unnecessary
overhead. This maintains the enumerability of the re-exported names
and the live binding of the exports.
The source of the facade is defined as a constant per-isolate property
required_module_facade_source_string, which looks like this
And the 'original' module request is always resolved by
createRequiredModuleFacade() to wrap which is a ModuleWrap wrapping
over the original module.
This PR originally used the same trick that Bun did (h/t @Jarred-Sumner) by putting the original module namespace in the prototype chain. Upon closer examination this now switched to use a SourceTextModule facade only when the exports contain default to 1) reduce the performance impact 2) ensure that the exported names are still enumerable and can be copied by tools.
Refs: #51977 (comment)
Refs: #52134