Skip to content

RealContentHashPlugin watch rebuild can keep stale runtime chunk filename hashes #20710

Description

@davidmurdoch

Bug report

When using watch mode with cache: { type: "memory" }, optimization.runtimeChunk: "single", and splitChunks forcing a named shared chunk, the second compilation can fail in RealContentHashPlugin after a source edit.

RealContentHashPlugin is only where the stale reference gets detected. The stale hash appears to be baked into the runtime chunk before the changed initial chunk has been re-hashed.

Minimal reproduction

async-entry.js

import("./sync-entry.js");

sync-entry.js

import "./large-shared.js";
export const syncValue = "__SYNC__";
console.log(syncValue);

large-shared.js

const bigTextBlob = `a VERY big string here to force chunking. like.... HUUGGGEEEEE. to reproduce you'll need to embed some long string here. I tested with the whole text of lodash`;
export const sharedValue = "__SHARED__";
console.log(sharedValue, bigTextBlob.length);

webpack.config.js

const path = require("path");

module.exports = {
  mode: "production",
  context: __dirname,
  cache: {
    type: "memory"
  },
  entry: {
    async: "./async-entry.js",
    sync: "./sync-entry.js",
    light: "./sync-entry.js"
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[contenthash].js"
  },
  optimization: {
    runtimeChunk: "single",
    splitChunks: {
      chunks: "all",
      name: "js"
    }
  }
};

Rebuild sequence:

  1. Start compiler.watch(...)
  2. Wait for the first successful build
  3. Rewrite large-shared.js, changing __SHARED__ to __SHARED__CHANGED
  4. Wait for the second build

Observed result:

ERROR in RealContentHashPlugin
Some kind of unexpected caching problem occurred.
An asset was cached with a reference to another asset (...) that's not in the compilation anymore.

Expected result:

The second watch build should succeed.

Suspected cause

In lib/Compilation.js, createHash() includes this comment:

all non-runtime chunks need to be hashes first, since runtime chunk might use their hashes.

But the implementation currently hashes chunks in this order:

  1. async chunks
  2. runtime chunks
  3. initial chunks

That seems backwards for runtime modules like GetChunkFilenameRuntimeModule, which are marked dependentHash and can embed initial chunk hashes. On rebuild, the runtime chunk can therefore keep a stale intermediate hash for a changed initial chunk, and RealContentHashPlugin later reports that stale reference.

Activity

  1. alexander-akait commented on Mar 26, 2026

    @alexander-akait
    Member

    Looks like a bug, anyway using RealContentHashPlugin is strongly not recommended due performance reasons

  2. davidmurdoch commented on Mar 26, 2026

    @davidmurdoch
    Author

    I don't think RealContentHashPlugin is the actual issue here, it is using [contenthash] in the filename that causes the problem. I think RealContentHashPlugin just happens to detect something is wrong at its run stage, but its not to blame for an internal compilation Error.

    I'm trying to find another minimal reproduction that doesn't require realContentHash: true (true is the default in mode: "production" when [contenthash] is used in the filename.)

  3. alexander-akait commented on Mar 26, 2026

    @alexander-akait
    Member

    I don't think RealContentHashPlugin is the actual issue here, it is using [contenthash] in the filename that causes the problem. I think RealContentHashPlugin just happens to detect something is wrong at its run stage, but its not to blame for an internal compilation Error.

    Yeah, I think you are right here, just for information about performance

  4. davidmurdoch commented on Mar 26, 2026

    @davidmurdoch
    Author

    Ok, so when using watch mode with a fixed-name service worker entry that loads split chunks via importScripts, the second compilation will actually "succeed" but leave a stale chunk manifest embedded in service-worker.js.

    I think it only happens with hashed chunk filenames. If I switch from [contenthash] to [id] or [name], the issue goes away.

    To make the stale output visible, this repro sets optimization.realContentHash = false. With realContentHash: true, webpack fails the second build instead of silently emitting the wrong manifest (repro for this is in the Issue description above)

    Minimal reproduction

    src/service-worker.js

    import './shared.js';
    
    console.log('service worker');

    src/offscreen.js

    import './shared.js';
    
    console.log('offscreen');

    src/shared.js

    const bigTextBlob = " PUT A REALLY BIG STRING HERE TO FORCE CHUNKING, like all of lodash";
    
    export const sharedValue = '__SHARED__';
    
    console.log(sharedValue, bigTextBlob.length);

    webpack.config.js

    module.exports = {
      mode: 'production',
      target: 'webworker',
      cache: { type: 'memory' },
      entry: {
        'service-worker': {
          import: './src/service-worker.js',
          chunkLoading: 'import-scripts',
          filename: 'service-worker.js',
        },
        offscreen: './src/offscreen.js',
      },
      output: {
        filename: '[name].[contenthash].js',
      },
      optimization: {
        // Disable RealContentHashPlugin so the rebuild completes and leaves
        // the stale manifest visible in service-worker.js.
        realContentHash: false,
        splitChunks: {
          chunks: 'all',
          name: 'js',
          minSize: 1,
        },
      },
    };

    Rebuild sequence

    1. Start compiler.watch(...)
    2. Wait for the first successful build
    3. Rewrite src/shared.js, changing __SHARED__ to __SHARED__CHANGED, or whatever
    4. Wait for the second build
    5. Compare:
      • the current service-worker entrypoint chunk file from stats
      • the js.<hash>.js filename referenced inside emitted service-worker.js

    Observed result

    The second build succeeds, and webpack emits a new shared chunk filename, but service-worker.js still points at the previous chunk filename.

    Example shape:

    Expected chunk file: js.<newhash>.js
    Referenced in service-worker.js: js.<oldhash>.js
    
    service-worker.js still points at the previous chunk filename

    Expected result

    On the second watch build, the manifest embedded in service-worker.js should reference the newly emitted shared chunk filename.

    Notes

    The PR, #20711 looks to solve both issues. I'm working on update the tests now.

  5. alexander-akait commented on Mar 26, 2026

    @alexander-akait
    Member

    @davidmurdoch thanks for detailed investigation, you can use this configuration for watchTests as a prof that it was fixed

  6. SAHIL15KP commented on Mar 28, 2026

    @SAHIL15KP

    @davidmurdoch you are working on this currently??

  7. added a commit that references this issue on Mar 28, 2026
    8ecd4f6
  8. xiaoxiaojx commented on Mar 28, 2026

    @xiaoxiaojx
    Member

    Hi @davidmurdoch, thanks for the feedback. It looks like this PR #20724 can fix both #20710 and issue #19439.

  9. added a commit that references this issue on Mar 28, 2026
    a53276f
  10. sujalgoel commented on Mar 28, 2026

    @sujalgoel

    I looked into this and traced the root cause to the chunk hashing order in Compilation.createHash().

    Runtime chunks were being hashed before initial/split chunks, even though GetChunkFilenameRuntimeModule (which generates __webpack_require__.u — the chunk ID → filename mapping) reads chunk.contentHash for each split chunk at hash time. Because those split chunks hadn't been hashed yet, the module embedded a stale hash from the previous compilation. On the next watch rebuild, the asset cache returned the previous runtime asset unchanged, so the runtime pointed to a filename that no longer existed on disk.

    This contradicts a comment already in the same method: "all non-runtime chunks need to be hashed first, since runtime chunk might use their hashes."

    Fix in PR #20730 — chunks are now hashed in four explicit passes that respect the dependency chain:

    1. Async chunks
    2. Non-entry initial chunks (split chunks)
    3. Runtime chunks
    4. Entry chunks (may depend on runtime chunk hash)
  11. davidmurdoch commented on Mar 28, 2026

    @davidmurdoch
    Author

    Awesome to see! Looks like my original solution was close, but not quite there. Nice!

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions