Skip to content

fix: relax overly conservative side-effect leak check in chunk optimizer#9085

Merged
graphite-app[bot] merged 1 commit intomainfrom
04-11-fix_side_effects_checking
Apr 13, 2026
Merged

fix: relax overly conservative side-effect leak check in chunk optimizer#9085
graphite-app[bot] merged 1 commit intomainfrom
04-11-fix_side_effects_checking

Conversation

@IWANABETHATGUY
Copy link
Copy Markdown
Member

@IWANABETHATGUY IWANABETHATGUY commented Apr 13, 2026

Summary

  • Remove redundant is_reachable check in try_insert_into_existing_chunk — all dynamic entries in chunk_idxs already depend on the pending common chunk by construction, so filtering them via is_reachable was a no-op.
  • Refine the side-effect leak guard: instead of unconditionally blocking the merge when any dynamic entry depends on the common chunk, only block it when a different user-defined entry can also reach those dynamic entries. This allows more merges that are actually safe (the dynamic entry is only reachable from the merge target, so its side effects have already run).
  • Remove the now-unused temp_chunk_idx parameter from try_insert_into_existing_chunk.

Test plan

  • All 1677 integration tests pass.
  • Clippy and fmt pass.

Copy link
Copy Markdown
Member Author

IWANABETHATGUY commented Apr 13, 2026


How to use the Graphite Merge Queue

Add the label graphite: merge-when-ready to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 13, 2026

Deploy Preview for rolldown-rs canceled.

Name Link
🔨 Latest commit 5ff1a72
🔍 Latest deploy log https://app.netlify.com/projects/rolldown-rs/deploys/69dc96cd235c59000912c8d0

@IWANABETHATGUY IWANABETHATGUY force-pushed the 04-11-fix_side_effects_checking branch 2 times, most recently from 0fe91b4 to 8d62b43 Compare April 13, 2026 06:35
@IWANABETHATGUY IWANABETHATGUY changed the title fix: side effects checking fix: simplify and refine side-effect leak detection in chunk optimizer Apr 13, 2026
@IWANABETHATGUY IWANABETHATGUY changed the title fix: simplify and refine side-effect leak detection in chunk optimizer fix: relax overly conservative side-effect leak check in chunk optimizer Apr 13, 2026
@IWANABETHATGUY IWANABETHATGUY marked this pull request as ready for review April 13, 2026 06:39
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 13, 2026

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing 04-11-fix_side_effects_checking (8d62b43) with main (2857357)2

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.

  2. No successful run was found on main (d06c805) during the generation of this report, so 2857357 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Copy link
Copy Markdown
Member Author

IWANABETHATGUY commented Apr 13, 2026

Merge activity

  • Apr 13, 7:08 AM UTC: The merge label 'graphite: merge-when-ready' was detected. This PR will be added to the Graphite merge queue once it meets the requirements.
  • Apr 13, 7:09 AM UTC: IWANABETHATGUY added this pull request to the Graphite merge queue.
  • Apr 13, 7:14 AM UTC: Merged by the Graphite merge queue.

…zer (#9085)

## Summary
- Remove redundant `is_reachable` check in `try_insert_into_existing_chunk` — all dynamic entries in `chunk_idxs` already depend on the pending common chunk by construction, so filtering them via `is_reachable` was a no-op.
- Refine the side-effect leak guard: instead of unconditionally blocking the merge when any dynamic entry depends on the common chunk, only block it when a *different* user-defined entry can also reach those dynamic entries. This allows more merges that are actually safe (the dynamic entry is only reachable from the merge target, so its side effects have already run).
- Remove the now-unused `temp_chunk_idx` parameter from `try_insert_into_existing_chunk`.

## Test plan
- All 1677 integration tests pass.
- Clippy and fmt pass.
@graphite-app graphite-app Bot force-pushed the 04-11-fix_side_effects_checking branch from 8d62b43 to 5ff1a72 Compare April 13, 2026 07:10
@graphite-app graphite-app Bot merged commit 5ff1a72 into main Apr 13, 2026
32 checks passed
@graphite-app graphite-app Bot deleted the 04-11-fix_side_effects_checking branch April 13, 2026 07:14
This was referenced Apr 15, 2026
shulaoda added a commit that referenced this pull request Apr 16, 2026
## [1.0.0-rc.16] - 2026-04-16

### 🚀 Features

- const enum cross-module inlining support (#8796) by @Dunqing
- implement module tagging system for code splitting (#9045) by @hyf0

### 🐛 Bug Fixes

- rolldown_plugin_vite_manifest: handle duplicate chunk names for CSS entries (#9059) by @sapphi-red
- improve error message for invalid return values in function options (#9125) by @shulaoda
- await async export-star init wrappers (#9101) by @thezzisu
- never panic during diagnostic emission (#9091) by @IWANABETHATGUY
- include array rest pattern in binding_identifiers (#9112) by @IWANABETHATGUY
- rolldown: set worker thread count with ROLLDOWN_WORKER_THREADS (#9086) by @fpotter
- rolldown_plugin_lazy_compilation: escape request ID in proxy modules (#9102) by @h-a-n-a
- treat namespace member access as side-effect-free (#9099) by @IWANABETHATGUY
- relax overly conservative side-effect leak check in chunk optimizer (#9085) by @IWANABETHATGUY
- runtime: release `cb` reference after `__commonJS` factory initialization (#9067) by @hyf0-agent
- `@__NO_SIDE_EFFECTS__` wrapper should not remove dynamic imports (#9075) by @IWANABETHATGUY
- rolldown_plugin_vite_import_glob: use POSIX path join/normalize for glob resolution (#9077) by @shulaoda
- emit REQUIRE_TLA error when require() loads a module with top-level await (#9071) by @jaehafe
- emit namespace declaration for empty modules in manual chunks (#8993) by @privatenumber
- rolldown_plugin_vite_import_glob: keep common base on path segment boundary (#9070) by @shulaoda
- prevent circular runtime helper imports during facade elimination (#8989) (#9057) by @IWANABETHATGUY
- correct circular dependency check in facade elimination (#9047) by @h-a-n-a
- docs: correct dead link in CodeSplittingGroup.tags JSDoc (#9051) by @hyf0
- emit DUPLICATE_SHEBANG warning when banner contains shebang (#9026) by @IWANABETHATGUY

### 🚜 Refactor

- use semantic reference flags for member write detection (#9060) by @Dunqing
- extract UsedSymbolRefs newtype wrapper (#9130) by @IWANABETHATGUY
- dedupe await wrapping in export-star init emit (#9119) by @IWANABETHATGUY
- calculate side-effect-free function symbols on demand (#9120) by @IWANABETHATGUY
- extract duplicated top-level await handling into shared helper (#9087) by @IWANABETHATGUY
- rolldown_plugin_vite_import_glob: use split_first for get_common_base (#9069) by @shulaoda
- simplify ESM init deduplication with idiomatic insert check (#9044) by @IWANABETHATGUY

### 📚 Documentation

- document runtime module placement strategy in code-splitting design (#9062) by @IWANABETHATGUY
- clarify `options` hook behavior difference with Rollup in watch mode (#9053) by @sapphi-red
- meta/design: introduce module tags (#9017) by @hyf0

### ⚡ Performance

- convert `generate_transitive_esm_init` to iterative (#9046) by @IWANABETHATGUY

### 🧪 Testing

- merge strict/non_strict test variants using configVariants (#9089) by @IWANABETHATGUY

### ⚙️ Miscellaneous Tasks

- disable Renovate auto-updates for oxc packages (#9129) by @IWANABETHATGUY
- upgrade oxc@0.126.0 (#9127) by @Dunqing
- deps: update napi to v3.8.5 (#9126) by @renovate[bot]
- deps: update dependency @napi-rs/cli to v3.6.2 (#9123) by @renovate[bot]
- move lazy-compilation design doc (#9117) by @h-a-n-a
- deps: update dependency vite-plus to v0.1.18 (#9118) by @renovate[bot]
- deps: update dependency vite-plus to v0.1.17 (#9113) by @renovate[bot]
- deps: update oxc to v0.125.0 (#9094) by @renovate[bot]
- deps: update dependency follow-redirects to v1.16.0 [security] (#9103) by @renovate[bot]
- deps: update test262 submodule for tests (#9097) by @sapphi-red
- deps: update crate-ci/typos action to v1.45.1 (#9096) by @renovate[bot]
- deps: update rust crates (#9081) by @renovate[bot]
- deps: update npm packages (#9080) by @renovate[bot]
- remove outdated TODO in determine_module_exports_kind (#9072) by @jaehafe
- rust/test: support `extendedTests: false` shorthand in test config (#9050) by @hyf0
- ci: extract shared infra-changes anchor in path filters (#9054) by @hyf0
- add docs build check to catch dead links in PRs (#9052) by @hyf0

### ❤️ New Contributors

* @thezzisu made their first contribution in [#9101](#9101)
* @fpotter made their first contribution in [#9086](#9086)
* @jaehafe made their first contribution in [#9071](#9071)
* @privatenumber made their first contribution in [#8993](#8993)

Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.com>
IWANABETHATGUY added a commit that referenced this pull request Apr 25, 2026
## Summary

Fixes a regression introduced by #9049: when the chunk optimizer merges
a shared module into an entry chunk, it can produce a static import
cycle between that entry chunk and a manual-split chunk that also
depends on the shared module.

## Problem

`would_create_circular_dependency` decides whether merging `source` (the
temp chunk holding shared modules) into `target` (an existing entry
chunk) is safe. The merge is unsafe iff, post-merge, `target` would
reach itself in the dependency graph.

#9049 split the algorithm into two cases based on whether `source` has
dependencies, in order to reduce false positives that were blocking
legitimate merges (the PR reported ~1,046 blocked merges and +12.5%
bundle size on a 2,200-entry application). However, the `source has
deps` branch only does a forward BFS from `source.deps` and drops the
post-merge alias step that the original algorithm used to model new
back-edges introduced by the merge:

```rust
// pre-#9049 step (removed in the source-has-deps branch)
if self.chunks[chunk_idx].dependencies.contains(&source_chunk_idx) {
  queue.push_back(target_chunk_idx);
}
```

Without that step, the BFS cannot see cycles where the closing edge is
one of the back-edges created by retargeting `source`'s importers onto
`target`.

## Concrete repro shape (issue #9225)

- `main.js` (entry) statically imports `api.js` and `env.js`, plus
dynamic imports `lazy.js`, `env-user.js`, `dep-user.js`.
- `api.js` is captured by a manual-split group `{ name: "api", test:
"api\\.js" }`.
- `api.js` statically imports `env.js`; `env-user.js` statically imports
`env.js`.
- `env.js` is a shared module not captured by any group
(`includeDependenciesRecursively: false`).

The optimizer selects `main` as the static dominator for `env.js`'s
consumers and merges `env.js` into it. After the merge:

- `api.js` and `env-user.js` rewrite their `./env.js` imports to
`./main.js`.
- `main.js` still statically imports `./api.js` (pre-existing edge).
- → static cycle `main.js ⇄ api.js` in the output.

In the `source has deps` branch, the BFS starts from `source.deps = {
dep.js's chunk }`. `dep.js` has no further deps and does not reach
`main`, so the BFS returns `false` and the merge proceeds. The dropped
alias step would have queued `target` when the BFS visited `api`'s chunk
(whose `dependencies` contain `source`), correctly detecting the cycle.

## Fix

Revert `would_create_circular_dependency` to the pre-#9049 unified
algorithm:

- BFS from `source.deps ∪ target.deps`.
- For each visited chunk `c`, when `source ∈ c.dependencies`, also
enqueue `target` (modeling the post-merge edge `c → target`).
- If `target` is dequeued, the merge would create a cycle.

This is sound: it catches both the forward path (`source.deps → target`)
and the back-edge path (`target → … → c → source-becomes-target`).

## Why reverting #9049 is safe: the `issues/9049` fixture is already
covered by #9085

The existing `crates/rolldown/tests/rolldown/issues/9049/` fixture
continues to pass after this revert, because **#9049's
`would_create_circular_dependency` change was never load-bearing for
that fixture**. Bisecting between rc.15 and rc.16 (rc.15 produces 4
chunks, rc.16 produces 3) pinpoints the actual fix as **#9085** — `fix:
relax overly conservative side-effect leak check in chunk optimizer`
(commit `5ff1a7265`). #9085 narrows the side-effect leak guard in
`try_insert_into_existing_chunk`: the merge is now blocked only when a
*different* user-defined entry can also reach the dynamic entries
depending on the common chunk, instead of unconditionally blocking on
any such dependency. That relaxation is what allows
`services/svc{0,1}.js` to merge into the `route0/route1` chunks for this
fixture.

The fixture's name (`issues/9049`) is therefore historically misleading:
it tests behavior fixed by #9085, not #9049. The false-positive
regressions #9049 was addressing in real-world bundles remain a concern
but are not exercised by any in-tree fixture; a follow-up can re-tighten
the check (e.g. by tracking dependent aliases more precisely) without
losing the regression coverage this PR adds.

## Regression test

`crates/rolldown/tests/rolldown/issues/9225/` reproduces the cycle that
#9049 was masking.

- `_config.json` sets up `manualCodeSplitting.groups = [{ name: "api",
test: "api\\.js" }]` with `includeDependenciesRecursively: false`.
- `_test.mjs` walks `dist/*.js`, parses the static-import edges, and
asserts the graph has no cycle. It additionally executes the bundled
entry, checks `globalThis.__rolldown_issue_7449_value === 300000`, and
verifies that the side-effect module `dep.js` runs exactly once even
when the dynamic chunks (which all transitively depend on `dep.js`)
load.

Without this fix, `_test.mjs` fails on the cycle assertion before any
runtime check runs.

## Testing

- `cargo test -p rolldown --test integration` — **1692 passed, 0 failed,
70 ignored**.
- `just test-node` (Rolldown vitest `test:main` + `test:watcher` +
Rollup compatibility suite) — **722 + 31 + 916 passed, 0 failed**.
- New `issues/9225` fixture passes both the cycle assertion and the
runtime checks.

Closes #9225.
`

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@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.

2 participants