Skip to content

Commit 4147146

Browse files
fix(rsc): harden CommonJS namespace interop
1 parent 0135881 commit 4147146

8 files changed

Lines changed: 49 additions & 6 deletions

File tree

packages/plugin-rsc/src/transforms/cjs.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ if (true) {
4444
expect(await testTransform(input)).toMatchInlineSnapshot(`
4545
"let __filename = "/test.js"; let __dirname = "/";
4646
let exports = {}; const module = { exports };
47-
function __cjs_interop__(m) {return m.__cjs_module_runner_transform || "default" in m && Object.keys(m).every((k) => k === "default" || m[k] === m.default[k]) ? m.default : m;}
47+
function __cjs_interop__(m) {return m.__cjs_module_runner_transform || "module.exports" in m && m["module.exports"] === m.default || "default" in m && Object.keys(m).every((k) => k === "default" || m.default != null && m[k] === m.default[k]) ? m.default : m;}
4848
if (true) {
4949
module.exports = (__cjs_interop__(await import('./cjs/use-sync-external-store.production.js')));
5050
} else {
@@ -69,7 +69,7 @@ if (true) {
6969
expect(await testTransform(input)).toMatchInlineSnapshot(`
7070
"let __filename = "/test.js"; let __dirname = "/";
7171
let exports = {}; const module = { exports };
72-
function __cjs_interop__(m) {return m.__cjs_module_runner_transform || "default" in m && Object.keys(m).every((k) => k === "default" || m[k] === m.default[k]) ? m.default : m;}
72+
function __cjs_interop__(m) {return m.__cjs_module_runner_transform || "module.exports" in m && m["module.exports"] === m.default || "default" in m && Object.keys(m).every((k) => k === "default" || m.default != null && m[k] === m.default[k]) ? m.default : m;}
7373
const __cjs_to_esm_hoist_0 = __cjs_interop__(await import("react"));
7474
const __cjs_to_esm_hoist_1 = __cjs_interop__(await import("react-dom"));
7575
"production" !== process.env.NODE_ENV && (function() {
@@ -100,7 +100,7 @@ function test() {
100100
expect(await testTransform(input)).toMatchInlineSnapshot(`
101101
"let __filename = "/test.js"; let __dirname = "/";
102102
let exports = {}; const module = { exports };
103-
function __cjs_interop__(m) {return m.__cjs_module_runner_transform || "default" in m && Object.keys(m).every((k) => k === "default" || m[k] === m.default[k]) ? m.default : m;}
103+
function __cjs_interop__(m) {return m.__cjs_module_runner_transform || "module.exports" in m && m["module.exports"] === m.default || "default" in m && Object.keys(m).every((k) => k === "default" || m.default != null && m[k] === m.default[k]) ? m.default : m;}
104104
const __cjs_to_esm_hoist_0 = __cjs_interop__(await import("te" + "st"));
105105
const __cjs_to_esm_hoist_1 = __cjs_interop__(await import("test"));
106106
const __cjs_to_esm_hoist_2 = __cjs_interop__(await import("test"));
@@ -196,6 +196,12 @@ function test() {
196196
},
197197
"depPrimitive": "[ok]",
198198
"dualLib": "ok",
199+
"interop": {
200+
"defaultOnlyNull": true,
201+
"markerCallable": true,
202+
"markerFalsy": true,
203+
"nullDefaultNamespace": true,
204+
},
199205
"testExternalFalsyPrimitive": {
200206
"ok": true,
201207
},

packages/plugin-rsc/src/transforms/cjs.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,28 @@ import { buildScopeTree } from './scope'
1111
// Runtime helper to handle CJS/ESM interop when transforming require() to import()
1212
// Unwrap .default for modules
1313
// 1. if it was transformed by this plugin (marked with __cjs_module_runner_transform)
14-
// 2. if all named exports point to .default properties (common CJS pattern)
14+
// 2. if Node marks .default as the exact CommonJS module.exports value
15+
// https://nodejs.org/api/esm.html#commonjs-namespaces
16+
// 3. if all named exports point to .default properties (common CJS pattern)
1517
// this is particularly important for Node built-in modules consumptions;
1618
// where the built-in modules are not transformed by this plugin but still follow the CJS export pattern
1719
// see [getESMFacade](https://github.com/nodejs/node/blob/f200685d9930404d610a52d9e06513bf0a821ed4/lib/internal/bootstrap/realm.js#L347-L360)
1820
//
19-
// This ensures we don't incorrectly unwrap .default on genuine ESM modules
21+
// Without a "module.exports" export, require(esm) returns the module namespace
22+
// object and exposes an ESM default export as its .default property:
23+
// https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require
24+
// https://tc39.es/ecma262/#sec-module-namespace-exotic-objects
25+
//
26+
// This ensures we don't incorrectly unwrap .default on genuine ESM modules.
27+
// The m.default != null guard allows genuine namespaces to have a nullish
28+
// default alongside named exports without dereferencing the nullish value.
2029
function __cjs_interop__(m: any) {
2130
return m.__cjs_module_runner_transform ||
31+
('module.exports' in m && m['module.exports'] === m.default) ||
2232
('default' in m &&
23-
Object.keys(m).every((k) => k === 'default' || m[k] === m.default[k]))
33+
Object.keys(m).every(
34+
(k) => k === 'default' || (m.default != null && m[k] === m.default[k]),
35+
))
2436
? m.default
2537
: m
2638
}

packages/plugin-rsc/src/transforms/fixtures/cjs/entry.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import testExternalFalsyPrimitive from './external-falsy-primitive.cjs'
66
import depFnRequire from './function-require.cjs'
77
import depFn from './function.cjs'
88
import cjsGlobals from './globals.cjs'
9+
import interop from './interop.cjs'
910
import testNodeBuiltins from './node-builtins.cjs'
1011
import depPrimitive from './primitive.cjs'
1112

@@ -20,4 +21,5 @@ export {
2021
cjsGlobals,
2122
testNodeBuiltins,
2223
testExternalFalsyPrimitive,
24+
interop,
2325
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Node require(esm) interop semantics:
2+
// https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require
3+
const callable = require('./marker-callable.mjs')
4+
const falsy = require('./marker-falsy.mjs')
5+
const defaultOnlyNull = require('./null-default-only.mjs')
6+
const genuineEsm = require('./null-default.mjs')
7+
8+
exports.markerCallable = callable() === 'ok'
9+
exports.markerFalsy = falsy === false
10+
exports.defaultOnlyNull = defaultOnlyNull === null
11+
exports.nullDefaultNamespace =
12+
genuineEsm.default === null && genuineEsm.named === 'ok'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const value = () => 'ok'
2+
3+
// Node uses this named export to return the value directly from require(esm).
4+
// https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require
5+
export { value as default, value as 'module.exports' }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const value = false
2+
3+
export { value as default, value as 'module.exports' }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default null
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const named = 'ok'
2+
export default null

0 commit comments

Comments
 (0)