Skip to content

build.chunkImportMap reuses a content-hashed file name for different bytes #23225

Description

@antur84

Describe the bug

With build.chunkImportMap: true, a chunk's emitted file name is computed before Vite substitutes the dynamic-import preload marker, so a graph change that only alters a chunk's preload dependency list changes the file's bytes while leaving its content-hashed name unchanged.

That breaks the assumption behind Cache-Control: immutable: a returning browser reuses its cached copy of a name whose contents have since changed. It equally breaks CDN caches and content-addressed object stores keyed on the file name.

The emitted difference is confined to the injected array:

  the only difference is inside the injected __vite__mapDeps array:
-     "assets/dep-a-zYMrD63L.js"
+     "assets/dep-b-ClVQIekE.js"

…while the emitted file keeps the same index-<hash>.js name across both builds. The reproduction prints both sha256 sums alongside it.

Related, but not a duplicate

#22946 (chunkImportMap css import uses contenthash, fixed by #22947) reported the same symptom in the reporter's words — "the content of the referencing js file is changing, but the content-hash stays the same" — for the case where a dependency's own content hash appeared in the array. That fix made the entries stable chunk-ids, and it works: in this reproduction the __vite__mapDeps entries are import-map keys, not hashed file names.

This is the residual case that stable ids cannot fix: the membership of the array changes, because the importer genuinely gains or loses a dependency. No naming scheme makes that array identical, so the importer's file name has to move — and it does not. This reproduction contains no CSS at all, so it never goes through the #22947 path.

Analysis

vite:build-import-analysis (packages/vite/src/node/plugins/importAnalysisBuild.ts) substitutes preloadMarker inside generateBundle, which runs after Rollup has finalised chunk file names, so the substituted content is not part of the hashed input.

Without chunkImportMap this is invisible: an importer references its dependencies by their real hashed names, so a dependency rotation also changes the importer's hashed input and the name moves anyway. chunkImportMap rewrites static imports to stable placeholder specifiers, which removes that masking effect — the option exposes the late substitution rather than causing it. That is why the control build in the reproduction is clean.

A fix presumably needs the substituted dependency list to participate in the chunk hash. augmentChunkHash runs at renderChunk, when the final dependency file names are not yet known, so this may need handling inside the hashing pass rather than by a plugin.

I checked main as well: importAnalysisBuild.ts still performs the substitution inside generateBundle and has no augmentChunkHash, and the main build served by pkg.pr.new reproduces identically.

Reproduction

https://github.com/antur84/vite-chunkimportmap-stale-hash-repro

Steps to reproduce

npm install
npm run repro

The script builds twice per mode. Between the two builds only src/deep.js changes — it gains one extra import; every other source file is byte-identical across builds.

Expected: a file whose bytes differ should not keep the same content-hashed name — the behaviour the control mode already shows.

Actual:

=== build.chunkImportMap: true  (the bug) ===
  SAME NAME, DIFFERENT BYTES: index-<hash>.js
    build 1 sha256 c08d5a72...
    build 2 sha256 ec9f44de...
    the only difference is inside the injected __vite__mapDeps array:
      - "assets/dep-a-zYMrD63L.js"
      + "assets/dep-b-ClVQIekE.js"
  SAME NAME, DIFFERENT BYTES: lazy-<hash>.js
    (same difference)

=== build.chunkImportMap: false (control) ===
  no collisions

SUMMARY
  chunkImportMap: true   -> 2 file(s) reused a content-hashed name for different bytes
  chunkImportMap: false  -> 0 file(s)

Two notes for anyone trimming the reproduction further:

  • src/other.js is load-bearing. Without a second importer the dependencies are inlined, no preload array is emitted, and there is nothing to rotate.
  • Emitted hashes differ between checkouts at different absolute paths; within one checkout the build is deterministic (verified by repeated runs), so a different hash than the one above is expected and harmless.

Reproduced on vite@8.2.1, 8.2.0, and the main build from https://pkg.pr.new/vitejs/vite/vite@main.

System Info

  System:
    OS: macOS 26.5.2
    CPU: (18) arm64 Apple M5 Max
    Memory: 1.17 GB / 64.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 24.14.1
    npm: 11.11.0
    pnpm: 10.33.4
    bun: 1.3.13
  Browsers:
    Chrome: 151.0.7922.77
    Firefox: 153.0.3
    Safari: 26.5.2
  npmPackages:
    vite: 8.2.1 => 8.2.1

Used Package Manager

npm

Logs

No response

Validations

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions