Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/early-eggs-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/enhanced': patch
---

Fix ESM default export handling for .mjs files by overriding getExportsType() in ConsumeSharedModule and RemoteModule to return "dynamic"
16 changes: 16 additions & 0 deletions packages/enhanced/src/lib/container/RemoteModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} types available (do not mutate)
*/
Expand Down
16 changes: 16 additions & 0 deletions packages/enhanced/src/lib/sharing/ConsumeSharedModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 0 additions & 51 deletions packages/enhanced/src/lib/sharing/ConsumeSharedPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,57 +696,6 @@ class ConsumeSharedPlugin {
},
);

// Add finishModules hook to copy buildMeta/buildInfo from fallback modules *after* webpack's export analysis
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for this change is that:

  1. since I introduced "dynamic", this tells webpack to generate __wepack_require__.n() at compile time, which properly handles the ESM/CJS interop. The deleted codes are to inherit the ESM/CJS detection from the actual shared module, which is no longer necessary
  2. As explained in the description section, this can't deal with federation-remote case
  3. Instead of copying buildMeta at runtime, it's better to deal with it at compilation time

// 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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
@@ -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,
};
}
Original file line number Diff line number Diff line change
@@ -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);
});
},
};
},
};
Original file line number Diff line number Diff line change
@@ -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',
},
}),
],
};
Original file line number Diff line number Diff line change
@@ -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');
});

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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,
};
}
Original file line number Diff line number Diff line change
@@ -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,
},
},
}),
],
};