ModuleConcatenationPlugin bails out for modules in available parent chunks ("not in the same chunk")
Bug report
When splitChunks extracts shared ESM modules into a separate async chunk (e.g. shared), ModuleConcatenationPlugin refuses to concatenate those modules into the async page chunks that import them. The bailout reason is "Module is not in the same chunk(s)".
The shared chunk is a parent chunk — it is guaranteed to be loaded before the async page chunk executes. So it should be safe to inline/concatenate those modules into the page scope, but webpack does not do this.
The root cause is unclear, probably related to module processing order which depends on module_graph_modules() iteration order. If NamedModulesPlugin vs HashedModuleIdsPlugin subtly affects module identity or iteration order, the greedy algorithm can amplify a small ordering difference into wildly different concatenation groups.
What is the current behavior?
With splitChunks extracting shared modules into a shared chunk, ModuleConcatenationPlugin emits:
ModuleConcatenation bailout: Cannot concat with ./src/shared/SharedComponent.js:
Module is not in the same chunk(s) (expected in chunk(s) page-b, but module is in chunk(s) shared)
Each shared module stays as a separate __webpack_require__() call in the page chunk. This adds per-module wrapper overhead (factory function, Object.defineProperty exports, etc.) that hurts parse/compile time
In a real app with ~5,000 such bailouts across async chunks, this causes measurable runtime regression in webpack5 (vs webpack4).
What is the expected behavior?
If a module lives in a parent chunk that is guaranteed to load before the child async chunk, ModuleConcatenationPlugin should be allowed to concatenate it into the child chunk's scope. The module stays in the parent chunk too (so other consumers still work), but the child chunk gets the inlined version.
Steps to reproduce
Minimal repo: https://github.com/kimjh12/rspack-repro
Or inline — create these files:
webpack.config.mjsimport path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default {
mode: "production",
entry: { main: "./src/index.js" },
output: {
clean: true,
path: path.resolve(__dirname, "dist"),
filename: "[name].js",
chunkFilename: "[name].js",
},
resolve: { extensions: [".js"] },
module: {
rules: [{ test: /\.js$/, resolve: { fullySpecified: false } }],
},
optimization: {
concatenateModules: true,
minimize: false,
splitChunks: {
chunks: "all",
minSize: 0,
cacheGroups: {
shared: {
test: /[\\/]shared[\\/]/,
name: "shared",
chunks: "async",
minChunks: 2,
enforce: true,
priority: 10,
},
},
},
},
stats: { optimizationBailout: true },
};
src/index.jsasync function main() {
const hash = location.hash;
if (hash === "#b") {
const { render } = await import(/* webpackChunkName: "page-b" */ "./pages/page-b/index.js");
render();
} else {
const { render } = await import(/* webpackChunkName: "page-a" */ "./pages/page-a/index.js");
render();
}
}
main();
src/shared/SharedComponent.jsexport const SharedComponent = (tag, props, children) => {
const el = document.createElement(tag);
if (props) Object.entries(props).forEach(([k, v]) => el.setAttribute(k, v));
if (children) el.innerHTML = children;
return el;
};
export const SharedWrapper = (child) => {
const wrapper = document.createElement("div");
wrapper.className = "shared-wrapper";
wrapper.appendChild(child);
return wrapper;
};
src/shared/SharedUtil.jsexport const formatValue = (v) => String(v).toUpperCase();
export const formatLabel = (label, value) => `${label}: ${formatValue(value)}`;
src/pages/page-a/helpers.jsexport const computeScore = (a, b) => a * 10 + b;
export const getTitle = () => "Page A Section";
src/pages/page-a/Section.jsimport { SharedComponent, SharedWrapper } from "../../shared/SharedComponent";
import { formatValue } from "../../shared/SharedUtil";
import { computeScore, getTitle } from "./helpers";
export const renderSection = (a, b) => {
const score = computeScore(a, b);
const label = formatValue(getTitle());
const content = SharedComponent("span", { class: "score" }, `${label}: ${score}`);
return SharedWrapper(content);
};
src/pages/page-a/index.jsimport { renderSection } from "./Section";
export const render = () => document.body.appendChild(renderSection(3, 7));
src/pages/page-b/index.jsimport { SharedComponent, SharedWrapper } from "../../shared/SharedComponent";
import { formatLabel } from "../../shared/SharedUtil";
export const render = () => {
const rank = Math.floor(42 / 10) + 1;
const label = formatLabel("rank", String(rank));
const content = SharedComponent("span", { class: "rank" }, label);
document.body.appendChild(SharedWrapper(content));
};
Run:
npx webpack --stats verbose 2>&1 | grep "ModuleConcatenation bailout"
Output shows:
ModuleConcatenation bailout: Cannot concat with ./src/shared/SharedComponent.js
ModuleConcatenation bailout: Cannot concat with ./src/shared/SharedUtil.js
The page-a chunk concatenates its local modules (helpers.js, Section.js, index.js) fine, but cannot concatenate the shared modules from the parent shared chunk. The page-b chunk has zero concatenation because all its imports come from the shared chunk.
Other relevant information: webpack version: 5.102.1 Node.js version: v20.20.0 Operating System: Linux 5.15
Source: webpack/webpack