From 96677b079427922573c18433b72c85f8f492ec74 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Sun, 21 Jun 2026 23:04:01 +0200 Subject: [PATCH] fix(rsc): harden CommonJS namespace interop --- packages/plugin-rsc/src/transforms/cjs.test.ts | 12 +++++++++--- packages/plugin-rsc/src/transforms/cjs.ts | 17 ++++++++++++++--- .../src/transforms/fixtures/cjs/entry.mjs | 2 ++ .../src/transforms/fixtures/cjs/interop.cjs | 12 ++++++++++++ .../transforms/fixtures/cjs/marker-callable.mjs | 5 +++++ .../transforms/fixtures/cjs/marker-falsy.mjs | 3 +++ .../fixtures/cjs/null-default-only.mjs | 1 + .../transforms/fixtures/cjs/null-default.mjs | 2 ++ 8 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 packages/plugin-rsc/src/transforms/fixtures/cjs/interop.cjs create mode 100644 packages/plugin-rsc/src/transforms/fixtures/cjs/marker-callable.mjs create mode 100644 packages/plugin-rsc/src/transforms/fixtures/cjs/marker-falsy.mjs create mode 100644 packages/plugin-rsc/src/transforms/fixtures/cjs/null-default-only.mjs create mode 100644 packages/plugin-rsc/src/transforms/fixtures/cjs/null-default.mjs diff --git a/packages/plugin-rsc/src/transforms/cjs.test.ts b/packages/plugin-rsc/src/transforms/cjs.test.ts index 8a71f2c53..be2c11fe7 100644 --- a/packages/plugin-rsc/src/transforms/cjs.test.ts +++ b/packages/plugin-rsc/src/transforms/cjs.test.ts @@ -44,7 +44,7 @@ if (true) { expect(await testTransform(input)).toMatchInlineSnapshot(` "let __filename = "/test.js"; let __dirname = "/"; let exports = {}; const module = { exports }; - 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;} + 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;} if (true) { module.exports = (__cjs_interop__(await import('./cjs/use-sync-external-store.production.js'))); } else { @@ -69,7 +69,7 @@ if (true) { expect(await testTransform(input)).toMatchInlineSnapshot(` "let __filename = "/test.js"; let __dirname = "/"; let exports = {}; const module = { exports }; - 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;} + 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;} const __cjs_to_esm_hoist_0 = __cjs_interop__(await import("react")); const __cjs_to_esm_hoist_1 = __cjs_interop__(await import("react-dom")); "production" !== process.env.NODE_ENV && (function() { @@ -100,7 +100,7 @@ function test() { expect(await testTransform(input)).toMatchInlineSnapshot(` "let __filename = "/test.js"; let __dirname = "/"; let exports = {}; const module = { exports }; - 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;} + 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;} const __cjs_to_esm_hoist_0 = __cjs_interop__(await import("te" + "st")); const __cjs_to_esm_hoist_1 = __cjs_interop__(await import("test")); const __cjs_to_esm_hoist_2 = __cjs_interop__(await import("test")); @@ -196,6 +196,12 @@ function test() { }, "depPrimitive": "[ok]", "dualLib": "ok", + "interop": { + "defaultOnlyNull": true, + "markerCallable": true, + "markerFalsy": true, + "nullDefaultNamespace": true, + }, "testExternalFalsyPrimitive": { "ok": true, }, diff --git a/packages/plugin-rsc/src/transforms/cjs.ts b/packages/plugin-rsc/src/transforms/cjs.ts index 20cfb1193..d475f560a 100644 --- a/packages/plugin-rsc/src/transforms/cjs.ts +++ b/packages/plugin-rsc/src/transforms/cjs.ts @@ -11,16 +11,27 @@ import { buildScopeTree } from './scope' // Runtime helper to handle CJS/ESM interop when transforming require() to import() // Unwrap .default for modules // 1. if it was transformed by this plugin (marked with __cjs_module_runner_transform) -// 2. if all named exports point to .default properties (common CJS pattern) +// 2. if Node marks .default as the exact CommonJS module.exports value +// https://nodejs.org/api/esm.html#commonjs-namespaces +// 3. if all named exports point to .default properties (common CJS pattern) // this is particularly important for Node built-in modules consumptions; // where the built-in modules are not transformed by this plugin but still follow the CJS export pattern // see [getESMFacade](https://github.com/nodejs/node/blob/f200685d9930404d610a52d9e06513bf0a821ed4/lib/internal/bootstrap/realm.js#L347-L360) // -// This ensures we don't incorrectly unwrap .default on genuine ESM modules +// Without a "module.exports" export, require(esm) returns the module namespace +// object and exposes an ESM default export as its .default property: +// https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require +// https://tc39.es/ecma262/#sec-module-namespace-exotic-objects +// +// Keep genuine ESM namespaces intact; the null guard also avoids dereferencing +// a nullish default when the namespace has named exports. function __cjs_interop__(m: any) { 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[k] === m.default[k])) + Object.keys(m).every( + (k) => k === 'default' || (m.default != null && m[k] === m.default[k]), + )) ? m.default : m } diff --git a/packages/plugin-rsc/src/transforms/fixtures/cjs/entry.mjs b/packages/plugin-rsc/src/transforms/fixtures/cjs/entry.mjs index 997d67b05..3398ad7db 100644 --- a/packages/plugin-rsc/src/transforms/fixtures/cjs/entry.mjs +++ b/packages/plugin-rsc/src/transforms/fixtures/cjs/entry.mjs @@ -6,6 +6,7 @@ import testExternalFalsyPrimitive from './external-falsy-primitive.cjs' import depFnRequire from './function-require.cjs' import depFn from './function.cjs' import cjsGlobals from './globals.cjs' +import interop from './interop.cjs' import testNodeBuiltins from './node-builtins.cjs' import depPrimitive from './primitive.cjs' @@ -20,4 +21,5 @@ export { cjsGlobals, testNodeBuiltins, testExternalFalsyPrimitive, + interop, } diff --git a/packages/plugin-rsc/src/transforms/fixtures/cjs/interop.cjs b/packages/plugin-rsc/src/transforms/fixtures/cjs/interop.cjs new file mode 100644 index 000000000..8bd0c2ce3 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/cjs/interop.cjs @@ -0,0 +1,12 @@ +// Node require(esm) interop semantics: +// https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require +const callable = require('./marker-callable.mjs') +const falsy = require('./marker-falsy.mjs') +const defaultOnlyNull = require('./null-default-only.mjs') +const genuineEsm = require('./null-default.mjs') + +exports.markerCallable = callable() === 'ok' +exports.markerFalsy = falsy === false +exports.defaultOnlyNull = defaultOnlyNull === null +exports.nullDefaultNamespace = + genuineEsm.default === null && genuineEsm.named === 'ok' diff --git a/packages/plugin-rsc/src/transforms/fixtures/cjs/marker-callable.mjs b/packages/plugin-rsc/src/transforms/fixtures/cjs/marker-callable.mjs new file mode 100644 index 000000000..99f5b6a7e --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/cjs/marker-callable.mjs @@ -0,0 +1,5 @@ +const value = () => 'ok' + +// Node uses this named export to return the value directly from require(esm). +// https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require +export { value as default, value as 'module.exports' } diff --git a/packages/plugin-rsc/src/transforms/fixtures/cjs/marker-falsy.mjs b/packages/plugin-rsc/src/transforms/fixtures/cjs/marker-falsy.mjs new file mode 100644 index 000000000..856e0194d --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/cjs/marker-falsy.mjs @@ -0,0 +1,3 @@ +const value = false + +export { value as default, value as 'module.exports' } diff --git a/packages/plugin-rsc/src/transforms/fixtures/cjs/null-default-only.mjs b/packages/plugin-rsc/src/transforms/fixtures/cjs/null-default-only.mjs new file mode 100644 index 000000000..7b8595488 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/cjs/null-default-only.mjs @@ -0,0 +1 @@ +export default null diff --git a/packages/plugin-rsc/src/transforms/fixtures/cjs/null-default.mjs b/packages/plugin-rsc/src/transforms/fixtures/cjs/null-default.mjs new file mode 100644 index 000000000..2efb61d9b --- /dev/null +++ b/packages/plugin-rsc/src/transforms/fixtures/cjs/null-default.mjs @@ -0,0 +1,2 @@ +export const named = 'ok' +export default null