Skip to content

fix(rolldown): keep entry facade when a shared chunk holds another entry's module#9997

Merged
graphite-app[bot] merged 1 commit into
mainfrom
fix/9463-chunkopt-exec-order
Jun 28, 2026
Merged

fix(rolldown): keep entry facade when a shared chunk holds another entry's module#9997
graphite-app[bot] merged 1 commit into
mainfrom
fix/9463-chunkopt-exec-order

Conversation

@hyf0

@hyf0 hyf0 commented Jun 28, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the strictExecutionOrder execution-order corruption reported in #9463: with experimental.chunkOptimization and a codeSplitting group that captures multiple user-defined entry modules, executing one entry eagerly ran another entry's top-level side effects.

Reproduction (the issue's REPL, as tests/rolldown/issues/9463/): entry a statically imports shared, entry b dynamically imports it, with an entriesAware group + large entriesAwareMergeThreshold + strictExecutionOrder.

Before — loading b runs a's side effects:

// a.js  → init_a/init_b/init_shared live here; chunk top-level unconditionally calls:
init_a();
export { init_b as t };
// b.js
import { t as init_b } from "./a.js";   // importing a.js runs init_a() first
init_b();

After — each entry runs only its own init_*:

// a.js → import { n as init_a } from "./common~a~b~shared.js"; init_a();
// b.js → import { t as init_b } from "./common~a~b~shared.js"; init_b();

Root cause

Manual code splitting can place several user-defined entry modules into one shared Common chunk (the entriesAware entriesAwareMergeThreshold merges subgroups by bitset similarity and unions their bits; a plain group unions bits directly). chunkOptimization's facade elimination (find_facade_chunk_merge_ops) then folds that shared chunk into one entry chunk, which makes that entry chunk eagerly run its own init_*. The other entry, whose module also lives in the shared chunk, is then forced to import the first entry chunk to reach its own module — and running that chunk eagerly executes the first entry's side effects.

The existing safety check only ran for preserveEntrySignatures: 'strict'. Entries with no exports resolve to AllowExtension and took the _ => true arm, so nothing blocked the fold.

The fix

In the user-defined-entry branch of find_facade_chunk_merge_ops, only fold the common chunk into the entry chunk when that chunk does not also hold another user-defined entry's module. Otherwise the thin facade is kept, so each entry imports the (wrapped) shared chunk and runs only its own init_*.

A common chunk that holds just this entry's module (plus non-entry deps a sibling genuinely depends on) is still folded — preserving the #5726 facade elimination.

Tests

Three fixtures with behavioral _test.mjs that execute the output and assert the actual semantics (not just chunk snapshots) — each module records its top-level side effect in a global; loading entry b must run only b's side effects and must not contain another entry's:

  • tests/rolldown/issues/9463/ — the reported case (entriesAware group, dynamic shared dep).
  • tests/rolldown/issues/9463_plain_group/ — plain (non-entriesAware) group, to prove the guard is not entriesAware-specific.
  • tests/rolldown/issues/9463_three_entries/ — three entries, to prove it scales past two.

All three were confirmed to fail on the pre-fix code (e.g. ["a","shared-foo","b","shared-foo"]) and pass with the fix.

I also audited the rest of the chunk-optimizer merge/fold/wrap sites for the same "eager cross-entry execution" family and verified (by running probes) that the strict path is otherwise closed: under strictExecutionOrder every side-effectful module is wrapped (including under onDemandWrapping, since an observable side effect makes a module execution-order-sensitive), so the only eager leak was the entry-chunk fold fixed here. Dynamic→group / dynamic→common / dynamic→user-entry / static-via-intermediate / plain-group shapes were all checked and are isolated under strict. Full rolldown integration suite: no snapshot regressions; #5726 and strict_execution_order/on_demand_wrapping unchanged.

Out of scope / follow-up

This addresses the strictExecutionOrder case from the issue. The same logical leak still exists without strictExecutionOrder when a manual code-splitting group co-locates modules of different entry reachability: there the shared chunk's modules are eager (unwrapped), so loading one entry runs the co-located code of another. For non-entry modules this is the pre-existing manual-group over-inclusion tradeoff (see advanced_chunks/entries_aware_merge_shared_deps); for entry modules it is a genuine isolation gap. The structural cause is manual code splitting producing chunks whose bits is a union across modules of different reachability. Tracked in #9998; a complete fix would make manual splitting respect reachability boundaries (or keep cross-entry modules wrapped in non-strict mode too).

Closes #9463

@netlify

netlify Bot commented Jun 28, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs canceled.

Name Link
🔨 Latest commit e05d9b3
🔍 Latest deploy log https://app.netlify.com/projects/rolldown-rs/deploys/6a411f6e90b2ce0007849713

@hyf0 hyf0 force-pushed the fix/9463-chunkopt-exec-order branch 2 times, most recently from a644523 to 1e7b809 Compare June 28, 2026 10:31
@hyf0 hyf0 marked this pull request as ready for review June 28, 2026 10:33
@codspeed-hq

codspeed-hq Bot commented Jun 28, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 7 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing fix/9463-chunkopt-exec-order (0e6ca24) with main (a2e6966)

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 commented Jun 28, 2026

Copy link
Copy Markdown
Member Author

Merge activity

…try's module (#9997)

## Summary

Fixes the `strictExecutionOrder` execution-order corruption reported in #9463: with `experimental.chunkOptimization` and a `codeSplitting` group that captures multiple **user-defined entry** modules, executing one entry eagerly ran another entry's top-level side effects.

Reproduction (the issue's REPL, as `tests/rolldown/issues/9463/`): entry `a` statically imports `shared`, entry `b` dynamically imports it, with an `entriesAware` group + large `entriesAwareMergeThreshold` + `strictExecutionOrder`.

Before — loading `b` runs `a`'s side effects:
```js
// a.js  → init_a/init_b/init_shared live here; chunk top-level unconditionally calls:
init_a();
export { init_b as t };
// b.js
import { t as init_b } from "./a.js";   // importing a.js runs init_a() first
init_b();
```
After — each entry runs only its own `init_*`:
```js
// a.js → import { n as init_a } from "./common~a~b~shared.js"; init_a();
// b.js → import { t as init_b } from "./common~a~b~shared.js"; init_b();
```

## Root cause

Manual code splitting can place several user-defined entry modules into one shared `Common` chunk (the `entriesAware` `entriesAwareMergeThreshold` merges subgroups by bitset similarity and unions their bits; a plain group unions bits directly). `chunkOptimization`'s facade elimination (`find_facade_chunk_merge_ops`) then folds that shared chunk **into** one entry chunk, which makes that entry chunk eagerly run its own `init_*`. The other entry, whose module also lives in the shared chunk, is then forced to `import` the first entry chunk to reach its own module — and running that chunk eagerly executes the first entry's side effects.

The existing safety check only ran for `preserveEntrySignatures: 'strict'`. Entries with no exports resolve to `AllowExtension` and took the `_ => true` arm, so nothing blocked the fold.

## The fix

In the user-defined-entry branch of `find_facade_chunk_merge_ops`, only fold the common chunk into the entry chunk when that chunk does **not** also hold another user-defined entry's module. Otherwise the thin facade is kept, so each entry imports the (wrapped) shared chunk and runs only its own `init_*`.

A common chunk that holds just this entry's module (plus non-entry deps a sibling genuinely depends on) is still folded — preserving the #5726 facade elimination.

## Tests

Three fixtures with **behavioral** `_test.mjs` that execute the output and assert the actual semantics (not just chunk snapshots) — each module records its top-level side effect in a global; loading entry `b` must run only `b`'s side effects and must not contain another entry's:
- `tests/rolldown/issues/9463/` — the reported case (entriesAware group, dynamic shared dep).
- `tests/rolldown/issues/9463_plain_group/` — plain (non-`entriesAware`) group, to prove the guard is not entriesAware-specific.
- `tests/rolldown/issues/9463_three_entries/` — three entries, to prove it scales past two.

All three were confirmed to **fail on the pre-fix code** (e.g. `["a","shared-foo","b","shared-foo"]`) and pass with the fix.

I also audited the rest of the chunk-optimizer merge/fold/wrap sites for the same "eager cross-entry execution" family and verified (by running probes) that the **strict** path is otherwise closed: under `strictExecutionOrder` every side-effectful module is wrapped (including under `onDemandWrapping`, since an observable side effect makes a module execution-order-sensitive), so the only eager leak was the entry-chunk fold fixed here. Dynamic→group / dynamic→common / dynamic→user-entry / static-via-intermediate / plain-group shapes were all checked and are isolated under strict. Full `rolldown` integration suite: no snapshot regressions; `#5726` and `strict_execution_order/on_demand_wrapping` unchanged.

## Out of scope / follow-up

This addresses the `strictExecutionOrder` case from the issue. The same logical leak still exists **without** `strictExecutionOrder` when a manual code-splitting group co-locates modules of different entry reachability: there the shared chunk's modules are eager (unwrapped), so loading one entry runs the co-located code of another. For non-entry modules this is the pre-existing manual-group over-inclusion tradeoff (see `advanced_chunks/entries_aware_merge_shared_deps`); for entry modules it is a genuine isolation gap. The structural cause is manual code splitting producing chunks whose `bits` is a union across modules of different reachability. Tracked in #9998; a complete fix would make manual splitting respect reachability boundaries (or keep cross-entry modules wrapped in non-strict mode too).

Closes #9463
@graphite-app graphite-app Bot force-pushed the fix/9463-chunkopt-exec-order branch from 0e6ca24 to e05d9b3 Compare June 28, 2026 13:19
@graphite-app graphite-app Bot merged commit e05d9b3 into main Jun 28, 2026
33 of 34 checks passed
@graphite-app graphite-app Bot deleted the fix/9463-chunkopt-exec-order branch June 28, 2026 13:23
@rolldown-guard rolldown-guard Bot mentioned this pull request Jul 1, 2026
shulaoda added a commit that referenced this pull request Jul 1, 2026
## [1.1.4] - 2026-07-01

### 🚀 Features

- disable `experimental.lazyBarrel` by default (#10071) by @shulaoda

### 🐛 Bug Fixes

- dev: disable lazy barrel in dev mode (#10060) by @shulaoda
- generate: keep full JSON interface under preserveModules namespa… (#10056) by @IWANABETHATGUY
- check finalize_other_specifiers in its own Debug attribute (#10032) by @shulaoda
- serialize the KeepAssign unused minify option as "keep_assign" (#10031) by @shulaoda
- keep fragments after the newline fragment in MagicString::last_line (#10023) by @shulaoda
- generate: undeclared JSON named exports under preserveModules (#10020) (#10027) by @IWANABETHATGUY
- deconflict: rename CJS-wrapped locals that shadow chunk-root bindings (#9921) by @IWANABETHATGUY
- rolldown: keep entry facade when a shared chunk holds another entry's module (#9997) by @hyf0
- treeshake: also bail JSON default split when the object escapes (#9996) by @IWANABETHATGUY
- don't classify await in a strict-mode function as top-level await (#9987) by @shulaoda
- avoid spurious leading newline in addon hooks (banner/footer/intro/outro) (#9989) by @shulaoda
- handle JSON default mutation bailouts (#9972) by @TheAlexLichter
- plugin: make lazy hook metadata enumerable (#9991) by @TheAlexLichter
- dev: make init errors in lazy-compiled modules catchable (#9981) by @h-a-n-a
- treeshake: keep computed-key side effects on namespace member access (#9986) by @shulaoda
- binding: validate replace plugin delimiters length instead of panicking (#9984) by @shulaoda
- reconstruct nested rest patterns in into_expression (#9980) by @IWANABETHATGUY
- reconstruct rest patterns as spread in into_expression (#9976) by @shulaoda
- preserve export keyword on multi-declarator exports under keepNames (#9974) by @shulaoda
- deterministically keep the shortest name for deduplicated assets (#9948) by @x1024
- treeshake: apply @__NO_SIDE_EFFECTS__ to cross-chunk namespace calls (#9960) by @IWANABETHATGUY

### 🚜 Refactor

- drop redundant program scope enter/leave in finalizer (#10049) by @shulaoda
- deconflict: extract collect_chunk_scope_captured_names (#10006) by @IWANABETHATGUY
- unify pre-scan multi-declarator split into one decision site (#9982) by @IWANABETHATGUY
- common: return bool from SymbolRef::is_not_reassigned (#9962) by @IWANABETHATGUY

### 📚 Documentation

- rolldown: remove outdated comment for removing parenthesized expression (#10062) by @Dunqing
- use GitHub-flavored alert for Etiquette note in contribution guide (#10012) by @IWANABETHATGUY
- replace: explain the delimiters left and right boundaries (#9985) by @shulaoda
- ast-mutation: remove stale Address Use section after pre-scan refactor (#9983) by @IWANABETHATGUY
- remove fathom (#9968) by @mdong1909
- contribution-guide: code-format main branch references (#9966) by @IWANABETHATGUY
- contribution-guide: fix stale REPL note and tidy wording (#9957) by @hyf0
- contribution-guide: clarify when to discuss before opening a PR (#9955) by @hyf0

### ⚡ Performance

- disable preserve_parens across all parse paths (#10057) by @Dunqing
- common: inline declared_symbols with SmallVec (#9920) by @IWANABETHATGUY
- common: pack TaggedSymbolRef into 8 bytes (#9919) by @IWANABETHATGUY
- sourcemap: skip newline scan on the no-sourcemap join fast path (#9936) by @Boshen

### 🧪 Testing

- dev: error in lazy module should be catchable (#9975) by @sapphi-red
- dev: reject unknown lazy compile modules (#9969) by @sapphi-red

### ⚙️ Miscellaneous Tasks

- deps: update actions/cache action to v6 (#10001) by @renovate[bot]
- trigger vite ecosystem-ci from PR comments (#10058) by @shulaoda
- deps: update napi to v3.10.0 (#10063) by @renovate[bot]
- remove unused From impl for RolldownLabelSpan (#10055) by @shulaoda
- remove dead Diagnostic::with_kind method (#10054) by @shulaoda
- remove unused StatementExt methods (#10053) by @shulaoda
- remove unused ExpressionExt methods (#10052) by @shulaoda
- remove commented-out re_export_all_names field (#10051) by @shulaoda
- deps: update pnpm to v11.9.0 (#10047) by @renovate[bot]
- remove the unused BindingGenerateHmrPatchReturn napi type (#10034) by @shulaoda
- remove the dead inline_entry_chunk_wrapping scaffolding (#10037) by @shulaoda
- deps: bump oxc_resolver to 11.22.0 (#10045) by @Boshen
- remove never-constructed MatchImportKind::_Ignore variant (#10041) by @shulaoda
- remove the unused ScheduledBuild napi struct (#10033) by @shulaoda
- remove dead compute_hmr_update_single method (#10040) by @shulaoda
- drop the redundant visited.insert in manual code splitting (#10038) by @shulaoda
- remove the dead output_assets vector in render_chunk_to_assets (#10036) by @shulaoda
- remove the unused From<String>/Display impls for BindingLogLevel (#10035) by @shulaoda
- deps: upgrade oxc to 0.138.0 and migrate to per-type AST construction (#10018) by @shulaoda
- deps: update rust crates (#9911) by @renovate[bot]
- deps: update test262 submodule for tests (#10016) by @rolldown-guard[bot]
- deps: update github actions (#9999) by @renovate[bot]
- deps: update npm packages (#10000) by @renovate[bot]

### ◀️ Revert

- "fix(plugin): make lazy hook metadata enumerable (#9991)" (#10005) by @shulaoda

### ❤️ New Contributors

* @x1024 made their first contribution in [#9948](#9948)

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.

chunkOptimization causes the output has wrong execution order with strictExecutionOrder: true

2 participants