Skip to content

fix(hash): keep chunk file names stable when an unrelated entry is added#9444

Merged
hyf0 merged 10 commits into
mainfrom
yhf/fix-hash-stability-9339
May 19, 2026
Merged

fix(hash): keep chunk file names stable when an unrelated entry is added#9444
hyf0 merged 10 commits into
mainfrom
yhf/fix-hash-stability-9339

Conversation

@hyf0

@hyf0 hyf0 commented May 18, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #9339: adding or removing an isolated entry currently changes the file names of every other chunk in the bundle, even when those chunks are byte-identical between builds. The reported example uses entryFileNames / chunkFileNames with codeSplitting and observes the rolldown runtime chunk's hash flip across builds that should produce identical output.

Root cause

Hash placeholders (!~{000}~, !~{001}~, …) are assigned in rendering order, so their numeric indices shift whenever the chunk graph changes. Those indices leak into the hash from two places in finalize_chunks.rs:

  1. Standalone content hash: the chunk's raw content includes placeholders pointing at sibling chunks (e.g. import "./shared-!~{001}~.js"), so when a sibling's index moves, this chunk's content bytes — and therefore its content hash — change.
  2. Final hash: preliminary_filename (which contains the chunk's own placeholder) is mixed in directly, so when the chunk's own index moves, its final hash moves too.

Fix

Mirrors Rollup's generateFinalHashes:

  1. Normalize generated placeholders before hashing content. A new visit_with_placeholders_defaulted helper in rolldown_utils::hash_placeholder walks the chunk content and streams it into Xxh3 byte-slice by byte-slice, replacing each rolldown-generated !~{xxx}~ with a zero-filled placeholder of the same shape. Placeholders rolldown didn't generate (literals in user source code that just happen to match the shape) are emitted verbatim so changes to them still flow into the hash — same placeholders.has(placeholder) check Rollup performs in replacePlaceholdersWithDefaultAndGetContainedPlaceholders. Streaming avoids materializing a chunk-sized normalized String per chunk; for a typical bundle with ~MB chunks containing cross-chunk imports, that's ~bundle-size's worth of throwaway allocations per build avoided.
  2. Drop preliminary_filename from the final hash, deconflict at the file-name layer instead. Two chunks whose resolved file names would collide (case-insensitively, for HFS+/NTFS safety) are now disambiguated by rehashing the colliding chunk's hash until its file name is unique — the same do { … } while (collision) loop Rollup uses.

A design doc covering the three invariants (stability across builds, sensitivity to real content, uniqueness within a build), the two-phase parallel pipeline + sequential deconflict pass, and known limitations is added at meta/design/chunk-hash.md.

Why a separate PR, not a follow-up to #9356

#9356 fixed the content-hash side of the bug but had two issues that made it not the right shape to land as-is:

  • It kept the buggy preliminary_filename.hash(&mut hasher) line under an if options.sourcemap_debug_ids branch, so users with sourcemapDebugIds: true would still hit the original non-determinism.
  • It removed preliminary_filename from the final hash in the default mode without adding any collision handling, regressing the case Rollup explicitly covers in its deconflict-hashes test (two byte-identical entries + entryFileNames: '[hash].js' → Rollup produces two distinct file names via rehash; fix: stabilize chunk content hashes #9356 would silently produce two assets with the same name).

This PR takes the same overall direction (normalize placeholders in content) but lands the rehash loop alongside it so both invariants hold simultaneously, and applies uniformly regardless of sourcemapDebugIds. Thanks to @DaZuiZui for the original investigation and for surfacing the issue clearly — they're credited as co-author on the commit.

Tests

  • New packages/rolldown/tests/behaviors/hash-stability.test.ts: builds the same set of inputs twice, once with an extra unrelated entry, and asserts shared chunks (rolldown-runtime, react, a, b) keep the same file name and code. This is a multi-build invariant that single-build snapshot tests can't capture.
  • cargo test -p rolldown_utils covers visit_with_placeholders_defaulted, including the case where an unknown placeholder-shaped literal is emitted verbatim.
  • The crates/rolldown/tests/rolldown/hash/content_include_placeholder fixture (source console.log('_shared-!~{003}~.js');) doubles as a regression test for the user-literal carve-out — its snapshot would silently change if we ever started normalizing unknown markers again.
  • Existing hash-related snapshots have been refreshed; the changes are hash-only, content unchanged.

Known CI failure: Vite Test Ubuntu

The Vite Test Ubuntu check fails on two js-sourcemap.spec.ts cases (sourcemap is correct when preload information is injected, sourcemap is correct when using object as "define" value). These tests use toMatchInlineSnapshot with concrete chunk hash strings (e.g. dynamic-foo-B4JkVMbo.js, after-preload-dynamic-Dxsmo7dM.js.map) hardcoded in the Vite rolldown-canary branch — those values were produced by main's hashing algorithm. This PR changes the algorithm, so the snapshots no longer match.

This is purely a cross-repo snapshot-pinning artefact, not a regression in behavior: the sourcemaps themselves are correct, only the embedded chunk file-name hashes differ. The Vite-side inline snapshots will need to be refreshed once this PR lands and a new rolldown canary is published. Asking reviewers to accept this failure.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 18, 2026 13:15
@netlify

netlify Bot commented May 18, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs ready!

Name Link
🔨 Latest commit 8902861
🔍 Latest deploy log https://app.netlify.com/projects/rolldown-rs/deploys/6a0b110c1b76d40008ed94ce
😎 Deploy Preview https://deploy-preview-9444--rolldown-rs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented May 18, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs ready!

Name Link
🔨 Latest commit 6cc8eed
🔍 Latest deploy log https://app.netlify.com/projects/rolldown-rs/deploys/6a0c5b61c4bd0b00089441a2
😎 Deploy Preview https://deploy-preview-9444--rolldown-rs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes non-deterministic chunk [hash] filenames across builds when the chunk graph changes (e.g. adding an unrelated isolated entry), by making hashing independent of placeholder indices and adding Rollup-like filename collision deconfliction.

Changes:

  • Normalize hash placeholders in rendered chunk content before computing standalone content hashes, so placeholder index shifts don’t perturb hashes.
  • Remove preliminary_filename from the final-hash inputs and add a deterministic “rehash-until-unique” loop to avoid output filename collisions (case-insensitively).
  • Add a multi-build stability regression test and refresh affected snapshots.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/rolldown/tests/fixtures/plugin/context/emit-chunk/_config.ts Updates inline snapshot expected chunk filename after hash behavior change.
packages/rolldown/tests/fixtures/plugin/augment-chunk-hash/_config.ts Updates inline snapshot expected chunk filenames after hash behavior change.
packages/rolldown/tests/fixtures/output/sanitize-filename/function/_config.ts Updates inline snapshot expected chunk filename after hash behavior change.
packages/rolldown/tests/fixtures/output/hash-filenames/normal/_config.ts Updates inline snapshots for [hash] filenames and import/dynamic-import references.
packages/rolldown/tests/fixtures/misc/virtual/virtual-module-as-chunk/_config.ts Updates inline snapshot expected virtual module chunk filename after hash behavior change.
packages/rolldown/tests/behaviors/hash-stability.test.ts New regression test asserting stable filenames/code for shared chunks when adding an unrelated entry.
crates/rolldown/tests/rolldown/topics/new_url/nested_dirs/artifacts.snap Refreshes expected hashed chunk filename and corresponding import path in snapshot output.
crates/rolldown/tests/rolldown/sourcemap/debug_ids/artifacts.snap Updates expected debugId after hash/debug-id derivation changes.
crates/rolldown/tests/rolldown/hash/dynamic_import/artifacts.snap Refreshes expected hashed filenames and dynamic import specifier in snapshot output.
crates/rolldown/tests/rolldown/hash/content_include_placeholder/artifacts.snap Refreshes expected hashed filename in snapshot output.
crates/rolldown/tests/rolldown/hash/code_splitting/artifacts.snap Refreshes expected hashed filenames and cross-chunk import specifiers in snapshot output.
crates/rolldown/tests/rolldown/hash/basic/artifacts.snap Refreshes expected hashed filename in snapshot output.
crates/rolldown/tests/rolldown/function/entry_filenames/should_generate_specified_hash_length/artifacts.snap Refreshes expected hashed entry filename for fixed-length hash output.
crates/rolldown/src/utils/chunk/finalize_chunks.rs Normalizes placeholders prior to content hashing; removes preliminary filename from final hash inputs; adds Rollup-like filename collision deconfliction via rehash loop.
crates/rolldown_utils/src/hash_placeholder.rs Adds replace_placeholders_with_default helper plus unit test to support stable content hashing.

Comment thread crates/rolldown/src/utils/chunk/finalize_chunks.rs Outdated
@codspeed-hq

codspeed-hq Bot commented May 18, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing yhf/fix-hash-stability-9339 (6cc8eed) with main (4a2ac11)

Open in CodSpeed

Footnotes

  1. 10 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

hyf0 added a commit that referenced this pull request May 18, 2026
…tion

Previously `visit_with_placeholders_defaulted` normalized every
syntactically valid `!~{...}~` marker to a zero-filled placeholder
before hashing. That included literals in user source code that
happened to match the shape — so a chunk whose code contains
`'!~{foo}~'` and a chunk whose code contains `'!~{bar}~'` would
collide on the same content hash, breaking content-addressable cache
invalidation when the user-supplied string changed.

Match Rollup's `replacePlaceholdersWithDefaultAndGetContainedPlaceholders`
by only normalizing placeholders rolldown itself generated (registered
in `ins_chunk_idx_by_placeholder`); unknown markers are emitted
verbatim so changes to their bytes still flow into the hash.

The `crates/rolldown/tests/rolldown/hash/content_include_placeholder`
fixture is exactly this case (its source is
`console.log('_shared-!~{003}~.js');`), so its snapshot hash updates
to reflect the literal now being hashed verbatim.

Also document the one remaining Phase 3 -> importer propagation gap
as a known limitation in `meta/design/chunk-hash.md`; Rollup behaves
the same way and fixing it would require topo-order processing.

Reported by Codex review on PR #9444.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hyf0 added a commit that referenced this pull request May 19, 2026
`to_ascii_lowercase` only folds ASCII case, so two chunk filenames that
differ only in non-ASCII case (e.g. `Á.js` vs `á.js`) wouldn't be
detected as colliding on HFS+/NTFS — yet the doc comment promises
case-insensitive deconfliction "safe to write on case-insensitive
filesystems".

Use `to_lowercase` to match Rollup's `toLowerCase()` and properly cover
non-ASCII chunk names. The cost is negligible in this sequential
post-pass.

Reported in Copilot review on PR #9444.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hyf0 hyf0 requested a review from sapphi-red May 19, 2026 06:51
@hyf0 hyf0 changed the title fix(hash): stabilize chunk content hashes across builds fix(hash): keep chunk file names stable when an unrelated entry is added May 19, 2026
@DaZuiZui

Copy link
Copy Markdown
Contributor

thank

hyf0 and others added 10 commits May 19, 2026 20:45
Hash placeholder indices (`!~{000}~`, `!~{001}~`, ...) are assigned in
rendering order and shift whenever an unrelated chunk is added, so
previously they leaked into both the standalone content hash (via raw
chunk content) and the final hash (via `preliminary_filename`). Adding
or removing an isolated entry could change every other chunk's file
name even when the chunks were byte-identical.

Two changes, mirroring Rollup's `generateFinalHashes`:

- Normalize hash placeholders to a zero-filled placeholder of the same
  shape (`!~{000...}~`) before hashing chunk content, so the content
  hash depends only on the actual bytes and not on transient indices.
- Stop mixing `preliminary_filename` into the final hash. Two chunks
  whose resolved file names would collide (case-insensitively) are now
  deconflicted by rehashing the colliding chunk until its file name is
  unique.

Fixes #9339.

Co-authored-by: DaZuiZui <66861267+DaZuiZui@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace `replace_placeholders_with_default` (which materialized a
chunk-sized normalized `String` per chunk) with
`visit_with_placeholders_defaulted`, a `FnMut(&[u8])` visitor that
feeds the normalized byte sequence straight into the Xxh3 streaming
hasher. The byte sequence fed in is bit-for-bit identical to the old
materialized version, so the resulting hash — and every snapshot — is
unchanged; only the allocation is gone.

For a bundle with N chunks averaging M bytes each, this drops
~N * M bytes of throwaway allocations per build. Rollup still
materializes the normalized string at this point, so this is a place
where rolldown intentionally diverges to be lighter on memory.

Also add meta/design/chunk-hash.md describing the three invariants the
hash mechanism has to satisfy (stability across builds, sensitivity to
real content, uniqueness within a build) and how the two parallel
phases plus the sequential deconflict loop combine to satisfy them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion

Previously `visit_with_placeholders_defaulted` normalized every
syntactically valid `!~{...}~` marker to a zero-filled placeholder
before hashing. That included literals in user source code that
happened to match the shape — so a chunk whose code contains
`'!~{foo}~'` and a chunk whose code contains `'!~{bar}~'` would
collide on the same content hash, breaking content-addressable cache
invalidation when the user-supplied string changed.

Match Rollup's `replacePlaceholdersWithDefaultAndGetContainedPlaceholders`
by only normalizing placeholders rolldown itself generated (registered
in `ins_chunk_idx_by_placeholder`); unknown markers are emitted
verbatim so changes to their bytes still flow into the hash.

The `crates/rolldown/tests/rolldown/hash/content_include_placeholder`
fixture is exactly this case (its source is
`console.log('_shared-!~{003}~.js');`), so its snapshot hash updates
to reflect the literal now being hashed verbatim.

Also document the one remaining Phase 3 -> importer propagation gap
as a known limitation in `meta/design/chunk-hash.md`; Rollup behaves
the same way and fixing it would require topo-order processing.

Reported by Codex review on PR #9444.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n_single_items`

The unit test built a one-element `FxHashSet` with `["..."].into_iter().collect()`,
which clippy `iter-on-single-items` (denied as warning in CI) flags as unnecessary
allocation/iteration on a single-element array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`to_ascii_lowercase` only folds ASCII case, so two chunk filenames that
differ only in non-ASCII case (e.g. `Á.js` vs `á.js`) wouldn't be
detected as colliding on HFS+/NTFS — yet the doc comment promises
case-insensitive deconfliction "safe to write on case-insensitive
filesystems".

Use `to_lowercase` to match Rollup's `toLowerCase()` and properly cover
non-ASCII chunk names. The cost is negligible in this sequential
post-pass.

Reported in Copilot review on PR #9444.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Findings from /simplify pass on the full PR:

- finalize_chunks.rs: the 9-line block above the standalone-content-hash
  closure was largely duplicated by the doc comment on
  `visit_with_placeholders_defaulted` and by `meta/design/chunk-hash.md`.
  Collapse to one line pointing at the design doc.
- finalize_chunks.rs: the 3-line block above the `deconflict_filenames`
  call duplicated the function's own doc comment. Drop.
- chunk-hash.md: the header said "Two-Phase Pipeline" but the diagram
  below and the body describe three phases (Phase 1, 2, 3). Rename to
  "The Pipeline".
- chunk-hash.md: the Phase 3 code snippet still used
  `to_ascii_lowercase`, drifted out of sync after the prior commit
  switched the code to `to_lowercase` per the Copilot review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hyf0 hyf0 force-pushed the yhf/fix-hash-stability-9339 branch from 6c04184 to 6cc8eed Compare May 19, 2026 12:45
@hyf0 hyf0 enabled auto-merge (squash) May 19, 2026 12:45
@hyf0 hyf0 merged commit 0f811f4 into main May 19, 2026
30 of 31 checks passed
@hyf0 hyf0 deleted the yhf/fix-hash-stability-9339 branch May 19, 2026 12:48
@rolldown-guard rolldown-guard Bot mentioned this pull request May 20, 2026
shulaoda added a commit that referenced this pull request May 20, 2026
## [1.0.2] - 2026-05-20

### 🚀 Features

- devtools: emit package size in PackageGraphReady (#9434) by @IWANABETHATGUY
- devtools: classify package dependency types (#9427) by @IWANABETHATGUY
- devtools: map packages to modules and chunks (#9426) by @IWANABETHATGUY
- devtools: mark used packages (#9423) by @IWANABETHATGUY
- devtools: make duplicate packages discoverable (#9422) by @IWANABETHATGUY
- devtools: emit package metadata (#9421) by @IWANABETHATGUY
- update oxc to 0.132.0 (#9449) by @shulaoda
- update oxc to 0.131.0 (#9424) by @shulaoda
- allow checks.* to escalate emissions to hard errors (#9388) by @IWANABETHATGUY
- dev: support watcher options `include` and `exclude` (#9395) by @h-a-n-a
- emit warnings for invalid pure annotations (#9381) by @Kyujenius

### 🐛 Bug Fixes

- hash: keep chunk file names stable when an unrelated entry is added (#9444) by @hyf0
- call `codeSplitting.groups[].name` in deterministic order (#9457) by @sapphi-red
- dev/lazy: make `resolve_id` idempotent when the resolved id is already a lazy entry (#9439) by @h-a-n-a
- chunk-optimization: publish absorbed dynamic-entry namespace cross-chunk (#9448) by @IWANABETHATGUY
- treeshake: propagate pure annotation through compound exprs (#9431) by @Dunqing
- finalizer: skip redundant init call when barrel executes in same chunk (#9354) by @IWANABETHATGUY
- linking: initialize wrapped ESM re-export owners (#9353) by @IWANABETHATGUY
- do not inherit __toESM across chunks for named-only external imports (#9333) (#9415) by @IWANABETHATGUY
- watcher: don't write output or emit events after close() (#9328) by @situ2001
- chunk-optimization: avoid unsafe dynamic-only merges (#9398) by @IWANABETHATGUY
- cjs: rename CJS-wrapped locals that would shadow chunk-scope names (#9392) by @hyf0
- dev/lazy: watch lazy modules added in rebuilds (#9391) by @h-a-n-a

### 🚜 Refactor

- rolldown_dev: move dev example to break publish cycle (#9465) by @Boshen
- binding: drop unsafe napi string helper, hoist transform ArcStr (#9456) by @hyf0
- ecmascript_utils: split rewrite_ident_reference off JsxExt trait (#9417) by @IWANABETHATGUY
- use `ThreadsafeFunction::call_async_catch` (#9390) by @sapphi-red

### 📚 Documentation

- devtools: document @rolldown/debug usage and package graph consumption (#9435) by @IWANABETHATGUY
- replace `Inter` with system font stack in OG template SVG (#9240) by @yvbopeng
- remove `output.comments` warning as all issues have been resolved (#9393) by @sapphi-red
- in-depth: clarify @__PURE__ scope and document positions (#9389) by @Kyujenius
- readme: remove release candidate notice (#9387) by @shulaoda

### ⚡ Performance

- vite-resolve: cache importer existence checks (#9443) by @Brooooooklyn
- binding: reduce plugin string clones (#9436) by @Brooooooklyn

### 🧪 Testing

- enable `legal_comments_inline` test (#9394) by @sapphi-red

### ⚙️ Miscellaneous Tasks

- bump pnpm to v11.1.2 (#9447) by @Boshen
- deps: update rust crates (#9461) by @renovate[bot]
- deps: update rollup submodule for tests to v4.60.4 (#9453) by @rolldown-guard[bot]
- deps: update test262 submodule for tests (#9454) by @rolldown-guard[bot]
- deps: update npm packages (#9430) by @renovate[bot]
- deps: update github actions (#9429) by @renovate[bot]
- deps: update dependency rolldown-plugin-dts to v0.25.1 (#9452) by @renovate[bot]
- deps: update rust crates (#9428) by @renovate[bot]
- revert allow checks.* to escalate emissions to hard errors (#9388) (#9438) by @IWANABETHATGUY
- update mimalloc-safe to 0.1.61 (#9413) by @shulaoda
- deny `todo`, `unimplemented`, and `print_stderr` clippy lints (#9412) by @Boshen
- deps: update mimalloc-safe to 0.1.60 (#9410) by @shulaoda
- remove `pip install setuptools` workaround for node-gyp on macOS (#9370) by @sapphi-red
- renovate: disable automerge to require manual approval (#9386) by @shulaoda
- deps: update napi (#9385) by @renovate[bot]

### ❤️ New Contributors

* @yvbopeng made their first contribution in [#9240](#9240)

Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.com>
@ryanto

ryanto commented May 20, 2026

Copy link
Copy Markdown

thanks @hyf0 !

V1OL3TF0X pushed a commit to V1OL3TF0X/rolldown that referenced this pull request May 25, 2026
…ded (rolldown#9444)

Fixes rolldown#9339: adding or removing an isolated entry currently changes the
file names of every other chunk in the bundle, even when those chunks
are byte-identical between builds. The reported example uses
`entryFileNames` / `chunkFileNames` with `codeSplitting` and observes
the rolldown runtime chunk's hash flip across builds that should produce
identical output.

Hash placeholders (`!~{000}~`, `!~{001}~`, …) are assigned in rendering
order, so their numeric indices shift whenever the chunk graph changes.
Those indices leak into the hash from two places in
`finalize_chunks.rs`:

1. **Standalone content hash**: the chunk's raw content includes
placeholders pointing at sibling chunks (e.g. `import
"./shared-!~{001}~.js"`), so when a sibling's index moves, this chunk's
content bytes — and therefore its content hash — change.
2. **Final hash**: `preliminary_filename` (which contains the chunk's
own placeholder) is mixed in directly, so when the chunk's own index
moves, its final hash moves too.

Mirrors Rollup's `generateFinalHashes`:

1. **Normalize generated placeholders before hashing content.** A new
`visit_with_placeholders_defaulted` helper in
`rolldown_utils::hash_placeholder` walks the chunk content and streams
it into `Xxh3` byte-slice by byte-slice, replacing each
rolldown-generated `!~{xxx}~` with a zero-filled placeholder of the same
shape. Placeholders rolldown didn't generate (literals in user source
code that just happen to match the shape) are emitted verbatim so
changes to them still flow into the hash — same
`placeholders.has(placeholder)` check Rollup performs in
`replacePlaceholdersWithDefaultAndGetContainedPlaceholders`. Streaming
avoids materializing a chunk-sized normalized `String` per chunk; for a
typical bundle with ~MB chunks containing cross-chunk imports, that's
~bundle-size's worth of throwaway allocations per build avoided.
2. **Drop `preliminary_filename` from the final hash, deconflict at the
file-name layer instead.** Two chunks whose resolved file names would
collide (case-insensitively, for HFS+/NTFS safety) are now disambiguated
by rehashing the colliding chunk's hash until its file name is unique —
the same `do { … } while (collision)` loop Rollup uses.

A design doc covering the three invariants (stability across builds,
sensitivity to real content, uniqueness within a build), the two-phase
parallel pipeline + sequential deconflict pass, and known limitations is
added at `meta/design/chunk-hash.md`.

made it not the right shape to land as-is:

- It kept the buggy `preliminary_filename.hash(&mut hasher)` line under
an `if options.sourcemap_debug_ids` branch, so users with
`sourcemapDebugIds: true` would still hit the original non-determinism.
- It removed `preliminary_filename` from the final hash in the default
mode without adding any collision handling, regressing the case Rollup
explicitly covers in its [`deconflict-hashes`
test](https://github.com/rollup/rollup/tree/master/test/chunking-form/samples/hashing/deconflict-hashes)
(two byte-identical entries + `entryFileNames: '[hash].js'` → Rollup
produces two distinct file names via rehash; rolldown#9356 would silently
produce two assets with the same name).

This PR takes the same overall direction (normalize placeholders in
content) but lands the rehash loop alongside it so both invariants hold
simultaneously, and applies uniformly regardless of `sourcemapDebugIds`.
Thanks to @DaZuiZui for the original investigation and for surfacing the
issue clearly — they're credited as co-author on the commit.

- New `packages/rolldown/tests/behaviors/hash-stability.test.ts`: builds
the same set of inputs twice, once with an extra unrelated entry, and
asserts shared chunks (`rolldown-runtime`, `react`, `a`, `b`) keep the
same file name and code. This is a multi-build invariant that
single-build snapshot tests can't capture.
- `cargo test -p rolldown_utils` covers
`visit_with_placeholders_defaulted`, including the case where an unknown
placeholder-shaped literal is emitted verbatim.
- The `crates/rolldown/tests/rolldown/hash/content_include_placeholder`
fixture (source `console.log('_shared-!~{003}~.js');`) doubles as a
regression test for the user-literal carve-out — its snapshot would
silently change if we ever started normalizing unknown markers again.
- Existing hash-related snapshots have been refreshed; the changes are
hash-only, content unchanged.

The `Vite Test Ubuntu` check fails on two `js-sourcemap.spec.ts` cases
(`sourcemap is correct when preload information is injected`, `sourcemap
is correct when using object as "define" value`). These tests use
`toMatchInlineSnapshot` with concrete chunk hash strings (e.g.
`dynamic-foo-B4JkVMbo.js`, `after-preload-dynamic-Dxsmo7dM.js.map`)
hardcoded in the Vite `rolldown-canary` branch — those values were
produced by `main`'s hashing algorithm. This PR changes the algorithm,
so the snapshots no longer match.

This is purely a cross-repo snapshot-pinning artefact, not a regression
in behavior: the sourcemaps themselves are correct, only the embedded
chunk file-name hashes differ. The Vite-side inline snapshots will need
to be refreshed once this PR lands and a new rolldown canary is
published. Asking reviewers to accept this failure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: DaZuiZui <66861267+DaZuiZui@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
V1OL3TF0X pushed a commit to V1OL3TF0X/rolldown that referenced this pull request May 25, 2026
## [1.0.2] - 2026-05-20

### 🚀 Features

- devtools: emit package size in PackageGraphReady (rolldown#9434) by @IWANABETHATGUY
- devtools: classify package dependency types (rolldown#9427) by @IWANABETHATGUY
- devtools: map packages to modules and chunks (rolldown#9426) by @IWANABETHATGUY
- devtools: mark used packages (rolldown#9423) by @IWANABETHATGUY
- devtools: make duplicate packages discoverable (rolldown#9422) by @IWANABETHATGUY
- devtools: emit package metadata (rolldown#9421) by @IWANABETHATGUY
- update oxc to 0.132.0 (rolldown#9449) by @shulaoda
- update oxc to 0.131.0 (rolldown#9424) by @shulaoda
- allow checks.* to escalate emissions to hard errors (rolldown#9388) by @IWANABETHATGUY
- dev: support watcher options `include` and `exclude` (rolldown#9395) by @h-a-n-a
- emit warnings for invalid pure annotations (rolldown#9381) by @Kyujenius

### 🐛 Bug Fixes

- hash: keep chunk file names stable when an unrelated entry is added (rolldown#9444) by @hyf0
- call `codeSplitting.groups[].name` in deterministic order (rolldown#9457) by @sapphi-red
- dev/lazy: make `resolve_id` idempotent when the resolved id is already a lazy entry (rolldown#9439) by @h-a-n-a
- chunk-optimization: publish absorbed dynamic-entry namespace cross-chunk (rolldown#9448) by @IWANABETHATGUY
- treeshake: propagate pure annotation through compound exprs (rolldown#9431) by @Dunqing
- finalizer: skip redundant init call when barrel executes in same chunk (rolldown#9354) by @IWANABETHATGUY
- linking: initialize wrapped ESM re-export owners (rolldown#9353) by @IWANABETHATGUY
- do not inherit __toESM across chunks for named-only external imports (rolldown#9333) (rolldown#9415) by @IWANABETHATGUY
- watcher: don't write output or emit events after close() (rolldown#9328) by @situ2001
- chunk-optimization: avoid unsafe dynamic-only merges (rolldown#9398) by @IWANABETHATGUY
- cjs: rename CJS-wrapped locals that would shadow chunk-scope names (rolldown#9392) by @hyf0
- dev/lazy: watch lazy modules added in rebuilds (rolldown#9391) by @h-a-n-a

### 🚜 Refactor

- rolldown_dev: move dev example to break publish cycle (rolldown#9465) by @Boshen
- binding: drop unsafe napi string helper, hoist transform ArcStr (rolldown#9456) by @hyf0
- ecmascript_utils: split rewrite_ident_reference off JsxExt trait (rolldown#9417) by @IWANABETHATGUY
- use `ThreadsafeFunction::call_async_catch` (rolldown#9390) by @sapphi-red

### 📚 Documentation

- devtools: document @rolldown/debug usage and package graph consumption (rolldown#9435) by @IWANABETHATGUY
- replace `Inter` with system font stack in OG template SVG (rolldown#9240) by @yvbopeng
- remove `output.comments` warning as all issues have been resolved (rolldown#9393) by @sapphi-red
- in-depth: clarify @__PURE__ scope and document positions (rolldown#9389) by @Kyujenius
- readme: remove release candidate notice (rolldown#9387) by @shulaoda

### ⚡ Performance

- vite-resolve: cache importer existence checks (rolldown#9443) by @Brooooooklyn
- binding: reduce plugin string clones (rolldown#9436) by @Brooooooklyn

### 🧪 Testing

- enable `legal_comments_inline` test (rolldown#9394) by @sapphi-red

### ⚙️ Miscellaneous Tasks

- bump pnpm to v11.1.2 (rolldown#9447) by @Boshen
- deps: update rust crates (rolldown#9461) by @renovate[bot]
- deps: update rollup submodule for tests to v4.60.4 (rolldown#9453) by @rolldown-guard[bot]
- deps: update test262 submodule for tests (rolldown#9454) by @rolldown-guard[bot]
- deps: update npm packages (rolldown#9430) by @renovate[bot]
- deps: update github actions (rolldown#9429) by @renovate[bot]
- deps: update dependency rolldown-plugin-dts to v0.25.1 (rolldown#9452) by @renovate[bot]
- deps: update rust crates (rolldown#9428) by @renovate[bot]
- revert allow checks.* to escalate emissions to hard errors (rolldown#9388) (rolldown#9438) by @IWANABETHATGUY
- update mimalloc-safe to 0.1.61 (rolldown#9413) by @shulaoda
- deny `todo`, `unimplemented`, and `print_stderr` clippy lints (rolldown#9412) by @Boshen
- deps: update mimalloc-safe to 0.1.60 (rolldown#9410) by @shulaoda
- remove `pip install setuptools` workaround for node-gyp on macOS (rolldown#9370) by @sapphi-red
- renovate: disable automerge to require manual approval (rolldown#9386) by @shulaoda
- deps: update napi (rolldown#9385) by @renovate[bot]

### ❤️ New Contributors

* @yvbopeng made their first contribution in [rolldown#9240](rolldown#9240)

Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Non-deterministic hash when entry/chunkFileNames are used

5 participants