-
-
Notifications
You must be signed in to change notification settings - Fork 599
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
feat(commonjs): reconstruct real es module from __esModule marker #537
feat(commonjs): reconstruct real es module from __esModule marker #537
Conversation
packages/commonjs/src/index.js
Outdated
@@ -110,7 +108,7 @@ export default function commonjs(options = {}) { | |||
ast | |||
); | |||
|
|||
setIsCjsPromise(id, isEsModule ? false : Boolean(transformed)); | |||
setIsCjsPromise(id, isEsModule || isCompiledEsModule ? false : Boolean(transformed)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This avoids creating a proxy module for these, which I think is correct. I'm not entirely sure if this creates other unintended side effects?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that if you are reconstructing as an es module, and that es module will still have require
calls with dynamic arguments, then the transformation should still take place - after the reconstruction.
"@typescript-eslint/no-unused-vars": "off" | ||
"@typescript-eslint/no-unused-vars": "off", | ||
"camelcase": "off", | ||
"no-underscore-dangle": "off" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Had to add some eslint ignore, what is the prefered approach here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Had to add some eslint ignore, what is the prefered approach here?
If I understand correctly, you did this due to the const
at the top.
It's pretty standard for const
s to be upper-underscore-case, and it looks like eslint supports that out of the box:
If ESLint decides that the variable is a constant (all uppercase), then no warning will be thrown
(https://eslint.org/docs/rules/camelcase)
So it's weird you had to do this.
Anyway I'm for using a local eslint-disable-line
on the same line. So other instances will still warn like it usually does, to keep the style aligned.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lintings errors come from generated output in the test fixtures, I can't make those const or add linting comments unfortunately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh so you can exclude the tests folder, or override settings for the tests folder, and that's what you did.
I missed the fact that this was under tests.
Maybe you can change the variable generation algorithm to avoid collisions with other people's eslint with generated code?
1745141
to
c239a73
Compare
packages/commonjs/src/transform.js
Outdated
return { | ||
code, | ||
map, | ||
syntheticNamedExports: isEsModule || isCompiledEsModule ? false : '__moduleExports' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm avoiding creating __moduleExports
for compiled es modules. This follows the pattern of turning the commonjs module into a real es module. I'm not sure if this can create issues in other places though.
Thank you already for your work! So I tried to compile Rollup with this new plugin and it failed by creating an empty namespace for a dependency. So here is a test case that should be considered: // input: dep.js
Object.defineProperty(exports, '__esModule', { value: true });
const foo = { test: 42 };
module.exports = foo;
// actual output (contains no exports at all)
const foo = { test: 42 };
var dep = foo; So what I would expect is that the plugin just falls back to the old logic if anything is assigned to Another case that we should definitely think about: // input
// main.js
import test from './dep.js'
console.log(test);
// dep.js
Object.defineProperty(exports, '__esModule', { value: true });
exports.test = 42; Now this one fails because A simple fix that would as far as I can tell be consistent with the current behaviour, which will be using this interop: function getDefaultExportFromCjs (x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
} would be to replicate this interop in so far as if there is no default export, export the namespace of this module as default. This would fix the above scenario and avoid the breaking change. Now of course this would require either creating a circular dependency or manually creating an object for the default export. |
@lukastaegert Thanks for the notes. module.exports reassignmentThis is very unusual, because it would overwrite the namespace exportI added a namespace export back when there is no default export. I think I got it in a way that didn't require a lot of extra logic or helpers. But please confirm :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So thank you for keeping up with my comments so far, and sorry this is taking a while. So I went for another round of testing, and here are some things I found by using a CJS module as an entry point:
-
"Proper" compiled ES module
// input exports.foo = 'foo'; exports.default = 'default'; Object.defineProperty(exports, '__esModule', { value: true }); // output var foo = 'foo'; var _default = 'default'; export default _default; export { foo };
looks good.
-
Using
module.exports
// input module.exports = { foo: 'foo', default: 'default' }; Object.defineProperty(exports, '__esModule', { value: true }); // output new plugin var main = { foo: 'foo', default: 'default' }; export default main; // output old plugin // ... helper code var main = createCommonjsModule(function (module, exports) { module.exports = { foo: 'foo', default: 'default' }; Object.defineProperty(exports, '__esModule', { value: true }); }); var main$1 = /*@__PURE__*/getDefaultExportFromCjs(main); export default main$1;
So here, the new plugin gives us the wrong default export. As there is a default property in the object, it should definitely give us that as the default export. I still think we really need to completely deoptimize in that situation and do what the old plugin did here to not break code. This also includes keeping the property definition. And regarding the question if that pattern is used,
anymatch
, which is a transitive dependency of Rollup itself, uses this pattern. -
No default export
// input exports.foo = 'foo'; Object.defineProperty(exports, '__esModule', { value: true }); // output var foo = 'foo'; var main = { foo: foo }; export default main; export { foo };
While the output here is generally not bad, we could actually "hide" the missing default export in entry points by adding
syntheticNamedExports: 'default'
to transpiled ES modules that do not have an explicit (and only those!) default export. That would still make it possible to import the default internally but it would not pollute the signature of entry points:// output var foo = 'foo'; export { foo };
packages/commonjs/src/transform.js
Outdated
// eslint-disable-next-line consistent-return | ||
return val.value; | ||
return { key: key.value, value: valueProperty.value }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you run this across code with Object.defineProperty(exports, 'someNonsense', {value: true})
then the plugin will crash because one usage of this function does not yet use the new signature:
https://github.com/rollup/plugins/pull/537/files#diff-425cdc831ee23ba9fbb89366518fb8b6L460-L461
Maybe you also want to adjust the name slightly to reflect that it is giving you now the property definition and not just the name?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It also crashes if you use a non-literal or falsy value.
So my general feeling at the moment is that this PR is a little too eager. There may be other edge cases I did not test yet. Considering the huge importance of this plugin for the ecosystem and the amount of tools relying on it (e.g. SnowPack), maybe a "conservative by default" approach would be better here. This would mean
|
Here is another scenario to consider: Assume we previously created a CJS module that has another module as an external dependency and now we are bundling both together: // input
// main.js
var dep = require('./dep.js');
function _interopDefault(e) {
return e && e.__esModule ? e : { default: e };
}
var dep__default = /*#__PURE__*/ _interopDefault(dep);
console.log(dep__default['default']); // should log "foo"
// dep.js
exports.default = 'foo';
Object.defineProperty(exports, '__esModule', { value: true });
// output new plugin
var _default = 'foo';
var dep = /*#__PURE__*/Object.freeze({
__proto__: null,
'default': _default
});
function _interopDefault(e) {
return e && e.__esModule ? e : { default: e };
}
var dep__default = /*#__PURE__*/ _interopDefault(dep);
console.log(dep__default['default']); // actually logs "{ default: 'foo' }"
var main = {};
export default main;
// output old plugin
// ... helper code
var dep = createCommonjsModule(function (module, exports) {
exports.default = 'foo';
Object.defineProperty(exports, '__esModule', { value: true });
});
var main = createCommonjsModule(function (module) {
function _interopDefault(e) {
return e && e.__esModule ? e : { default: e };
}
var dep__default = /*#__PURE__*/ _interopDefault(dep);
console.log(dep__default['default']); // logs "foo" as expected
});
var main$1 = /*@__PURE__*/getDefaultExportFromCjs(main);
module.exports = main$1; So how to fix this? We could make sure that internal export const __moduleExports = Object.defineProperty({
default: foo
}, '__esModule', {value: true}); So basically wrapping the artificial namespace object in a "defineProperty" to keep semantics. But I fear this may already be a problem when importing an actual ES module that I did not anticipate, so maybe this could also be "fixed" in the proxy in some way. |
Ok, so there is definitely a bug in the current implementation where importing an ES module from CommonJS does not properly resolve the default export due to the missing property. This should actually be solved separately, hope I find some time soon. My best idea so far is to import * as namespace from 'module';
export default /*#__PURE__*/Object.defineProperty(/*#__PURE__*/Object.assign({}, namespace), '__esModule', {value: true}); in the proxy, hm |
@lukastaegert Thanks for the review. I thought I had already covered the assignment to exports case, will double check the test cases. I was indeed unsure about not marking the module as CJS. I was hoping we could do a clean CJS -> ESM -> CJS, but clearly there is a lot more to consider here. |
Maybe let's first wait for #552 as it might have some implications here. |
Hi @LarsDenBakker, if you do not mind, I would offer to take over this PR so that you can focus on the IMO more important #540. Background is also that once this last open commonjs PR is merged, I would like to do a larger refactoring PR to improve the code structure and modularization of this plugin. There are some very important future improvements I hope to tackle based on this. |
No problem, go for it! |
28deeca
to
2493fe4
Compare
The new approach here is looking great @lukastaegert. |
packages/commonjs/src/transform.js
Outdated
|
||
switch (node.type) { | ||
case 'Literal': | ||
return node.value; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't it be return !!node.value
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would not make a difference but we can do that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah just to be consistent so you can can always expect a boolean, later you might use this function for other cases
packages/commonjs/src/transform.js
Outdated
@@ -60,12 +61,56 @@ export function hasCjsKeywords(code, ignoreGlobal) { | |||
return firstpass.test(code); | |||
} | |||
|
|||
function isTrueNode(node) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that all of these AST helpers beg for a new utility file 😉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely. Only wondering if I should do these things in this PR or go for a separate refactoring PR, currently leaning towards the latter.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm with you. This way we can review this PR without wondering what actually changed
Converting this one to a draft because I still want to check some stuff here. |
2493fe4
to
375ba48
Compare
Finally remove second AST walk
1d59b3a
to
627f801
Compare
I already started work on the next big refactoring PR that will add circular dependency support here: #658. To avoid merge cascades, I would hope to put any larger changes beyond regression fixing into the other PR. |
👏 great work everyone |
…537) BREAKING CHANGES: rollup/plugins#537 (comment) * feat(commonjs): reconstruct real es module from __esModule marker * fix(commonjs): handle module.exports reassignment * fix(commonjs): preserve namespace default export fallback * fix(commonjs): mark redefined module.exports as CJS * chore(commonjs): fix tests * Make tests actually throw when there is an error in the code and skip broken test * chore(commonjs): Improve how AST branche are skipped * fix(commonjs): Fix Rollup peer dependency version check * refactor(commonjs): Use a switch statement for top level analysis * refactor(commonjs): Restructure transform slightly * refactor(commonjs): Extract helpers * refactor(commonjs): Extract helpers * refactor(commonjs): Move __esModule detection logic entirely within CJS transformer * refactor(commonjs): Add __moduleExports back to compiled modules * refactor(commonjs): Move more parts into switch statement * refactor(commonjs): Completely refactor require handling Finally remove second AST walk * fix(commonjs): Handle nested and multiple __esModule definitions * fix(commonjs): Handle shadowed imports for multiple requires * fix(commonjs): Handle double assignments to exports * chore(commonjs): Further cleanup * refactor(commonjs): extract logic to rewrite imports * feat(commonjs): only add interop if necessary * refactor(commonjs): Do not add require helper unless used * refactor(commonjs): Inline dynamic require handling into loop * refactor(commonjs): Extract import logic * refactor(commonjs): Extract export generation * refactor(commonjs): Avoid empty lines before wrapped code * Do not remove leading comments in files * refactor(commonjs): Remove unused code * fix(commonjs): Improve error message for unsupported dynamic requires Co-authored-by: Lukas Taegert-Atkinson <[email protected]>
Rollup Plugin Name:
commonjs
This PR contains:
Are tests included?
Breaking Changes?
If yes, then include "BREAKING CHANGES:" in the first commit message body, followed by a description of what is breaking.
List any relevant issue numbers: #481
Description
By @lukastaegert: The description has been updated to reflect the final state of this PR after my changes
This implements "improvement 2" described in #481. When a module is compiled from es module to commonjs it is a common practice to use the
__esModule
marker to indicate that the module follows esm semantics.With this change, when a module is marked with either:
or
And it does not otherwise perform any unchecked operations such as assignments to
module.exports
, it will be reconstructed without any wrapper or interop code. In contrast to the previous implementation of this PR, it will still provide the same return value when required by another commonjs module and also still support synthetic exports.If there is an assignment to
exports.default
ormodule.exports.default
, this assignment will become the default export. Otherwise the default export will be the commonjs namespace, i.e. the return value when requiring the module. This is to ensure that if there is no explicit default export, we keep compatibility with Node.Moreover, this PR is now close to a complete rewrite of core parts of this plugin, making it hopefully much more maintainable, and also faster. In short, the previous version of the plugin did two complete AST traversals as well as two iterations over all top level statements while now, there is only one top level iteration and one AST traversal.
The leading principle here was to focus on gather information and collecting specific nodes during this traversal and only perform transformations that need to be performed in any case. Then only after the traversal is complete and we have a complete picture of the code, the remaining code transformations are performed.
The remaining code then is roughly grouped in export generation and import generation, both of which were extracted to separate modules.
There are some small improvements like not adding interop code in more situations, but also some bugs were fixed:
exports.foo
, the plugin will no longer generate invalid codeOne last change I added here is that in case no dynamic requires are used, the plugin uses a simplified commonjs wrapper, because it really bugged me that the message
Dynamic requires are not currently supported by @rollup/plugin-commonjs
became part of everyones code base while hardly anyone would ever see it. Now this message and the corresponding require handler will only be embedded if the code explicitly referencesrequire
ormodule.require
.