Repository navigation
Description
Have you used AI?
Yes. AI-assisted investigation and report; the standalone reproducer below was executed against unmodified npm installations of webpack 5.111.0 and 5.112.0.
Which part of webpack is affected?
cache (persistent/filesystem cache)
Bug Description
With ESM output (output.module: true) an entry chunk imports the other initial chunks of its entrypoint by their hashed file names:
import __webpack_require__ from "./runtime.<hash>.js";
import * as __webpack_chunk_1__ from "./lib.<hash>.js";The hash of that entry chunk does not depend on the hashes of those chunks. createChunkHashHandler (lib/javascript/ChunkHelpers.js) adds the hash of the runtime chunk (#19439), but updateHashForEntryStartup only adds the ids of the other chunks of the entrypoint. When a split initial chunk changes and the entry chunk's own modules do not, the entry chunk keeps its content hash although its rendered code has to change.
With the filesystem cache the rendered asset is looked up by that unchanged hash, so the previous render is served, and it imports the old file name of the split chunk, which is no longer emitted:
- with
optimization.realContentHash: true(the production default) the build fails inRealContentHashPluginwith "Some kind of unexpected caching problem occurred. An asset was cached with a reference to another asset … that's not in the compilation anymore"; - with
realContentHash: falsethe build succeeds and emits an entry chunk that imports a file which does not exist.
The runtime chunk hash only masks this when the runtime changes too (e.g. when an async chunk changes as well), so in real projects it shows up intermittently, as a cache error that goes away on retry.
Link to Minimal Reproduction and steps to reproduce
In an empty directory:
npm init -y
npm install --save-exact [email protected]
# save the script below as repro.cjs
node repro.cjs # stale import, exit code 1
node repro.cjs --real-content-hash # RealContentHashPlugin error, exit code 1The script builds an entry (entry.js) whose only dependency (lib.js) is split into a named initial chunk, with runtimeChunk: 'single', ESM output and the filesystem cache. It then changes lib.js and builds again from the cache.
Self-contained repro.cjs (creates and removes its own fixture)
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const webpack = require('webpack');
(async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'webpack-entry-hash-'));
async function build() {
const compiler = webpack({
mode: 'production',
context: directory,
entry: { main: './entry.js' },
cache: { type: 'filesystem', cacheDirectory: path.join(directory, '.cache') },
experiments: { outputModule: true },
output: {
path: path.join(directory, 'dist'),
filename: '[name].[contenthash].js',
module: true,
clean: true
},
optimization: {
minimize: false,
realContentHash: process.argv[2] === '--real-content-hash',
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
lib: {
name: 'lib',
chunks: (chunk) => chunk.name === 'main',
test: /lib\.js$/,
enforce: true
}
}
}
}
});
await new Promise((resolve, reject) => compiler.run((error, stats) => compiler.close((closeError) => {
if (error || closeError) reject(error || closeError);
else if (stats.hasErrors()) reject(new Error(stats.toString({ all: false, errors: true })));
else resolve();
})));
const files = await fs.readdir(path.join(directory, 'dist'));
const main = files.find((file) => file.startsWith('main.'));
const source = await fs.readFile(path.join(directory, 'dist', main), 'utf8');
const imports = [...source.matchAll(/from\s*"\.\/([^"]+)"/g)].map(([, file]) => file);
return { files, imports };
}
try {
await fs.writeFile(path.join(directory, 'entry.js'), 'import { greet } from "./lib.js";\ngreet();\n');
await fs.writeFile(path.join(directory, 'lib.js'), 'export const greet = () => console.log("one");\n');
const first = await build();
await fs.writeFile(path.join(directory, 'lib.js'), 'export const greet = () => console.log("two");\n');
const second = await build();
console.log({ webpack: webpack.version, first, second });
for (const file of second.imports) {
assert.ok(second.files.includes(file), `main imports ${file}, which was not emitted`);
}
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
})().catch((error) => { console.error(error.message); process.exitCode = 1; });Expected Behavior
The second build emits an entry chunk that imports the new lib.<hash>.js. The entry chunk's content hash changes whenever the file name of a chunk it imports changes.
Actual Behavior
The entry chunk keeps its file name and content from the first build and imports lib.<old hash>.js, which is not emitted (realContentHash: false), or the build fails in RealContentHashPlugin (realContentHash: true).
Rebuilding with an empty cache directory produces correct output.
Environment
- webpack: 5.112.0 (clean npm installation); also reproduced with 5.111.0
- Node.js: 26.9.0
- OS: macOS, arm64
- Loaders/plugins: none
Additional Context
createChunkHashHandler is called for entry chunks only after all non-entry chunks are hashed (Compilation#createHash processes async, then initial, then runtime, then entry chunks), so the content hashes of the other chunks of the entrypoint are already final at that point. A local workaround hashes them in addition to their ids:
updateHashForEntryStartup(hash, chunkGraph, entries, chunk);
+ for (const [, entrypoint] of entries) {
+ for (const c of getAllChunks(entrypoint, chunk, runtimeChunk)) {
+ hash.update(`${c.contentHash.javascript || c.hash || ""}`);
+ }
+ }
};With this change the reproducer passes, and a rebuild without source changes keeps every file name. getAllChunks returns only the initial chunks of the entrypoint and its parents, the same set updateHashForEntryStartup already walks for the ids, so the extra cost is small.
The same handler is used by CommonJsChunkFormatPlugin, so the CommonJS chunk format may be affected the same way; I have only verified ESM.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
fix(esm): rehash entry chunks when a chunk they import changes (#22804)