fix(hash): keep chunk file names stable when an unrelated entry is added#9444
Conversation
✅ Deploy Preview for rolldown-rs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for rolldown-rs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
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_filenamefrom 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. |
Merging this PR will not alter performance
Comparing Footnotes
|
…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>
`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>
|
thank |
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>
6c04184 to
6cc8eed
Compare
## [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>
|
thanks @hyf0 ! |
…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>
## [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>
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/chunkFileNameswithcodeSplittingand 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 infinalize_chunks.rs:import "./shared-!~{001}~.js"), so when a sibling's index moves, this chunk's content bytes — and therefore its content hash — change.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:visit_with_placeholders_defaultedhelper inrolldown_utils::hash_placeholderwalks the chunk content and streams it intoXxh3byte-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 — sameplaceholders.has(placeholder)check Rollup performs inreplacePlaceholdersWithDefaultAndGetContainedPlaceholders. Streaming avoids materializing a chunk-sized normalizedStringper chunk; for a typical bundle with ~MB chunks containing cross-chunk imports, that's ~bundle-size's worth of throwaway allocations per build avoided.preliminary_filenamefrom 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 samedo { … } 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:
preliminary_filename.hash(&mut hasher)line under anif options.sourcemap_debug_idsbranch, so users withsourcemapDebugIds: truewould still hit the original non-determinism.preliminary_filenamefrom the final hash in the default mode without adding any collision handling, regressing the case Rollup explicitly covers in itsdeconflict-hashestest (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
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_utilscoversvisit_with_placeholders_defaulted, including the case where an unknown placeholder-shaped literal is emitted verbatim.crates/rolldown/tests/rolldown/hash/content_include_placeholderfixture (sourceconsole.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.Known CI failure:
Vite Test UbuntuThe
Vite Test Ubuntucheck fails on twojs-sourcemap.spec.tscases (sourcemap is correct when preload information is injected,sourcemap is correct when using object as "define" value). These tests usetoMatchInlineSnapshotwith concrete chunk hash strings (e.g.dynamic-foo-B4JkVMbo.js,after-preload-dynamic-Dxsmo7dM.js.map) hardcoded in the Viterolldown-canarybranch — those values were produced bymain'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