From 30a4df4dbc16f236ce2a2d46b567322d8771e6ae Mon Sep 17 00:00:00 2001 From: Yuki Okita Date: Sun, 23 Nov 2025 02:15:28 +0900 Subject: [PATCH 1/2] fix(webpack-bundler-runtime): correct ESM default export handling for mjs files Fix ESM interop issue where .mjs files received module namespace objects instead of default exports when using Module Federation with remotes. The runtime now intelligently unwraps ESM namespace objects for object/function default exports while preserving the namespace for primitive defaults to maintain named export accessibility. --- .changeset/early-eggs-attack.md | 5 + .../__tests__/esm-interop.spec.ts | 381 ++++++++++++++++++ .../webpack-bundler-runtime/src/consumes.ts | 47 ++- .../src/installInitialConsumes.ts | 46 ++- 4 files changed, 467 insertions(+), 12 deletions(-) create mode 100644 .changeset/early-eggs-attack.md create mode 100644 packages/webpack-bundler-runtime/__tests__/esm-interop.spec.ts diff --git a/.changeset/early-eggs-attack.md b/.changeset/early-eggs-attack.md new file mode 100644 index 00000000000..0ad3c0e3123 --- /dev/null +++ b/.changeset/early-eggs-attack.md @@ -0,0 +1,5 @@ +--- +'@module-federation/webpack-bundler-runtime': patch +--- + +Resolve module (mjs) correctly on runtime by changing consumes.ts and installInitialConsumes.ts diff --git a/packages/webpack-bundler-runtime/__tests__/esm-interop.spec.ts b/packages/webpack-bundler-runtime/__tests__/esm-interop.spec.ts new file mode 100644 index 00000000000..916e8b82897 --- /dev/null +++ b/packages/webpack-bundler-runtime/__tests__/esm-interop.spec.ts @@ -0,0 +1,381 @@ +import { consumes } from '../src/consumes'; +import { installInitialConsumes } from '../src/installInitialConsumes'; +import type { + ConsumesOptions, + InstallInitialConsumesOptions, +} from '../src/types'; + +// Mock attachShareScopeMap as it's used in consumes +jest.mock('../src/attachShareScopeMap', () => ({ + attachShareScopeMap: jest.fn(), +})); + +describe('ESM Interop', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('consumes', () => { + test('should unwrap default export and add circular reference for ESM Namespace Object with object/function default', async () => { + const mockModuleId = 'esmModule'; + const mockPromises: Promise[] = []; + const mockDefaultExport = function defaultFn() { + return 'default'; + }; + const mockNamespaceObject = { + default: mockDefaultExport, + named: 'named', + [Symbol.toStringTag]: 'Module', + }; + + const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject); + // consumes uses loadShare which returns a promise of the factory + const mockLoadSharePromise = Promise.resolve(mockFactory); + + const mockFederationInstance = { + loadShare: jest.fn().mockReturnValue(mockLoadSharePromise), + }; + + const mockWebpackRequire = { + o: jest + .fn() + .mockImplementation((obj, key) => + Object.prototype.hasOwnProperty.call(obj, key), + ), + m: {}, + c: {}, + federation: { + instance: mockFederationInstance, + }, + }; + + const mockOptions: ConsumesOptions = { + chunkId: 'testChunkId', + promises: mockPromises, + chunkMapping: { + testChunkId: [mockModuleId], + }, + installedModules: {}, + moduleToHandlerMapping: { + [mockModuleId]: { + shareKey: 'shareKey', + getter: jest.fn(), + shareInfo: { + scope: ['default'], + shareConfig: { singleton: true, requiredVersion: '1.0.0' }, + }, + }, + }, + webpackRequire: mockWebpackRequire as any, + }; + + // Execute + consumes(mockOptions); + + // Wait for the promise to resolve + await mockPromises[0]; + + // Execute the installed module + const moduleObj = { exports: {} }; + mockWebpackRequire.m[mockModuleId](moduleObj); + + // Verify the fix: + // 1. module.exports should be the default export function itself + expect(moduleObj.exports).toBe(mockDefaultExport); + + // 2. module.exports.default should point to itself (circular reference) + expect((moduleObj.exports as any).default).toBe(moduleObj.exports); + + // 3. Named exports should be available on the function object + expect((moduleObj.exports as any).named).toBe('named'); + }); + + test('should NOT unwrap if not an ESM Namespace Object', async () => { + const mockModuleId = 'cjsModule'; + const mockPromises: Promise[] = []; + const mockExports = { + default: 'default', + named: 'named', + // No Symbol.toStringTag === 'Module' + }; + + const mockFactory = jest.fn().mockReturnValue(mockExports); + const mockLoadSharePromise = Promise.resolve(mockFactory); + + const mockFederationInstance = { + loadShare: jest.fn().mockReturnValue(mockLoadSharePromise), + }; + + const mockWebpackRequire = { + o: jest + .fn() + .mockImplementation((obj, key) => + Object.prototype.hasOwnProperty.call(obj, key), + ), + m: {}, + c: {}, + federation: { + instance: mockFederationInstance, + }, + }; + + const mockOptions: ConsumesOptions = { + chunkId: 'testChunkId', + promises: mockPromises, + chunkMapping: { + testChunkId: [mockModuleId], + }, + installedModules: {}, + moduleToHandlerMapping: { + [mockModuleId]: { + shareKey: 'shareKey', + getter: jest.fn(), + shareInfo: { + scope: ['default'], + shareConfig: { singleton: true, requiredVersion: '1.0.0' }, + }, + }, + }, + webpackRequire: mockWebpackRequire as any, + }; + + consumes(mockOptions); + await mockPromises[0]; + + const moduleObj = { exports: {} }; + mockWebpackRequire.m[mockModuleId](moduleObj); + + // Should be untouched + expect(moduleObj.exports).toBe(mockExports); + expect((moduleObj.exports as any).default).toBe('default'); + // Circular reference should NOT be added + expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports); + }); + + test('should NOT unwrap ESM Namespace Object with primitive default export', async () => { + const mockModuleId = 'esmPrimitiveModule'; + const mockPromises: Promise[] = []; + const mockNamespaceObject = { + default: 'primitiveDefault', + version: '1.3.4', + [Symbol.toStringTag]: 'Module', + }; + + const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject); + const mockLoadSharePromise = Promise.resolve(mockFactory); + + const mockFederationInstance = { + loadShare: jest.fn().mockReturnValue(mockLoadSharePromise), + }; + + const mockWebpackRequire = { + o: jest + .fn() + .mockImplementation((obj, key) => + Object.prototype.hasOwnProperty.call(obj, key), + ), + m: {}, + c: {}, + federation: { + instance: mockFederationInstance, + }, + }; + + const mockOptions: ConsumesOptions = { + chunkId: 'testChunkId', + promises: mockPromises, + chunkMapping: { + testChunkId: [mockModuleId], + }, + installedModules: {}, + moduleToHandlerMapping: { + [mockModuleId]: { + shareKey: 'shareKey', + getter: jest.fn(), + shareInfo: { + scope: ['default'], + shareConfig: { singleton: true, requiredVersion: '1.0.0' }, + }, + }, + }, + webpackRequire: mockWebpackRequire as any, + }; + + consumes(mockOptions); + await mockPromises[0]; + + const moduleObj = { exports: {} }; + mockWebpackRequire.m[mockModuleId](moduleObj); + + // Should keep original ESM namespace to preserve named exports + expect(moduleObj.exports).toBe(mockNamespaceObject); + expect((moduleObj.exports as any).default).toBe('primitiveDefault'); + expect((moduleObj.exports as any).version).toBe('1.3.4'); + // Should NOT have circular reference since we didn't unwrap + expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports); + }); + }); + + describe('installInitialConsumes', () => { + test('should unwrap default export and add circular reference for ESM Namespace Object with object/function default', () => { + const mockModuleId = 'esmModuleInitial'; + const mockDefaultExport = function defaultFn() { + return 'default'; + }; + const mockNamespaceObject = { + default: mockDefaultExport, + named: 'named', + [Symbol.toStringTag]: 'Module', + }; + + const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject); + + const mockFederationInstance = { + loadShareSync: jest.fn().mockReturnValue(mockFactory), + }; + + const mockWebpackRequire = { + m: {}, + c: {}, + federation: { + instance: mockFederationInstance, + }, + }; + + const mockOptions: InstallInitialConsumesOptions = { + moduleToHandlerMapping: { + [mockModuleId]: { + shareKey: 'shareKey', + getter: jest.fn(), + shareInfo: { + scope: ['default'], + shareConfig: { + singleton: true, + requiredVersion: '1.0.0', + }, + }, + }, + }, + webpackRequire: mockWebpackRequire as any, + installedModules: {}, + initialConsumes: [mockModuleId], + }; + + // Execute + installInitialConsumes(mockOptions); + + // Execute the installed module factory + const moduleObj = { exports: {} }; + mockWebpackRequire.m[mockModuleId](moduleObj); + + // Verify + expect(moduleObj.exports).toBe(mockDefaultExport); + expect((moduleObj.exports as any).default).toBe(moduleObj.exports); + expect((moduleObj.exports as any).named).toBe('named'); + }); + + test('should NOT unwrap if not an ESM Namespace Object', () => { + const mockModuleId = 'cjsModuleInitial'; + const mockExports = { + default: 'default', + named: 'named', + }; + + const mockFactory = jest.fn().mockReturnValue(mockExports); + + const mockFederationInstance = { + loadShareSync: jest.fn().mockReturnValue(mockFactory), + }; + + const mockWebpackRequire = { + m: {}, + c: {}, + federation: { + instance: mockFederationInstance, + }, + }; + + const mockOptions: InstallInitialConsumesOptions = { + moduleToHandlerMapping: { + [mockModuleId]: { + shareKey: 'shareKey', + getter: jest.fn(), + shareInfo: { + scope: ['default'], + shareConfig: { + singleton: true, + requiredVersion: '1.0.0', + }, + }, + }, + }, + webpackRequire: mockWebpackRequire as any, + installedModules: {}, + initialConsumes: [mockModuleId], + }; + + installInitialConsumes(mockOptions); + + const moduleObj = { exports: {} }; + mockWebpackRequire.m[mockModuleId](moduleObj); + + expect(moduleObj.exports).toBe(mockExports); + expect((moduleObj.exports as any).default).toBe('default'); + expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports); + }); + + test('should NOT unwrap ESM Namespace Object with primitive default export', () => { + const mockModuleId = 'esmPrimitiveModuleInitial'; + const mockNamespaceObject = { + default: 'primitiveDefault', + version: '1.3.4', + [Symbol.toStringTag]: 'Module', + }; + + const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject); + + const mockFederationInstance = { + loadShareSync: jest.fn().mockReturnValue(mockFactory), + }; + + const mockWebpackRequire = { + m: {}, + c: {}, + federation: { + instance: mockFederationInstance, + }, + }; + + const mockOptions: InstallInitialConsumesOptions = { + moduleToHandlerMapping: { + [mockModuleId]: { + shareKey: 'shareKey', + getter: jest.fn(), + shareInfo: { + scope: ['default'], + shareConfig: { + singleton: true, + requiredVersion: '1.0.0', + }, + }, + }, + }, + webpackRequire: mockWebpackRequire as any, + installedModules: {}, + initialConsumes: [mockModuleId], + }; + + installInitialConsumes(mockOptions); + + const moduleObj = { exports: {} }; + mockWebpackRequire.m[mockModuleId](moduleObj); + + // Should keep original ESM namespace to preserve named exports + expect(moduleObj.exports).toBe(mockNamespaceObject); + expect((moduleObj.exports as any).default).toBe('primitiveDefault'); + expect((moduleObj.exports as any).version).toBe('1.3.4'); + // Should NOT have circular reference since we didn't unwrap + expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports); + }); + }); +}); diff --git a/packages/webpack-bundler-runtime/src/consumes.ts b/packages/webpack-bundler-runtime/src/consumes.ts index 2c0ad375b9d..114ca2086b0 100644 --- a/packages/webpack-bundler-runtime/src/consumes.ts +++ b/packages/webpack-bundler-runtime/src/consumes.ts @@ -24,26 +24,61 @@ export function consumes(options: ConsumesOptions) { webpackRequire.m[id] = (module) => { delete webpackRequire.c[id]; const result = factory(); + let moduleExports = result; + + if ( + result && + typeof result === 'object' && + (result as any)[Symbol.toStringTag] === 'Module' + ) { + try { + const defaultExport = (result as any).default; + // Only unwrap if default export is an object or function + // For primitives, keep the original ESM namespace to preserve named exports + if ( + defaultExport && + (typeof defaultExport === 'object' || + typeof defaultExport === 'function') + ) { + moduleExports = defaultExport; + // Copy named exports to the unwrapped default + for (const key in result) { + if (key !== 'default' && !(key in (moduleExports as any))) { + Object.defineProperty(moduleExports as any, key, { + enumerable: true, + get: () => (result as any)[key], + }); + } + } + // Add circular reference for ESM interop + (moduleExports as any).default = moduleExports; + } + // If default is primitive, keep original result to preserve named exports + } catch (e) { + moduleExports = result; + } + } + // Add layer property from shareConfig if available const { shareInfo } = moduleToHandlerMapping[id]; if ( shareInfo?.shareConfig?.layer && - result && - typeof result === 'object' + moduleExports && + typeof moduleExports === 'object' ) { try { // Only set layer if it's not already defined or if it's undefined if ( - !result.hasOwnProperty('layer') || - (result as any).layer === undefined + !moduleExports.hasOwnProperty('layer') || + (moduleExports as any).layer === undefined ) { - (result as any).layer = shareInfo.shareConfig.layer; + (moduleExports as any).layer = shareInfo.shareConfig.layer; } } catch (e) { // Ignore if layer property is read-only } } - module.exports = result; + module.exports = moduleExports; }; }; const onError = (error: unknown) => { diff --git a/packages/webpack-bundler-runtime/src/installInitialConsumes.ts b/packages/webpack-bundler-runtime/src/installInitialConsumes.ts index fc4c0eb92c6..e6aaffaf810 100644 --- a/packages/webpack-bundler-runtime/src/installInitialConsumes.ts +++ b/packages/webpack-bundler-runtime/src/installInitialConsumes.ts @@ -47,26 +47,60 @@ export function installInitialConsumes(options: InstallInitialConsumesOptions) { ); } const result = factory(); + let moduleExports = result; + if ( + result && + typeof result === 'object' && + (result as any)[Symbol.toStringTag] === 'Module' + ) { + try { + const defaultExport = (result as any).default; + // Only unwrap if default export is an object or function + // For primitives, keep the original ESM namespace to preserve named exports + if ( + defaultExport && + (typeof defaultExport === 'object' || + typeof defaultExport === 'function') + ) { + moduleExports = defaultExport; + // Copy named exports to the unwrapped default + for (const key in result) { + if (key !== 'default' && !(key in (moduleExports as any))) { + Object.defineProperty(moduleExports as any, key, { + enumerable: true, + get: () => (result as any)[key], + }); + } + } + // Add circular reference for ESM interop + (moduleExports as any).default = moduleExports; + } + // If default is primitive, keep original result to preserve named exports + } catch (e) { + moduleExports = result; + } + } + // Add layer property from shareConfig if available const { shareInfo } = moduleToHandlerMapping[id]; if ( shareInfo?.shareConfig?.layer && - result && - typeof result === 'object' + moduleExports && + typeof moduleExports === 'object' ) { try { // Only set layer if it's not already defined or if it's undefined if ( - !result.hasOwnProperty('layer') || - (result as any).layer === undefined + !moduleExports.hasOwnProperty('layer') || + (moduleExports as any).layer === undefined ) { - (result as any).layer = shareInfo.shareConfig.layer; + (moduleExports as any).layer = shareInfo.shareConfig.layer; } } catch (e) { // Ignore if layer property is read-only } } - module.exports = result; + module.exports = moduleExports; }; }); } From 696a6379488b11570f2b0224ce63f2771da55450 Mon Sep 17 00:00:00 2001 From: Yuki Okita Date: Tue, 2 Dec 2025 21:59:08 +0900 Subject: [PATCH 2/2] fix(webpack-bundler-runtime): override by dynamic to resolve mjs issue fix mjs handling issue by introducing dynamic override --- .changeset/early-eggs-attack.md | 4 +- .../src/lib/container/RemoteModule.ts | 16 + .../src/lib/sharing/ConsumeSharedModule.ts | 16 + .../src/lib/sharing/ConsumeSharedPlugin.ts | 51 --- .../remote-module-mjs-default-export/index.js | 7 + .../pure-esm-consumer.mjs | 10 + .../test.config.js | 19 + .../webpack.config.js | 14 + .../index.js | 8 + .../node_modules/shared-esm-pkg/index.js | 5 + .../node_modules/shared-esm-pkg/package.json | 5 + .../pure-esm-consumer.mjs | 10 + .../webpack.config.js | 30 ++ .../__tests__/esm-interop.spec.ts | 381 ------------------ .../webpack-bundler-runtime/src/consumes.ts | 47 +-- .../src/installInitialConsumes.ts | 46 +-- 16 files changed, 154 insertions(+), 515 deletions(-) create mode 100644 packages/enhanced/test/configCases/container/remote-module-mjs-default-export/index.js create mode 100644 packages/enhanced/test/configCases/container/remote-module-mjs-default-export/pure-esm-consumer.mjs create mode 100644 packages/enhanced/test/configCases/container/remote-module-mjs-default-export/test.config.js create mode 100644 packages/enhanced/test/configCases/container/remote-module-mjs-default-export/webpack.config.js create mode 100644 packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/index.js create mode 100644 packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/node_modules/shared-esm-pkg/index.js create mode 100644 packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/node_modules/shared-esm-pkg/package.json create mode 100644 packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/pure-esm-consumer.mjs create mode 100644 packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/webpack.config.js delete mode 100644 packages/webpack-bundler-runtime/__tests__/esm-interop.spec.ts diff --git a/.changeset/early-eggs-attack.md b/.changeset/early-eggs-attack.md index 0ad3c0e3123..fedadcdf047 100644 --- a/.changeset/early-eggs-attack.md +++ b/.changeset/early-eggs-attack.md @@ -1,5 +1,5 @@ --- -'@module-federation/webpack-bundler-runtime': patch +'@module-federation/enhanced': patch --- -Resolve module (mjs) correctly on runtime by changing consumes.ts and installInitialConsumes.ts +Fix ESM default export handling for .mjs files by overriding getExportsType() in ConsumeSharedModule and RemoteModule to return "dynamic" diff --git a/packages/enhanced/src/lib/container/RemoteModule.ts b/packages/enhanced/src/lib/container/RemoteModule.ts index 73e2ede44b1..74844f2452f 100644 --- a/packages/enhanced/src/lib/container/RemoteModule.ts +++ b/packages/enhanced/src/lib/container/RemoteModule.ts @@ -147,6 +147,22 @@ class RemoteModule extends Module { return 6; } + /** + * @returns {string} the export type + * + * "dynamic" means: Check at runtime if __esModule is set. + * When set: namespace = { ...exports, default: exports } + * When not set: namespace = { default: exports } + */ + // @ts-ignore + override getExportsType(): + | 'namespace' + | 'default-only' + | 'default-with-named' + | 'dynamic' { + return 'dynamic'; + } + /** * @returns {Set} types available (do not mutate) */ diff --git a/packages/enhanced/src/lib/sharing/ConsumeSharedModule.ts b/packages/enhanced/src/lib/sharing/ConsumeSharedModule.ts index 94a29b0b488..b37936dae97 100644 --- a/packages/enhanced/src/lib/sharing/ConsumeSharedModule.ts +++ b/packages/enhanced/src/lib/sharing/ConsumeSharedModule.ts @@ -205,6 +205,22 @@ class ConsumeSharedModule extends Module { return 42; } + /** + * @returns {string} the export type + * + * "dynamic" means: Check at runtime if __esModule is set. + * When set: namespace = { ...exports, default: exports } + * When not set: namespace = { default: exports } + */ + // @ts-ignore + override getExportsType(): + | 'namespace' + | 'default-only' + | 'default-with-named' + | 'dynamic' { + return 'dynamic'; + } + /** * @param {Hash} hash the hash used to track dependencies * @param {UpdateHashContext} context context diff --git a/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts b/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts index 7ca621558a5..c015d1e5c29 100644 --- a/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts +++ b/packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts @@ -696,57 +696,6 @@ class ConsumeSharedPlugin { }, ); - // Add finishModules hook to copy buildMeta/buildInfo from fallback modules *after* webpack's export analysis - // Running earlier causes failures, so we intentionally execute later than plugins like FlagDependencyExportsPlugin. - // This still follows webpack's pattern used by FlagDependencyExportsPlugin and InferAsyncModulesPlugin, but with a - // later stage. Based on webpack's Compilation.js: finishModules (line 2833) runs before seal (line 2920). - compilation.hooks.finishModules.tapAsync( - { - name: PLUGIN_NAME, - stage: 10, // Run after FlagDependencyExportsPlugin (default stage 0) - }, - (modules, callback) => { - for (const module of modules) { - // Only process ConsumeSharedModule instances with fallback dependencies - if ( - !(module instanceof ConsumeSharedModule) || - !module.options.import - ) { - continue; - } - - let dependency; - if (module.options.eager) { - // For eager mode, get the fallback directly from dependencies - dependency = module.dependencies[0]; - } else { - // For async mode, get it from the async dependencies block - dependency = module.blocks[0]?.dependencies[0]; - } - - if (dependency) { - const fallbackModule = - compilation.moduleGraph.getModule(dependency); - if ( - fallbackModule && - fallbackModule.buildMeta && - fallbackModule.buildInfo - ) { - // Copy buildMeta and buildInfo following webpack's DelegatedModule pattern: this.buildMeta = { ...delegateData.buildMeta }; - // This ensures ConsumeSharedModule inherits ESM/CJS detection (exportsType) and other optimization metadata - module.buildMeta = { ...fallbackModule.buildMeta }; - module.buildInfo = { ...fallbackModule.buildInfo }; - // Mark all exports as provided, to avoid webpack's export analysis from marking them as unused since we copy buildMeta - compilation.moduleGraph - .getExportsInfo(module) - .setUnknownExportsProvided(); - } - } - } - callback(); - }, - ); - compilation.hooks.additionalTreeRuntimeRequirements.tap( PLUGIN_NAME, (chunk, set) => { diff --git a/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/index.js b/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/index.js new file mode 100644 index 00000000000..f9ff030a0b8 --- /dev/null +++ b/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/index.js @@ -0,0 +1,7 @@ +it('should correctly handle default imports in .mjs files from remote modules', async () => { + const { testDefaultImport } = await import('./pure-esm-consumer.mjs'); + const result = testDefaultImport(); + expect(result.defaultType).toBe('function'); + expect(result.defaultValue).toBe('remote default export'); + expect(result.namedExportValue).toBe('remote named export'); +}); diff --git a/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/pure-esm-consumer.mjs b/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/pure-esm-consumer.mjs new file mode 100644 index 00000000000..0a74e985ca9 --- /dev/null +++ b/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/pure-esm-consumer.mjs @@ -0,0 +1,10 @@ +import something from 'remote-esm-pkg/module'; +import { namedExport } from 'remote-esm-pkg/module'; + +export function testDefaultImport() { + return { + defaultType: typeof something, + defaultValue: typeof something === 'function' ? something() : something, + namedExportValue: namedExport, + }; +} diff --git a/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/test.config.js b/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/test.config.js new file mode 100644 index 00000000000..468f8fed935 --- /dev/null +++ b/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/test.config.js @@ -0,0 +1,19 @@ +module.exports = { + moduleScope(scope) { + scope.REMOTE_ESM_PKG = { + get(module) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(() => ({ + __esModule: true, + default: function remoteFunction() { + return 'remote default export'; + }, + namedExport: 'remote named export', + })); + }, 100); + }); + }, + }; + }, +}; diff --git a/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/webpack.config.js b/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/webpack.config.js new file mode 100644 index 00000000000..e351532ca0f --- /dev/null +++ b/packages/enhanced/test/configCases/container/remote-module-mjs-default-export/webpack.config.js @@ -0,0 +1,14 @@ +const { ContainerReferencePlugin } = require('../../../../dist/src'); + +module.exports = { + mode: 'development', + devtool: false, + plugins: [ + new ContainerReferencePlugin({ + remoteType: 'var', + remotes: { + 'remote-esm-pkg': 'REMOTE_ESM_PKG', + }, + }), + ], +}; diff --git a/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/index.js b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/index.js new file mode 100644 index 00000000000..df01290358b --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/index.js @@ -0,0 +1,8 @@ +it('should correctly handle default imports in .mjs files from shared modules', async () => { + await __webpack_init_sharing__('default'); + const { testDefaultImport } = await import('./pure-esm-consumer.mjs'); + const result = testDefaultImport(); + expect(result.defaultType).toBe('function'); + expect(result.defaultValue).toBe('shared default export'); + expect(result.namedExportValue).toBe('shared named export'); +}); diff --git a/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/node_modules/shared-esm-pkg/index.js b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/node_modules/shared-esm-pkg/index.js new file mode 100644 index 00000000000..7512ee45979 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/node_modules/shared-esm-pkg/index.js @@ -0,0 +1,5 @@ +export default function sharedFunction() { + return 'shared default export'; +} + +export const namedExport = 'shared named export'; diff --git a/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/node_modules/shared-esm-pkg/package.json b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/node_modules/shared-esm-pkg/package.json new file mode 100644 index 00000000000..7fac6b2a9f8 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/node_modules/shared-esm-pkg/package.json @@ -0,0 +1,5 @@ +{ + "name": "shared-esm-pkg", + "version": "1.0.0", + "type": "module" +} diff --git a/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/pure-esm-consumer.mjs b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/pure-esm-consumer.mjs new file mode 100644 index 00000000000..b26e0cfe3cc --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/pure-esm-consumer.mjs @@ -0,0 +1,10 @@ +import something from 'shared-esm-pkg'; +import { namedExport } from 'shared-esm-pkg'; + +export function testDefaultImport() { + return { + defaultType: typeof something, + defaultValue: typeof something === 'function' ? something() : something, + namedExportValue: namedExport, + }; +} diff --git a/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/webpack.config.js b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/webpack.config.js new file mode 100644 index 00000000000..85b2a931fc0 --- /dev/null +++ b/packages/enhanced/test/configCases/sharing/consume-module-mjs-default-export/webpack.config.js @@ -0,0 +1,30 @@ +const { + ConsumeSharedPlugin, + ProvideSharedPlugin, +} = require('../../../../dist/src'); + +module.exports = { + mode: 'development', + devtool: false, + plugins: [ + new ProvideSharedPlugin({ + provides: { + 'shared-esm-pkg': { + shareKey: 'shared-esm-pkg', + version: '1.0.0', + eager: true, + }, + }, + }), + new ConsumeSharedPlugin({ + consumes: { + 'shared-esm-pkg': { + shareKey: 'shared-esm-pkg', + requiredVersion: '^1.0.0', + strictVersion: false, + eager: true, + }, + }, + }), + ], +}; diff --git a/packages/webpack-bundler-runtime/__tests__/esm-interop.spec.ts b/packages/webpack-bundler-runtime/__tests__/esm-interop.spec.ts deleted file mode 100644 index 916e8b82897..00000000000 --- a/packages/webpack-bundler-runtime/__tests__/esm-interop.spec.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { consumes } from '../src/consumes'; -import { installInitialConsumes } from '../src/installInitialConsumes'; -import type { - ConsumesOptions, - InstallInitialConsumesOptions, -} from '../src/types'; - -// Mock attachShareScopeMap as it's used in consumes -jest.mock('../src/attachShareScopeMap', () => ({ - attachShareScopeMap: jest.fn(), -})); - -describe('ESM Interop', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('consumes', () => { - test('should unwrap default export and add circular reference for ESM Namespace Object with object/function default', async () => { - const mockModuleId = 'esmModule'; - const mockPromises: Promise[] = []; - const mockDefaultExport = function defaultFn() { - return 'default'; - }; - const mockNamespaceObject = { - default: mockDefaultExport, - named: 'named', - [Symbol.toStringTag]: 'Module', - }; - - const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject); - // consumes uses loadShare which returns a promise of the factory - const mockLoadSharePromise = Promise.resolve(mockFactory); - - const mockFederationInstance = { - loadShare: jest.fn().mockReturnValue(mockLoadSharePromise), - }; - - const mockWebpackRequire = { - o: jest - .fn() - .mockImplementation((obj, key) => - Object.prototype.hasOwnProperty.call(obj, key), - ), - m: {}, - c: {}, - federation: { - instance: mockFederationInstance, - }, - }; - - const mockOptions: ConsumesOptions = { - chunkId: 'testChunkId', - promises: mockPromises, - chunkMapping: { - testChunkId: [mockModuleId], - }, - installedModules: {}, - moduleToHandlerMapping: { - [mockModuleId]: { - shareKey: 'shareKey', - getter: jest.fn(), - shareInfo: { - scope: ['default'], - shareConfig: { singleton: true, requiredVersion: '1.0.0' }, - }, - }, - }, - webpackRequire: mockWebpackRequire as any, - }; - - // Execute - consumes(mockOptions); - - // Wait for the promise to resolve - await mockPromises[0]; - - // Execute the installed module - const moduleObj = { exports: {} }; - mockWebpackRequire.m[mockModuleId](moduleObj); - - // Verify the fix: - // 1. module.exports should be the default export function itself - expect(moduleObj.exports).toBe(mockDefaultExport); - - // 2. module.exports.default should point to itself (circular reference) - expect((moduleObj.exports as any).default).toBe(moduleObj.exports); - - // 3. Named exports should be available on the function object - expect((moduleObj.exports as any).named).toBe('named'); - }); - - test('should NOT unwrap if not an ESM Namespace Object', async () => { - const mockModuleId = 'cjsModule'; - const mockPromises: Promise[] = []; - const mockExports = { - default: 'default', - named: 'named', - // No Symbol.toStringTag === 'Module' - }; - - const mockFactory = jest.fn().mockReturnValue(mockExports); - const mockLoadSharePromise = Promise.resolve(mockFactory); - - const mockFederationInstance = { - loadShare: jest.fn().mockReturnValue(mockLoadSharePromise), - }; - - const mockWebpackRequire = { - o: jest - .fn() - .mockImplementation((obj, key) => - Object.prototype.hasOwnProperty.call(obj, key), - ), - m: {}, - c: {}, - federation: { - instance: mockFederationInstance, - }, - }; - - const mockOptions: ConsumesOptions = { - chunkId: 'testChunkId', - promises: mockPromises, - chunkMapping: { - testChunkId: [mockModuleId], - }, - installedModules: {}, - moduleToHandlerMapping: { - [mockModuleId]: { - shareKey: 'shareKey', - getter: jest.fn(), - shareInfo: { - scope: ['default'], - shareConfig: { singleton: true, requiredVersion: '1.0.0' }, - }, - }, - }, - webpackRequire: mockWebpackRequire as any, - }; - - consumes(mockOptions); - await mockPromises[0]; - - const moduleObj = { exports: {} }; - mockWebpackRequire.m[mockModuleId](moduleObj); - - // Should be untouched - expect(moduleObj.exports).toBe(mockExports); - expect((moduleObj.exports as any).default).toBe('default'); - // Circular reference should NOT be added - expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports); - }); - - test('should NOT unwrap ESM Namespace Object with primitive default export', async () => { - const mockModuleId = 'esmPrimitiveModule'; - const mockPromises: Promise[] = []; - const mockNamespaceObject = { - default: 'primitiveDefault', - version: '1.3.4', - [Symbol.toStringTag]: 'Module', - }; - - const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject); - const mockLoadSharePromise = Promise.resolve(mockFactory); - - const mockFederationInstance = { - loadShare: jest.fn().mockReturnValue(mockLoadSharePromise), - }; - - const mockWebpackRequire = { - o: jest - .fn() - .mockImplementation((obj, key) => - Object.prototype.hasOwnProperty.call(obj, key), - ), - m: {}, - c: {}, - federation: { - instance: mockFederationInstance, - }, - }; - - const mockOptions: ConsumesOptions = { - chunkId: 'testChunkId', - promises: mockPromises, - chunkMapping: { - testChunkId: [mockModuleId], - }, - installedModules: {}, - moduleToHandlerMapping: { - [mockModuleId]: { - shareKey: 'shareKey', - getter: jest.fn(), - shareInfo: { - scope: ['default'], - shareConfig: { singleton: true, requiredVersion: '1.0.0' }, - }, - }, - }, - webpackRequire: mockWebpackRequire as any, - }; - - consumes(mockOptions); - await mockPromises[0]; - - const moduleObj = { exports: {} }; - mockWebpackRequire.m[mockModuleId](moduleObj); - - // Should keep original ESM namespace to preserve named exports - expect(moduleObj.exports).toBe(mockNamespaceObject); - expect((moduleObj.exports as any).default).toBe('primitiveDefault'); - expect((moduleObj.exports as any).version).toBe('1.3.4'); - // Should NOT have circular reference since we didn't unwrap - expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports); - }); - }); - - describe('installInitialConsumes', () => { - test('should unwrap default export and add circular reference for ESM Namespace Object with object/function default', () => { - const mockModuleId = 'esmModuleInitial'; - const mockDefaultExport = function defaultFn() { - return 'default'; - }; - const mockNamespaceObject = { - default: mockDefaultExport, - named: 'named', - [Symbol.toStringTag]: 'Module', - }; - - const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject); - - const mockFederationInstance = { - loadShareSync: jest.fn().mockReturnValue(mockFactory), - }; - - const mockWebpackRequire = { - m: {}, - c: {}, - federation: { - instance: mockFederationInstance, - }, - }; - - const mockOptions: InstallInitialConsumesOptions = { - moduleToHandlerMapping: { - [mockModuleId]: { - shareKey: 'shareKey', - getter: jest.fn(), - shareInfo: { - scope: ['default'], - shareConfig: { - singleton: true, - requiredVersion: '1.0.0', - }, - }, - }, - }, - webpackRequire: mockWebpackRequire as any, - installedModules: {}, - initialConsumes: [mockModuleId], - }; - - // Execute - installInitialConsumes(mockOptions); - - // Execute the installed module factory - const moduleObj = { exports: {} }; - mockWebpackRequire.m[mockModuleId](moduleObj); - - // Verify - expect(moduleObj.exports).toBe(mockDefaultExport); - expect((moduleObj.exports as any).default).toBe(moduleObj.exports); - expect((moduleObj.exports as any).named).toBe('named'); - }); - - test('should NOT unwrap if not an ESM Namespace Object', () => { - const mockModuleId = 'cjsModuleInitial'; - const mockExports = { - default: 'default', - named: 'named', - }; - - const mockFactory = jest.fn().mockReturnValue(mockExports); - - const mockFederationInstance = { - loadShareSync: jest.fn().mockReturnValue(mockFactory), - }; - - const mockWebpackRequire = { - m: {}, - c: {}, - federation: { - instance: mockFederationInstance, - }, - }; - - const mockOptions: InstallInitialConsumesOptions = { - moduleToHandlerMapping: { - [mockModuleId]: { - shareKey: 'shareKey', - getter: jest.fn(), - shareInfo: { - scope: ['default'], - shareConfig: { - singleton: true, - requiredVersion: '1.0.0', - }, - }, - }, - }, - webpackRequire: mockWebpackRequire as any, - installedModules: {}, - initialConsumes: [mockModuleId], - }; - - installInitialConsumes(mockOptions); - - const moduleObj = { exports: {} }; - mockWebpackRequire.m[mockModuleId](moduleObj); - - expect(moduleObj.exports).toBe(mockExports); - expect((moduleObj.exports as any).default).toBe('default'); - expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports); - }); - - test('should NOT unwrap ESM Namespace Object with primitive default export', () => { - const mockModuleId = 'esmPrimitiveModuleInitial'; - const mockNamespaceObject = { - default: 'primitiveDefault', - version: '1.3.4', - [Symbol.toStringTag]: 'Module', - }; - - const mockFactory = jest.fn().mockReturnValue(mockNamespaceObject); - - const mockFederationInstance = { - loadShareSync: jest.fn().mockReturnValue(mockFactory), - }; - - const mockWebpackRequire = { - m: {}, - c: {}, - federation: { - instance: mockFederationInstance, - }, - }; - - const mockOptions: InstallInitialConsumesOptions = { - moduleToHandlerMapping: { - [mockModuleId]: { - shareKey: 'shareKey', - getter: jest.fn(), - shareInfo: { - scope: ['default'], - shareConfig: { - singleton: true, - requiredVersion: '1.0.0', - }, - }, - }, - }, - webpackRequire: mockWebpackRequire as any, - installedModules: {}, - initialConsumes: [mockModuleId], - }; - - installInitialConsumes(mockOptions); - - const moduleObj = { exports: {} }; - mockWebpackRequire.m[mockModuleId](moduleObj); - - // Should keep original ESM namespace to preserve named exports - expect(moduleObj.exports).toBe(mockNamespaceObject); - expect((moduleObj.exports as any).default).toBe('primitiveDefault'); - expect((moduleObj.exports as any).version).toBe('1.3.4'); - // Should NOT have circular reference since we didn't unwrap - expect((moduleObj.exports as any).default).not.toBe(moduleObj.exports); - }); - }); -}); diff --git a/packages/webpack-bundler-runtime/src/consumes.ts b/packages/webpack-bundler-runtime/src/consumes.ts index 114ca2086b0..2c0ad375b9d 100644 --- a/packages/webpack-bundler-runtime/src/consumes.ts +++ b/packages/webpack-bundler-runtime/src/consumes.ts @@ -24,61 +24,26 @@ export function consumes(options: ConsumesOptions) { webpackRequire.m[id] = (module) => { delete webpackRequire.c[id]; const result = factory(); - let moduleExports = result; - - if ( - result && - typeof result === 'object' && - (result as any)[Symbol.toStringTag] === 'Module' - ) { - try { - const defaultExport = (result as any).default; - // Only unwrap if default export is an object or function - // For primitives, keep the original ESM namespace to preserve named exports - if ( - defaultExport && - (typeof defaultExport === 'object' || - typeof defaultExport === 'function') - ) { - moduleExports = defaultExport; - // Copy named exports to the unwrapped default - for (const key in result) { - if (key !== 'default' && !(key in (moduleExports as any))) { - Object.defineProperty(moduleExports as any, key, { - enumerable: true, - get: () => (result as any)[key], - }); - } - } - // Add circular reference for ESM interop - (moduleExports as any).default = moduleExports; - } - // If default is primitive, keep original result to preserve named exports - } catch (e) { - moduleExports = result; - } - } - // Add layer property from shareConfig if available const { shareInfo } = moduleToHandlerMapping[id]; if ( shareInfo?.shareConfig?.layer && - moduleExports && - typeof moduleExports === 'object' + result && + typeof result === 'object' ) { try { // Only set layer if it's not already defined or if it's undefined if ( - !moduleExports.hasOwnProperty('layer') || - (moduleExports as any).layer === undefined + !result.hasOwnProperty('layer') || + (result as any).layer === undefined ) { - (moduleExports as any).layer = shareInfo.shareConfig.layer; + (result as any).layer = shareInfo.shareConfig.layer; } } catch (e) { // Ignore if layer property is read-only } } - module.exports = moduleExports; + module.exports = result; }; }; const onError = (error: unknown) => { diff --git a/packages/webpack-bundler-runtime/src/installInitialConsumes.ts b/packages/webpack-bundler-runtime/src/installInitialConsumes.ts index e6aaffaf810..fc4c0eb92c6 100644 --- a/packages/webpack-bundler-runtime/src/installInitialConsumes.ts +++ b/packages/webpack-bundler-runtime/src/installInitialConsumes.ts @@ -47,60 +47,26 @@ export function installInitialConsumes(options: InstallInitialConsumesOptions) { ); } const result = factory(); - let moduleExports = result; - if ( - result && - typeof result === 'object' && - (result as any)[Symbol.toStringTag] === 'Module' - ) { - try { - const defaultExport = (result as any).default; - // Only unwrap if default export is an object or function - // For primitives, keep the original ESM namespace to preserve named exports - if ( - defaultExport && - (typeof defaultExport === 'object' || - typeof defaultExport === 'function') - ) { - moduleExports = defaultExport; - // Copy named exports to the unwrapped default - for (const key in result) { - if (key !== 'default' && !(key in (moduleExports as any))) { - Object.defineProperty(moduleExports as any, key, { - enumerable: true, - get: () => (result as any)[key], - }); - } - } - // Add circular reference for ESM interop - (moduleExports as any).default = moduleExports; - } - // If default is primitive, keep original result to preserve named exports - } catch (e) { - moduleExports = result; - } - } - // Add layer property from shareConfig if available const { shareInfo } = moduleToHandlerMapping[id]; if ( shareInfo?.shareConfig?.layer && - moduleExports && - typeof moduleExports === 'object' + result && + typeof result === 'object' ) { try { // Only set layer if it's not already defined or if it's undefined if ( - !moduleExports.hasOwnProperty('layer') || - (moduleExports as any).layer === undefined + !result.hasOwnProperty('layer') || + (result as any).layer === undefined ) { - (moduleExports as any).layer = shareInfo.shareConfig.layer; + (result as any).layer = shareInfo.shareConfig.layer; } } catch (e) { // Ignore if layer property is read-only } } - module.exports = moduleExports; + module.exports = result; }; }); }