Skip to content

Module Federation shared module ids and runtime-chunk emission order both drift between builds. #20852

@imccausl

Description

@imccausl

Have you used AI?

Yes

Bug Description

In a large project, we noticed that the runtime and entry point bundle hashes were changing between builds in CI even if there were no actual changes to the code (I could not reproduce this locally). I used Claude Code to get to the bottom of it, since I had to compare the output of some very large minified file outputs, and to generate a more detailed summary, below, which is why I've answered yes to the AI question, but I think the fix is pretty straight-forward and focused, and I have confirmed that it fixes the issue by patching webpack with yarn patch.

It turns out the unexpected bundle churn was being caused by two issues in two places. The first is that ProvideSharedModule.identifier() is using a separator that can't be parsed by makePathsRelative; the second is that ConsumeSharedRuntimeModule.generate() iterates chunk modules without a comparator. Fixing the first issue (via a yarn patch) revealed that the second issue was also a source of unexpected bundle hash churn. The first issue really only becomes a problem, as far as I can tell, when your CI creates a working directory with a random hash value on every build so that the absolute path of your working directory changes every time you build.

Since these are dependent on each other and ultimately both fixes are needed to solve the underlying problem of keeping the chunk file names stable between identical builds, I've included both in this report. The ultimate goal of fixing these bugs is to ensure that the entrypoint file and runtime file hashes remain as stable as possible between successive builds and only change when necessary because of an actual code change affecting them.

The issues depend depend on some specifics of our setup:

  1. We use module federation and have shared modules
  2. We use a service in CI that generates a new working directory with a random hash on every build, so between builds the directory that the repo is built from changes.

I'm happy to provide a PR to fix this issue. I'm curious though if there is any context as to why this is the way it is that I am missing -- although my patch seems to work fine.

Below are the more specific details with code references, summarized by AI.

Issue 1 — ProvideSharedModule.identifier() uses a separator that makePathsRelative cannot parse

ProvideSharedModule.prototype.identifier() (lib/sharing/ProvideSharedModule.js:55) returns:

return `provide module (${this._shareScope}) ${this._name}@${this._version} = ${this._request}`;

this._request is an absolute path (e.g. /output/src111872280/node_modules/react/index.js).

DeterministicModuleIdsPlugin derives each module's numeric ID from makePathsRelative(context, module.identifier()). makePathsRelative splits the identifier on | or ! (SEGMENTS_SPLIT_REGEXP = /([|!])/ in lib/util/identifier.js:10), runs absoluteToRequest on each segment, and rejoins. absoluteToRequest only rewrites a segment whose first character is /.

Because the separator between the metadata and the absolute path is = (not | or !), the whole identifier comes out as a single segment that starts with p (from the literal provide module prefix). The absolute-path test in absoluteToRequest fails, the segment is returned unchanged, and the absolute path — including any per-run prefix — is included verbatim in the hash input. The resulting module ID flips whenever the absolute path flips.

For contrast, ConsumeSharedModule.prototype.identifier() (lib/sharing/ConsumeSharedModule.js:85) uses | as its separator throughout, so the embedded absolute path lands in its own segment and is correctly normalised to a cwd-relative request before hashing.

Because ConsumeSharedModule IDs depend on the ProvideSharedModule IDs they target, the drift also propagates into every matched consume-shared ID even though that class itself is not buggy.

Issue 2 — ConsumeSharedRuntimeModule.generate() iterates chunk modules without a comparator

ConsumeSharedRuntimeModule.prototype.generate() (lib/sharing/ConsumeSharedRuntimeModule.js:76 and :90) calls:

const modules = chunkGraph.getChunkModulesIterableBySourceType(
    chunk,
    "consume-shared",
);

getChunkModulesIterableBySourceType is the unordered variant of this API and no comparator is passed. The returned SortableSet is iterated in insertion order — the order modules were added to the chunk during compilation, which varies non-deterministically across builds.

The generated output embeds three data structures whose byte output is driven directly by this iteration:

  • moduleToHandlerMapping — an object literal whose key order affects the emitted bytes.
  • initialConsumes — an array of module IDs serialised via JSON.stringify.
  • chunkToModuleMapping[chunkId] — a per-chunk array of module IDs.

All three drift across builds even when every module ID is stable.

The sibling ShareRuntimeModule.js:44 does this correctly:

const modules = chunkGraph.getOrderedChunkModulesIterableBySourceType(
    chunk,
    "share-init",
    compareModulesByIdentifier,
);

Issue 2 is normally masked by Issue 1 — when module IDs are already drifting, nobody notices the emission order also drifting. Once Issue 1 is fixed, Issue 2 becomes the sole remaining source of runtime-chunk hash instability.

Proposed Fix

Both issues should be fixable with small, surgical changes to lib/sharing/:

Issue 1 — change the separator in ProvideSharedModule.identifier() from = to | so the embedded absolute path becomes its own segment that makePathsRelative can normalise.

Concretely:

- return `provide module (${this._shareScope}) ${this._name}@${this._version} = ${this._request}`;
+ return `provide module (${this._shareScope}) ${this._name}@${this._version}|${this._request}`;

Issue 2 — switch both call sites to the ordered variant with a comparator, matching ShareRuntimeModule.js:44:

- const modules = chunkGraph.getChunkModulesIterableBySourceType(
-     chunk,
-     "consume-shared",
- );
+ const modules = chunkGraph.getOrderedChunkModulesIterableBySourceType(
+     chunk,
+     "consume-shared",
+     compareModulesByIdentifier,
+ );

After both fixes, successive builds from different absolute paths should produce byte-identical output.

Link to Minimal Reproduction and step to reproduce

Minimal reproduction:

  1. Configure ModuleFederationPlugin with a shared block containing at least one singleton shared module (e.g. react).
  2. Build the project from /tmp/build-a/project.
  3. Rename the parent to /tmp/build-b/project (or copy the project to a sibling directory with a different absolute path) and build again from there.
  4. Diff runtime-*.js (and any bundle that embeds affected module IDs).

You can use the repro repo here: https://github.com/imccausl/webpack-hash-churn-repro

Expected Behavior

bundles are byte-identical between two successive, otherwise identical builds -- no unexpected content hash changes.

Actual Behavior

  • Issue 1: ProvideSharedModules are assigned different numeric module IDs in each build, because the absolute request path leaks into the deterministic-module-id hash input. Any bundle whose content embeds one of those IDs differs between builds.
  • Issue 2: the runtime chunk's moduleToHandlerMapping, initialConsumes, and chunkToModuleMapping tables are emitted in a different order in each build, because the iteration over consume-shared modules is unordered. The entries are the same; their textual order is not.

Environment

System:   macOS 26.2, arm64 Apple M1 Pro                                                    
Node:     22.22.1                                      
Yarn:     4.12.0 (PnP)                                                                      
webpack:  5.105.4

Is this a regression?

None

Last Working Version

No response

Additional Context

  • webpack version: 5.105.0 (also confirmed by reading source at main as of April 2026)
  • Node.js version: 20.x
  • Operating System: Linux
  • Additional tools: ModuleFederationPlugin with shared: { react, react-dom, react-router, react-router-dom, styled-components, @thm/fe-firestore, core-js }, all singleton: true, eager: true except core-js.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions