Skip to content

Commit c2d23a7

Browse files
authored
fix: load ESM-only dependencies with native import() in the CommonJS build (#5701)
* fix: load ESM-only dependencies with native import() in the CommonJS build * fix: update dynamic import handling to ensure relative imports are converted to require() while preserving ESM-only imports
1 parent ba54764 commit c2d23a7

5 files changed

Lines changed: 55 additions & 65 deletions

File tree

babel.config.js

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
1+
import rewriteRelativeDynamicImport from "./scripts/babel-plugin-rewrite-relative-dynamic-import.mjs";
2+
13
export default (api) => {
24
// `api.env()` makes the resolved config cache depend on `BABEL_ENV`/`NODE_ENV`
35
// so the `cjs` build and the default client build don't share a cache entry.
46
const env = api.env();
57

6-
// CommonJS build (`build:cjs`, `babel --env-name cjs`). `lib/` is authored as
7-
// native ESM; here we transpile it to CJS for the dual package. `preset-env`
8-
// with `modules: "commonjs"` rewrites `import()` into real `require()` (the
9-
// target environments don't reliably support `require(ESM)`), and
10-
// `babel-plugin-transform-import-meta` rewrites `import.meta.url` (used by
11-
// `createRequire`) for CJS.
8+
// CommonJS build. Transpile static `import`/`export` to CJS, but leave dynamic
9+
// `import()` native (`exclude` below) so ESM-only deps load without
10+
// `require(ESM)`; `rewrite-relative-dynamic-import` handles internal modules.
1211
if (env === "cjs") {
1312
return {
1413
presets: [
1514
[
1615
"@babel/preset-env",
1716
{
1817
modules: "commonjs",
18+
exclude: ["@babel/plugin-transform-dynamic-import"],
1919
targets: {
2020
node: "22.15.0",
2121
},
2222
},
2323
],
2424
],
25-
plugins: ["babel-plugin-transform-import-meta"],
25+
plugins: [
26+
"babel-plugin-transform-import-meta",
27+
rewriteRelativeDynamicImport,
28+
],
2629
};
2730
}
2831

@@ -40,20 +43,5 @@ export default (api) => {
4043
],
4144
],
4245
plugins: ["@babel/plugin-transform-object-assign"],
43-
env: {
44-
test: {
45-
presets: [
46-
[
47-
"@babel/preset-env",
48-
{
49-
targets: {
50-
node: "22.15.0",
51-
},
52-
},
53-
],
54-
],
55-
plugins: ["@babel/plugin-transform-runtime"],
56-
},
57-
},
5846
};
5947
};

package-lock.json

Lines changed: 0 additions & 37 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,7 @@
8989
"@babel/core": "^7.29.0",
9090
"@babel/eslint-parser": "^7.28.6",
9191
"@babel/plugin-transform-object-assign": "^7.27.1",
92-
"@babel/plugin-transform-runtime": "^7.29.0",
9392
"@babel/preset-env": "^7.29.2",
94-
"@babel/runtime": "^7.29.2",
9593
"@changesets/cli": "^2.30.0",
9694
"@changesets/get-github-info": "^0.8.0",
9795
"@commitlint/cli": "^19.5.0",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Babel plugin (CJS build): rewrite only *relative* dynamic `import()` to
2+
// `require()` so babel's `.default` interop keeps working for our internal
3+
// modules. Bare/`node:` specifiers stay native `import()`, letting ESM-only
4+
// deps load without `require(ESM)`.
5+
export default ({ types: t }) => ({
6+
name: "rewrite-relative-dynamic-import",
7+
visitor: {
8+
CallExpression(path) {
9+
if (path.node.callee.type !== "Import") return;
10+
11+
const [arg] = path.node.arguments;
12+
13+
// a string-literal relative specifier (`./` or `../`) = an internal module
14+
if (t.isStringLiteral(arg) && /^\.\.?\//.test(arg.value)) {
15+
// `Promise.resolve().then(() => require(x))`: require runs in the
16+
// callback so a load failure rejects (like `import()`), not throws.
17+
path.replaceWith(
18+
t.callExpression(
19+
t.memberExpression(
20+
t.callExpression(
21+
t.memberExpression(
22+
t.identifier("Promise"),
23+
t.identifier("resolve"),
24+
),
25+
[],
26+
),
27+
t.identifier("then"),
28+
),
29+
[
30+
t.arrowFunctionExpression(
31+
[],
32+
t.callExpression(t.identifier("require"), [arg]),
33+
),
34+
],
35+
),
36+
);
37+
}
38+
},
39+
},
40+
});

test/cjs.test.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,15 @@ describe("cjs", () => {
114114
expect(serverSource).toMatch(/exports\.default = /);
115115
expect(serverSource).toMatch(/module\.exports = exports\.default;/);
116116

117-
// No executable dynamic `import()` or `import.meta` survives in the CJS
118-
// build. Strip comments first since JSDoc type annotations legitimately
119-
// reference `import("pkg")` and `[S=import("http").Server]`.
117+
// Relative `import("./…")` becomes `require()` and `import.meta` is gone,
118+
// but bare `import("pkg")` stays native for ESM-only deps. Strip comments
119+
// first (JSDoc legitimately references `import("pkg")`).
120120
const executableCode = serverSource
121121
.replaceAll(/\/\*[\s\S]*?\*\//g, "")
122122
.replaceAll(/^\s*\/\/.*$/gm, "");
123123

124-
expect(executableCode).not.toMatch(/import\(/);
124+
expect(executableCode).not.toMatch(/import\(\s*["']\.\.?\//); // no relative
125+
expect(executableCode).toMatch(/import\(\s*["']p-retry["']\)/); // bare stays
125126
expect(executableCode).not.toMatch(/import\.meta/);
126127
});
127128

0 commit comments

Comments
 (0)