Skip to content

fix ESM default export handling for .mjs files in Module Federation#20189

Merged
alexander-akait merged 6 commits intowebpack:mainfrom
y-okt:fix-mjs-on-federation-remote-dynamic
Dec 20, 2025
Merged

fix ESM default export handling for .mjs files in Module Federation#20189
alexander-akait merged 6 commits intowebpack:mainfrom
y-okt:fix-mjs-on-federation-remote-dynamic

Conversation

@y-okt
Copy link
Contributor

@y-okt y-okt commented Nov 29, 2025

Summary

Fixes: #16125

When .mjs files import a default export from a shared/remote module in Module Federation, they receive the ESM namespace object instead of the actual default export value.

The root cause was that ConsumeSharedModule and RemoteModule have empty buildMeta (no exportsType). When .mjs files (which have strictHarmonyModule: true) import from these modules, getExportsType() returns "default-with-named", causing webpack to generate code that expects .default to be pre-unwrapped. But the federation runtime simply passes through the namespace object.

Explaining in more detail,

  1. the _default wrapper is created uniquely for "dynamic", but not for "default-with-named" (code)
// "default-with-named"
var _pkg = __webpack_require__("shared-pkg");

// "dynamic"
var _pkg = __webpack_require__("shared-pkg");
var _pkg_default = __webpack_require__.n(_pkg);  // ← This is added
  1. when import something from "shared-pkg", exportName will be ["default"], and when "default-with-named", exportName will be an empty array (code). By this logic, the entire importVar will be returned (code).

  2. Module Federation's ConsumeSharedModule returns shared module's export object entirely (code).

{
    __esModule: true,
    default: function actualFunction() { ... },
    namedExport: "value" 
}
  1. As a result,
// "default-with-named"
var _pkg = __webpack_require__("shared-pkg");

var something = _pkg;  // ← entire namespace object

// "dynamic"
var _pkg = __webpack_require__("shared-pkg");
var _pkg_default = __webpack_require__.n(_pkg);

var something = _pkg_default();  // ← calls function that returns .default

What kind of change does this PR introduce?

As aforementioned in the detailed section, dynamic can insert handlings for the default import. If we avoid using "default-with-named" but "dynamic", we can resolve this issue.

Another solution is to unwrap the default export at runtime, but this breaks the original webpack's approach to process at compilation time, and also can complicate the implementation due to its complexity.

Did you add tests for your changes?

Yes

Does this PR introduce a breaking change?

No. This will fix the bug reported in #16125

If relevant, what needs to be documented once your changes are merged or what have you already documented?
No

@changeset-bot
Copy link

changeset-bot bot commented Nov 29, 2025

🦋 Changeset detected

Latest commit: 76c88e4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
webpack Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@linux-foundation-easycla
Copy link

linux-foundation-easycla bot commented Nov 29, 2025

CLA Signed

The committers listed above are authorized under a signed CLA.

@y-okt y-okt changed the title lib: fix ESM default export handling for .mjs files in Module Federation fix ESM default export handling for .mjs files in Module Federation Nov 29, 2025
@codspeed-hq
Copy link

codspeed-hq bot commented Dec 1, 2025

CodSpeed Performance Report

Merging #20189 will not alter performance

Comparing y-okt:fix-mjs-on-federation-remote-dynamic (76c88e4) with main (2eb0d6a)

Summary

✅ 72 untouched

@y-okt y-okt force-pushed the fix-mjs-on-federation-remote-dynamic branch from d32be4e to b7a7123 Compare December 1, 2025 19:41
@y-okt y-okt requested a review from alexander-akait December 1, 2025 23:32
@y-okt
Copy link
Contributor Author

y-okt commented Dec 2, 2025

@alexander-akait Thank you for approving! It seems that CI is saying "Waiting for status to be reported". Do I need to do anything here?

@alexander-akait
Copy link
Member

@y-okt No, I am waiting for review from other developers and we can merge

@y-okt
Copy link
Contributor Author

y-okt commented Dec 4, 2025

@alexander-akait I see, thank you for letting me know!

@hai-x
Copy link
Member

hai-x commented Dec 13, 2025

Looks like the issue involves strict ESM module (.mjs) importing another strict ESM module (.mjs). In this case, using the dynamic exportType doesn’t seem appropriate. We’ll likely need additional logic to handle it correctly.

@y-okt
Copy link
Contributor Author

y-okt commented Dec 13, 2025

@hai-x Hi, thank you for reviewing. My understanding on what happens on module federation is that:

app.mjs
      │
      └──→ imports "shared-pkg"
                │
                └──→ ConsumeSharedModule (wrapper created by Module Federation)
                          │
                          └──→ At runtime: Resolved from host's share scope
                               (NOT the .mjs file in consumer's node_modules)

The format can differ when the host has a different package version that uses a different module format (e.g., older version before ESM was added), or when resolve conditions differ between host and consumer.
While this mismatch is rare in well-coordinated setups, "dynamic" safely handles all cases by checking __esModule at runtime.
Module federation is inherently runtime-resolved and we genuinely don't know the module format at compile time. "dynamic" defers the decision to runtime via __esModule check, which correctly handles both ESM and CJS.

Furthermore, I think this matches how ExternalModule (code) handles unknown module types. In this case, external modules are resolved at runtime, and webpack doesn't know the format at compile time (only when the user explicitly configures externalType does it change this).

For these reasons, my guess is that "dynamic" is appropriate here due to the fact that we can't know the exact format.

Could you share your opinion?

@hai-x
Copy link
Member

hai-x commented Dec 13, 2025

I think the root reason is that we can’t determine whether the shared module is actually CJS or ESM, so we don’t assign exportsType to it.

Module federation is inherently runtime-resolved and we genuinely don't know the module format at compile time. "dynamic" defers the decision to runtime via __esModule check, which correctly handles both ESM and CJS.

Yeah, in most scenarios, dynamic is used.

But when a module(assumed CJS here) is imported by a strict ESM file (.mjs), webpack’s interop behavior aligned with nodejs. That’s why the logic is return strict ? "default-with-named" : "dynamic";. You can check the different result in the default-export-esModule row of https://sokra.github.io/interop-test/by-syntax#import-x.

Furthermore, I think this matches how ExternalModule (code) handles unknown module types.

We have externalType, so we can know the exact exportsType when externalType is provided - You can check the following logic after code. But yeah it is dynamic when nothing externalType is provided and it's unknown.

Back to the origin issue, it’s .mjs importing .mjs (the shared module), so I think we can safely assign exportsType: "namespace" to it (it's definitely ESM). But this doesn’t help with #16125 (comment), where the scenario is still .mjs importing .js (as the shared module).

y-okt added a commit to y-okt/webpack that referenced this pull request Dec 14, 2025
use namespace rather than dynamic in js importing
mjs case

Fixes: webpack#20189
@y-okt y-okt force-pushed the fix-mjs-on-federation-remote-dynamic branch from b7a7123 to 64ddd69 Compare December 14, 2025 08:11
@y-okt
Copy link
Contributor Author

y-okt commented Dec 14, 2025

@hai-x Thank you, I now understand your concern. I updated my implementation as follows.

  1. Copy buildMeta/buildInfo from fallback module (matching the approach in module-federation/core) via finishModules hook in ConsumeSharedPlugin.js
  2. Override getExportType, in .mjs -> .mjs case, it will be "namespace".
  3. RemoteModule keeps returning "dynamic" since it has no fallback module to inspect. The actual module is fetched from a remote container at runtime.

Could you please review again?

@hai-x
Copy link
Member

hai-x commented Dec 15, 2025

For 1, I think we can handle it in a separate PR and we also need some input from @ScriptedAlchemy . To be honest, I’m not fully understand it, and concerned it might break interop.

For 2 and 3 look good to me.

@alexander-akait
Copy link
Member

@y-okt Yeah, let's remove changes for 1 and will wait feedback from @ScriptedAlchemy here (will solve it in a separate PR)

y-okt added a commit to y-okt/webpack that referenced this pull request Dec 15, 2025
remove module federation implementation to separately do so in
another MR

Fixes: webpack#20189
@y-okt
Copy link
Contributor Author

y-okt commented Dec 15, 2025

@hai-x @alexander-akait Thank you, changed the handling for 1 to deal with "namespace". I remember @ScriptedAlchemy mentioned this copying buildInfo/meta here, and the suggestion was to use copy attributes. However I agree that we can handle in another PR to safely confirm if there is no side effect.
Could you review again?

The logic:

  1. fallbackModule is set in build, and where it is set differs by this.options.eager
  2. There is no addDependency other than in build (code) ,so we can get this value safely, ensuring that that is fallbackModule

y-okt and others added 6 commits December 16, 2025 07:19
When .mjs files import a default export from a shared/remote module in
Module Federation, they receive the ESM namespace object instead of the
actual default export value.
The solution of this approach is to override the getExportsType() method
in ConsumeSharedModule and RemoteModule to always return "dynamic".
fix failed linting for lib classes
as pointed out in PR review, their values are less
so remove these
use namespace rather than dynamic in js importing
mjs case

Fixes: webpack#20189
remove module federation implementation to separately do so in
another MR

Fixes: webpack#20189
@y-okt y-okt force-pushed the fix-mjs-on-federation-remote-dynamic branch from d57edf1 to 76c88e4 Compare December 15, 2025 22:19
@y-okt
Copy link
Contributor Author

y-okt commented Dec 15, 2025

Sorry, rebased because codecov was failing - could you retrigger the pipeline?

@alexander-akait
Copy link
Member

alexander-akait commented Dec 15, 2025

@y-okt Sometimes codecov has false positive reports due our codebase is complex and many things are async, so ignore it, I always check it before merge and ask to adding test cases if found something uncovered that should be tested

@y-okt
Copy link
Contributor Author

y-okt commented Dec 15, 2025

Thank you @alexander-akait for the information🙇

@hai-x
Copy link
Member

hai-x commented Dec 20, 2025

@y-okt @alexander-akait Sorry for the delay. I just checked the logic of fallbackModule — it makes sense to me now.

And we could probably refine it further, but that can go into a separate PR. For now, we can go ahead and merge this one; at least the output won’t be any worse.

@alexander-akait alexander-akait merged commit 50e0997 into webpack:main Dec 20, 2025
51 checks passed
@alexander-akait
Copy link
Member

@y-okt Thanks for fix

@github-actions
Copy link
Contributor

This PR is packaged and the instant preview is available (50e0997).

Install it locally:

  • npm
npm i -D webpack@https://pkg.pr.new/webpack@50e0997
  • yarn
yarn add -D webpack@https://pkg.pr.new/webpack@50e0997
  • pnpm
pnpm add -D webpack@https://pkg.pr.new/webpack@50e0997

@y-okt
Copy link
Contributor Author

y-okt commented Dec 20, 2025

@alexander-akait @hai-x Thank you for reviewing and merging!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Resolve module (mjs) incorrectly when using Module Federation Plugin

3 participants