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:
- We use module federation and have shared modules
- 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:
- Configure
ModuleFederationPlugin with a shared block containing at least one singleton shared module (e.g. react).
- Build the project from
/tmp/build-a/project.
- 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.
- 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.
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 bymakePathsRelative; the second is thatConsumeSharedRuntimeModule.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:
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 thatmakePathsRelativecannot parseProvideSharedModule.prototype.identifier()(lib/sharing/ProvideSharedModule.js:55) returns:this._requestis an absolute path (e.g./output/src111872280/node_modules/react/index.js).DeterministicModuleIdsPluginderives each module's numeric ID frommakePathsRelative(context, module.identifier()).makePathsRelativesplits the identifier on|or!(SEGMENTS_SPLIT_REGEXP = /([|!])/inlib/util/identifier.js:10), runsabsoluteToRequeston each segment, and rejoins.absoluteToRequestonly 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 withp(from the literalprovide moduleprefix). The absolute-path test inabsoluteToRequestfails, 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
ConsumeSharedModuleIDs depend on theProvideSharedModuleIDs 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 comparatorConsumeSharedRuntimeModule.prototype.generate()(lib/sharing/ConsumeSharedRuntimeModule.js:76and:90) calls:getChunkModulesIterableBySourceTypeis the unordered variant of this API and no comparator is passed. The returnedSortableSetis 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 viaJSON.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:44does this correctly: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 thatmakePathsRelativecan normalise.Concretely:
Issue 2 — switch both call sites to the ordered variant with a comparator, matching
ShareRuntimeModule.js:44:After both fixes, successive builds from different absolute paths should produce byte-identical output.
Link to Minimal Reproduction and step to reproduce
Minimal reproduction:
ModuleFederationPluginwith asharedblock containing at least one singleton shared module (e.g.react)./tmp/build-a/project./tmp/build-b/project(or copy the project to a sibling directory with a different absolute path) and build again from there.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
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.moduleToHandlerMapping,initialConsumes, andchunkToModuleMappingtables 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
Is this a regression?
None
Last Working Version
No response
Additional Context
mainas of April 2026)shared: { react, react-dom, react-router, react-router-dom, styled-components, @thm/fe-firestore, core-js }, allsingleton: true, eager: trueexceptcore-js.