fix(commonjs): recognize named export#1962
Conversation
|
@rtritto looks like CI is failing on prettier |
|
I reviewed the diff + CI + the context in #1678. Using MUST fix (before merge)
IMPROVEMENTS (non-blocking) |
|
@shellscape thanks for reply |
574b39e to
d4f6c3c
Compare
|
@shellscape please can you review? |
There was a problem hiding this comment.
Pull request overview
This PR enhances @rollup/plugin-commonjs named-export detection by integrating cjs-module-lexer, aiming to recognize additional CommonJS export patterns (e.g. Object.defineProperty(exports, "foo", ...)) and expose them as ESM named exports.
Changes:
- Add
cjs-module-lexerand a new lexer-based export analysis helper. - Populate
commonjsmodule meta with lexer-detected exports and use them to generate additional named exports in proxies and transforms. - Add a new fixture and test intended to cover named CJS exports.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Locks the newly added cjs-module-lexer dependency. |
| packages/commonjs/package.json | Adds cjs-module-lexer to runtime dependencies. |
| packages/commonjs/src/analyze-exports-lexer.js | Introduces lexer initialization and export analysis utilities. |
| packages/commonjs/src/index.js | Runs lexer analysis during transform, stores results in module meta, and initializes lexer in buildStart. |
| packages/commonjs/src/transform-commonjs.js | Augments the generated export block with lexer-detected named exports not found via AST. |
| packages/commonjs/src/proxies.js | Extends ES import proxies to explicitly re-export lexer-detected named exports. |
| packages/commonjs/test/test.js | Adds a new test for named exports from CJS (currently written in an incompatible style for Vitest). |
| packages/commonjs/test/fixtures/samples/named-cjs-exports/main.cjs | Adds a new sample module with named exports. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
| test('test named exports from cjs', async (t) => { | ||
| const bundle = await rollup({ | ||
| input: 'fixtures/samples/named-cjs-exports/main.cjs', | ||
| plugins: [commonjs()] | ||
| }); |
There was a problem hiding this comment.
The PR description mentions supporting named exports created via Object.defineProperty(exports, "foo", …), but the added fixture/test only covers assignment-based exports. Add a fixture that defines a named export via Object.defineProperty and assert it is recognized to prevent regressions for the reported issue.
| buildStart({ plugins }) { | ||
| async buildStart({ plugins }) { | ||
| validateVersion(this.meta.rollupVersion, peerDependencies.rollup, 'rollup'); | ||
| await ensureLexerInit(); |
There was a problem hiding this comment.
buildStart now unconditionally awaits ensureLexerInit(). If cjs-module-lexer initialization fails in an environment (WASM load issues, restricted FS, etc.), the entire plugin will hard-fail before any fallback logic can run. Consider catching initialization errors here (and warning) so the plugin can continue with the existing AST-based export detection.
| await ensureLexerInit(); | |
| try { | |
| await ensureLexerInit(); | |
| } catch (error) { | |
| this.warn({ | |
| code: 'CJS_LEXER_INIT_FAILED', | |
| message: | |
| 'Failed to initialize cjs-module-lexer. Falling back to AST-based export detection.' + | |
| (error && error.message ? ` Cause: ${error.message}` : '') | |
| }); | |
| } |
| await ensureInit(); | ||
| try { |
There was a problem hiding this comment.
analyzeExports calls await ensureInit() outside the try block, so any failure to initialize the lexer (e.g. WASM load/init errors) will reject and bypass the intended graceful fallback. Wrap initialization in the same try/catch (or have ensureInit return a cached init promise and handle errors) so lexer failures don’t hard-fail the build.
| await ensureInit(); | |
| try { | |
| try { | |
| await ensureInit(); |
| // If lexer fails (e.g. WASM issue), fall back gracefully | ||
| this.warn( | ||
| `[commonjs] cjs-module-lexer failed for ${id}: ${err.message}. ` + | ||
| 'Falling back to no named exports.' | ||
| ); | ||
|
|
||
| return { exports: [], reexports: [], hasDefaultExport: true }; |
There was a problem hiding this comment.
analyzeExports uses this.warn(...), but this function is not called with a Rollup plugin context (it’s invoked as analyzeExports(code, id)), so this will be undefined and this path will throw. Pass a warn callback/context into analyzeExports (or move the warning to the caller) and ensure the fallback return does not incorrectly set hasDefaultExport: true when lexing fails.
|
|
||
| /** | ||
| * Ensure cjs-module-lexer WASM is initialized. | ||
| * Safe to call multiple times — will only init once. | ||
| */ | ||
| export async function ensureInit() { | ||
| if (!initialized) { | ||
| await init(); | ||
| initialized = true; | ||
| } |
There was a problem hiding this comment.
ensureInit is not concurrency-safe: if two async callers hit it before initialized flips to true, init() can run multiple times. Consider caching the in-flight init promise (e.g. let initPromise) or setting the flag before awaiting to ensure only one initialization occurs per process.
| /** | |
| * Ensure cjs-module-lexer WASM is initialized. | |
| * Safe to call multiple times — will only init once. | |
| */ | |
| export async function ensureInit() { | |
| if (!initialized) { | |
| await init(); | |
| initialized = true; | |
| } | |
| let initPromise = null; | |
| /** | |
| * Ensure cjs-module-lexer WASM is initialized. | |
| * Safe to call multiple times — will only init once. | |
| */ | |
| export async function ensureInit() { | |
| if (initialized) return; | |
| if (!initPromise) { | |
| initPromise = (async () => { | |
| await init(); | |
| initialized = true; | |
| initPromise = null; | |
| })(); | |
| } | |
| await initPromise; |
| test('test named exports from cjs', async (t) => { | ||
| const bundle = await rollup({ | ||
| input: 'fixtures/samples/named-cjs-exports/main.cjs', | ||
| plugins: [commonjs()] | ||
| }); | ||
|
|
||
| t.plan(3); | ||
| await testBundle(t, bundle); |
There was a problem hiding this comment.
This test is executed by Vitest (see package.json), which does not pass an AVA-style t object into the test callback. As written, t will be undefined and t.plan(3) will throw, failing the suite. Align this test with the surrounding ones (e.g. expect.assertions(…) + testBundle(avaAssertions, bundle)), or use Vitest’s test context APIs.
| test('test named exports from cjs', async (t) => { | |
| const bundle = await rollup({ | |
| input: 'fixtures/samples/named-cjs-exports/main.cjs', | |
| plugins: [commonjs()] | |
| }); | |
| t.plan(3); | |
| await testBundle(t, bundle); | |
| test('test named exports from cjs', async () => { | |
| const bundle = await rollup({ | |
| input: 'fixtures/samples/named-cjs-exports/main.cjs', | |
| plugins: [commonjs()] | |
| }); | |
| expect.assertions(3); | |
| await testBundle(avaAssertions, bundle); |
|
@sapphi-red @bluwy @tada5hi @TrickyPi sorry for the ping, please can you take a look? |
Rollup Plugin Name:
@rollup/plugin-commonjsThis 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: #1678
Description
Add the analyze export lexer for named export that allows to recognize exports like: