feat(code-splitting): wrap strict execution order modules on demand#10104
Conversation
✅ Deploy Preview for rolldown-rs canceled.
|
4ce9346 to
e6585a3
Compare
09e70de to
5a47f3b
Compare
f2aeed7 to
4df73b8
Compare
e386c18 to
e3e1baf
Compare
Merging this PR will not alter performance
Comparing Footnotes
|
|
I recommend splitting all newly added tests into a separate PR to make the PR review more manageable. |
… strict order The entry/facade-chunk loop called add_transitive_esm_init_depended_symbols for the entry module with no strict gate, but legacy interop transitive_esm_init_targets exist flag-off too. For an interop-wrapped entry rendered behind a facade chunk, with an excluded re-export whose wrapped targets share the entry's host chunk, the facade would gain a dead cross-chunk init_* import that the pre-#10104 base never emitted. Gate this entry-level call on is_strict_execution_order_enabled(). Strict still needs it (it feeds order-wrapped entries' prologue init imports). The per-module call in add_module_esm_init_depended_symbols stays ungated: it is provably inert flag-off, since legacy targets are same-chunk-only and same-chunk targets never become cross-chunk imports. Behavior/snapshot impact: none. Full suite runs with zero snapshot changes vs the ungated base — for a non-facade entry the entry chunk equals the host chunk, so the registered targets are same-chunk and never turn into cross-chunk imports; only a facade entry (entry module hosted elsewhere) would differ.
…llector unification #10104 replaced the finalizer's private wrapped-esm init-target collector with the shared one, changing flag-off interop emission in two ways: star re-export records now iterate sorted, non-ambiguous resolved exports (ambiguous-export owners no longer receive a per-record init; order is name-order not hash-order), and named/namespace specifiers are liveness-gated per importer (an `import * as ns` consumer that only reads `ns.x` no longer inits every wrapped exporter). Two directed flag-off fixtures (no strictExecutionOrder) execute under node and observe side-effect order: - namespace_liveness_gating: a wrapped barrel star-re-exports two side-effectful wrapped leaves; the entry reads only `ns.a`. Both leaves still run (A, B) via the barrel's own init, which calls init_leaf_a and init_leaf_b. - ambiguous_star_reexport: two leaves both export `x` (ambiguous -> undefined); both owners still run (A, B) via the barrel's init. Decision: every side effect still executes — just via the barrel's init rather than redundant per-consumer init sites — and only ordering/bytes changed. The collector unification narrowed redundant per-site inits without dropping semantics. No regression found.
|
Took another pass over this after the two Fable reviews landed. The architecture holds up and I won't repeat what those already covered — everything below is stuff neither of them mentioned, from reading the diff at The ones I'd fix before merge1. The facade-split gate only sees pre-lowering import edges —
The interesting part is that 2. Deconflict doesn't see force-included runtime statements — The other two consumers of statement inclusion both OR in the overlay ( I couldn't find any other path that registers these symbols (synthetic statements only register the wrapper refs, and 3. Moving the runtime sweep after exec-order assignment changes flag-off chunk order — On main the sweep ran before exec-order assignment — there was even a comment saying it had to. Now exec orders are assigned first, and the Common-chunk comparator keys on Concretely: strict off, sweep fires (the #9374 walk-back shape), runtime shares chunk C with some user module. Main keyed C on that module's exec order; this PR keys it on 0, so C can sort ahead of chunks it used to follow. That feeds Worth fixing, less urgent4. The set records 5. Both earlier reviews suggested pinning this with 6. Dead importers can force runtime helpers into the output —
Maintainability7. The liveness overlay pattern already claimed its first victim. Order-lowering's demand is OR-ed into consumers at five call sites instead of being folded into 8. A few duplications that will drift:
Perf quickies
Nits
One meta-note: I also chased a few things that turned out fine and might save the next reader time — the dead-specifier/ambiguous-star flag-off deviations the deep review asked fixtures for can't actually drop a side effect (side-effectful targets always force an earlier init through the barrel; the residual is byte-parity only, so a strict-gate on Nice work overall — the emit/register/project contract centralization in particular paid off; most of what I found lives in the seams around it, not in it. |
|
Verified all three pre-merge items against the code — all real. Fixed as three stacked draft PRs continuing the follow-up stack, each with a directed fixture:
Items 4–8, the perf quickies, and the nits are being recorded on #10294 so the follow-up ledger stays in one place. Two notes on the meta-note: the byte-parity conclusion on the collector unification matches what #10324's executing fixtures found independently (both leaves' effects still run via the barrel's own init), and agreed on the recursion-depth argument — dropping that item. Full follow-up stack on this PR, bottom-up: #10320 #10321 #10322 #10323 #10324 #10334 #10335 #10336. |
Merge activity
|
…10104) ## Summary This PR adds an opt-in on-demand planner for `output.strictExecutionOrder` while preserving both the flag-off path and strict mode's existing wrap-all default. | Configuration | Execution-order behavior | | --- | --- | | `strictExecutionOrder: false` | No execution-order wrapping | | `strictExecutionOrder: true` | Wrap every eligible module (wrap-all, the conservative default) | | `strictExecutionOrder: true` + `experimental.onDemandWrapping: true` | Derive a conservative wrapping plan from the generated chunk graph | The on-demand plan is deliberately conservative rather than minimal. It begins with predicted source-order versus chunk-order hazards, then closes over importers, trigger hosts, retained re-export paths, and cyclic chunks where eager evaluation cannot be proven safe. This PR does not change the default behavior of `strictExecutionOrder` and does not enable strict execution order globally. Fixes #10236. > [!IMPORTANT] > Strict-order output may change the entry-chunk shape. When an entry implementation must remain inert while another chunk is evaluated, Rolldown emits a small entry facade that calls the relevant `init_*()` function and moves the implementation into a separate chunk. Reviewers and downstream tooling should expect this facade/implementation split. ## Why the analysis runs after chunking Interop wrapping answers how a module is represented and remains a link-stage decision. Execution-order wrapping answers when an already-included module body may run, which depends on generated chunk placement, manual groups, facade elimination, shared entries, and chunk cycles. This PR keeps link-owned `WrapKind` and user-code liveness immutable, computes an `OrderWrapPlan` after provisional chunking, and lowers that plan into a separate `OrderWrapState`. Lowering may add synthetic wrappers, init calls, runtime symbols, entry facades, and topology state; it cannot make an excluded user statement or binding live. The on-demand path then projects imports added by lowering back into the chunk graph. If those imports create a new chunk cycle, every eligible module in the cyclic chunks is added to the plan and projection repeats until stable. The process is monotone and bounded by the module count. ## Correctness hardening - Preserve init obligations through re-export barrels without reviving tree-shaken imports or unrelated exports. - Share init-obligation enumeration between emission, cross-chunk registration, and fixpoint projection so their record gates cannot drift. - Preserve the same link-stage tree-shaking result in wrap-all and on-demand, including consumer-local named and namespace re-export paths. - Keep cross-chunk `init_*` bindings live and deconflicted for both ESM and CJS output. - Restore user, dynamic, and emitted entry facades after strict lowering changes chunk topology. - Recompute topology-derived metadata and final output validation after lowering. Tree-shaking parity here is specifically between wrap-all and on-demand. It does not claim byte identity with flag-off output and does not include the separate `wrappedModuleTreeshaking` work. ## Review guide 1. Start with `internal-docs/code-splitting/design.md` for the invariants, mode split, conservative decisions, and projected/omitted edge inventory. 2. Read `order_analysis.rs` for source-order versus chunk-order prediction and the emergent-cycle fixpoint. 3. Read `order_wrap_state.rs` and `order_wrapping.rs` for the late synthetic state and lowering boundary. 4. Read `esm_init_obligations.rs` together with the module finalizers and cross-chunk-link pass for the shared emit/register/project contract. 5. Review #10287 for config/variant normalization and the current-main output baseline. 6. Review this PR's test diff for removal of every temporary expected-failure marker and the same-position snapshots of the final facade/runtime/chunk layouts. ## Test-first sequence The earlier test-first layers are already merged into `main`: 1. #10253 adds the fail-closed runtime-failure harness and core integration regressions. 2. #10252 adds the later hardening fixtures and paired-mode coverage. The remaining Graphite stack is: 1. #10287 establishes the final config/variant review layout and current-`main` snapshots while preserving all expected-failure markers. 2. #10104 (this PR) adds the implementation, removes all temporary failure markers, and records the implementation output. The final tree contains 96 strict-enabled fixture configs and 200 named strict executions. Between #10287 and #10104, 21 config files only delete 44 expected-failure markers, and all 93 changed snapshots retain identical variant-heading order. ## Validation Local validation ran on rebased head `79efe909bb3f0bcd2abcc76dcd33e58069c3474c`, based on `main` at `c4e25974b8ff7598bf507ae4a2d7602cbcbc86fd`. - The 86 changed fixtures passed twice on #10287 with the old implementation; the second pass was snapshot-stable. - All 96 strict-enabled fixture configs passed twice on this implementation, with a snapshot-stable final pass. - Full Rust workspace tests passed with `just test-rust`. - Rolldown Node tests passed: 793 main tests and 38 watcher tests. - The Rollup compatibility suite passed 1,213 tests with 0 failures. - `cargo check --workspace --all-features --all-targets --locked` and `cargo fmt --all --check` passed. - Clippy passed with `--deny warnings` for every changed Rust crate: `rolldown`, `rolldown_common`, and `rolldown_ecmascript_utils`. - `just lint-node` and `just lint-repo` passed. - Full-workspace `just lint-rust` is locally blocked on Rust 1.97 by `clippy::ignore-without-reason` in the unchanged `rolldown_plugin_isolated_declaration/tests/mod.rs`; the failing path is byte-identical to `main`. ## Frozen closure evidence The broader correctness campaign was frozen on one build of `42628c18b`: - Differential fuzzer full matrix: 3,000/3,000 passed (ESM/CJS/mixed inputs × wrap-all/on-demand × 500 cases). - Directed barrel/definer family: 600/600 passed; the same generated shapes failed about 23.7% of the time before the fix. - Directed eager-barrel family: 20/20 passed in wrap-all and 20/20 in on-demand. - #9887 strict-path bracket passed on the branch and failed 20/20 on released `[email protected]`. This PR restores strict mode as an escape hatch; the non-strict path remains a separate issue, so this PR does not close #9887. - #10236 CJS-output/on-demand bracket passed on the branch and failed all 8 directed seeds on released `[email protected]`. - ComfyUI, Camunda, shadcn-admin, and vue-vben-admin passed the flag-off/on-demand/wrap-all browser-probed matrix. Fuzzer source and machine-readable evidence: https://github.com/hyf0/rolldown-order-fuzzer/tree/853fb775d1e949ced567deb0c163d044a031ba4f/.agents/evidence ## Boundaries and known residuals - Top-level-await output remains legal, but this PR does not promise stronger TLA scheduling semantics than the default build. - External-module evaluation order remains outside the strict-order guarantee; generated output must still be legal. - #9998 is a separate bug in the non-strict/default code-splitting path and is not fixed here. The strict path covered by this PR passes its directed controls. - Camunda still emits two `init_*` functions with no call site (`OverflowMenu$1` and `RadioButton$1`). They were harmless on all probed routes and have no synthetic reproduction. - The partial-forwarder per-obligation refinement is present in the shared contract, but no constructible corpus shape currently exercises that narrower branch directly. - On vue-vben-admin, the emergent-cycle fixpoint converged in two rounds and added 141 wrappers over the one-shot plan. `ROLLDOWN_ORDER_DEBUG=1` reproduces the per-round trace.
2ee23cc to
1d86ffe
Compare
## Summary - Add the circular ESM initialization graph reported in #10265, whose legacy init-call transfer failure is addressed by #10270. - Exercise both strict modes: `strictExecutionOrder: true` by itself (wrap-all), and strict execution order with `experimental.onDemandWrapping: true`. - Execute the generated entry in both cells and assert that the circular `init_a` / `init_b` path completes without calling an uninitialized wrapper. ## What this proves The failure addressed by #10270 comes from the legacy `ensure_lazy_module_initialization_order()` transfer moving an `init_*()` call before a later wrapper assignment. With #10104's new strict implementation, that function is still called but returns before running the transfer logic; the wrap-all or on-demand execution-order analysis owns scheduling instead. The exact circular graph executes successfully in both cells, demonstrating that this failure mode is absent from both new strict paths. Current `main` at `03e1e3422` also passes both strict configurations through the old implementation, so this is path-specific regression coverage rather than a `main`-fails / parent-passes claim. ## Scope This conclusion is limited to strict execution order, in wrap-all and on-demand modes. Default and non-strict output still use the legacy transfer logic and are outside this test. Refs #10265 and #10270. ## Validation - `CARGO_TARGET_DIR=/tmp/codex-rolldown-pr10104-target just t-run crates/rolldown/tests/rolldown/issues/10265/_config.json` (twice; snapshot-stable) - Both strict cells also pass on `main` at `03e1e3422`, with different generated layouts - `just lint-node` - `just lint-repo` - `git diff --check`
…ep (#10334) ## Summary Flag-off builds could emit cross-chunk imports — and therefore run side effects — in a different order than `main`, on the default path. When the post-chunking runtime sweep drops the runtime module out of a shared chunk it co-hosts with user modules, that chunk was left with an execution order keyed on the removed runtime (exec order `0`) instead of its first surviving module, so it sorted ahead of chunks it should have followed. This restores `main`'s ordering by re-deriving chunk execution orders after the sweep. ## Verified mechanism - Before #10104 (`1d86ffe06`), `sweep_unused_runtime_module` ran in `generate_chunks` **before** chunk exec-order assignment (there was a comment requiring it). #10104 moved the sweep into `finalize_chunk_plan`, which runs **after** assignment, so the regression is live on `main` today. - The Common-chunk comparator keys on `modules[0]`'s exec order; `sort_chunk_modules` always makes the runtime (module exec order `0`) `modules[0]`. So a chunk hosting the runtime keys on `0`. - When the walk-back (`find_entry_level_external_module`) invalidates the runtime demand (the #9374 star-re-export-to-external shape) and the sweep strips the runtime from a still-live shared chunk, the chunk keeps `exec_order` derived from the now-absent runtime. `rebuild_sorted_chunk_idx_vec(true)` only re-sorts the stale values; `renumber_live_chunks` is strict-only. That stale key feeds both `sorted_chunk_idx_vec` and the cross-chunk import sort (`compute_cross_chunk_links.rs:110/124`), so an entry emits `import "./C.js"` before a chunk that should run first. ## Fix Chunk exec orders must already be assigned before order analysis runs (the on-demand actual-order simulation sorts sibling chunks by `exec_order`), so assignment cannot simply move after the sweep. Instead: - Extract the assignment loop into `assign_chunk_exec_orders` (`code_splitting.rs`); keep the provisional call where it was in `generate_chunks`. - Re-run it in `finalize_chunk_plan` when the sweep actually removed the runtime from a chunk — the same condition that already triggered `rebuild_sorted_chunk_idx_vec(true)` — then rebuild the sorted vec. **Final ordering contract (documented on `assign_chunk_exec_orders`).** Flag-off, `analyze_execution_order` returns `None`, so no lowering and no `renumber_live_chunks` run; the re-run is the sole authority and reproduces exactly what `main` produced by sweeping before assignment. Under strict, the sweep block only runs when `required_runtime_helpers()` is empty (no order wrapper demands the runtime); `renumber_live_chunks` (strict-only, after lowering) merely *compacts* existing `exec_order` values to close tombstone gaps and never re-derives them from `modules[0]`. The re-run happens after lowering, so whichever of the two touches `exec_order` last leaves every live chunk both densely numbered and keyed on its correct lead module — they never contend. ## Behavior / snapshot impact - **New fixture only.** `topics/runtime/sweep_shared_chunk_exec_order` — three entries; `re.js` (`export * from 'external'`) makes tree-shaking include the runtime for `__reExport`, and the runtime folds into `re.js`'s shared `{entry_a, entry_b}` chunk. The walk-back flattens the star re-export to a chunk-level `export * from "external"`, so the post-order sweep drops the runtime from that still-live shared chunk. `entry_a` also imports the earlier side-effect chunk `s_ac.js` (shared `{entry_a, entry_c}`). - **Main-parity proof.** The fixture was built against three states. Pre-#10104 `main` (`ce98ae798`) emits `import "./s_ac.js"` before `import "./re.js"`; current `main` emits `import "./re.js"` first (the runtime-hosting chunk keeping stale exec order `0`) — a real side-effect-order change on the default path; this branch restores the pre-#10104 order. The committed snapshot is therefore the pre-regression output, not an expectation written to match the fix. Reverting only the two source files onto this branch reproduces the failure, so the fixture discriminates. - **No existing snapshot changes.** No other fixture failed under `INSTA_UPDATE=no`, which is what a changed snapshot would do, so no existing snapshot changed. ## Validation - `cargo test -p rolldown` — 1877 passed here against 1876 on `main`, the difference being exactly the new fixture. Both runs share the same 5 environmental failures (`cjs_module_lexer_compat` ×3 + `npm_packages/util_deprecate` need `pnpm install`; `test262_module_code` needs the submodule). Run with `INSTA_UPDATE=no`: `.insta.yaml` sets `update: 'always'`, so a local run without it rewrites snapshots in place and reports success regardless — worth knowing for anyone reproducing the revert-check above. - `cargo fmt --all --check` — clean. - `cargo clippy -p rolldown --all-targets -- -D warnings` — clean. --- Follow-up to #10104; addresses item 3 of #10104 (comment)
…nts (#10336) ## Summary Chunk deconfliction filtered each module's statements on raw `stmt_info_included`, so a runtime helper statement that tree-shaking excluded but strict order lowering force-includes (e.g. `__esmMin`) was rendered and symbol-assigned yet never registered with the renamer. When a user module co-hosted with the runtime declares a top-level binding of the same name, the two share the chunk's root scope: the user's initializer overwrites the runtime helper and the next wrapper's `__esmMin(...)` call throws. This ORs the order overlay into deconfliction's inclusion test, matching the two other consumers. ## Verified mechanism - `compute_cross_chunk_links.rs:438` and `module_finalizers/mod.rs:1623` both OR `order_wrap_state.forces_runtime_stmt(...)` into their inclusion test; `deconflict_chunk_symbols.rs:137` filtered on `meta.stmt_info_included.has_bit(...)` alone. - A single-chunk strict build (`codeSplitting: false`) places the runtime next to user code (`order_wrapping.rs:451-457`). Pure ESM demands no helper at link time, so tree-shaking drops `__esmMin` and strict order lowering force-includes it — the exact statement the deconflict filter dropped. ## Fix `deconflict_chunk_symbols` already receives `order_wrap_state` and `link_output`, so the change is a one-line mirror of the existing two sites: `has_bit(idx) || forces_runtime_stmt(&link_output.runtime, module.idx, stmt_info)` (`forces_runtime_stmt` self-guards `module_idx == runtime.id()`, so it is inert for every non-runtime module). **Item 7 (maintainability).** I kept the inline `||` rather than extracting a shared "is this statement rendered?" accessor: `forces_runtime_stmt` is already the shared half, the other two sites use this exact inline form, and a shared method would have to take `meta` + `stmt_idx` + `stmt_info` (coupling `OrderWrapState` to `LinkingMetadata`) and rewrite two already-correct sites — a larger diff than the reviewer's "keep the diff small" guidance wants for a fix. The three sites now read identically. ## Behavior / snapshot impact - **Full suite: zero existing snapshots change.** No current fixture co-hosts a user binding named after a force-included runtime helper, so the added disjunct fires nowhere else. - **One new fixture** `function/experimental/strict_execution_order/deconflict_forced_runtime_helper` (strict, `codeSplitting: false`): `helper.js` declares a top-level `__esmMin` (hoisted to a root-scope `var __esmMin` by its order wrapper) co-hosted with the runtime; `late.js` is ordered after it. - **Broken output verified on the unfixed base:** two `var __esmMin` at root — the runtime helper and helper.js's binding. `helper`'s init reassigns the shared binding to the user string, and `init_late = __esmMin(...)` then throws `TypeError: __esmMin is not a function` (the fixture fails to execute). - **Fixed:** deconflict registers the runtime statement, so the renamer renames the runtime helper to `__esmMin$1`; every wrapper calls `__esmMin$1(...)` and the user's `__esmMin` keeps its name. Snapshot pins the rename; `_test.mjs` asserts the executed result `{ helper: 'H:USERVAL', late: 'LATE:z' }`. **Note on wrap-all vs on-demand, and the co-hosting mechanism.** The reproduction is a wrap-all strict build (`strictExecutionOrder` without `onDemandWrapping`) because it deterministically co-hosts the runtime with user code in one chunk. The collision needs the runtime co-hosted with a user module *at deconflict time*. `try_merge_runtime_chunk` can co-host the standalone runtime into a user consumer chunk even in multi-chunk builds, but under strict order wrapping `ensure_runtime_module_for_order_wraps` (`order_wrapping.rs:395-457`) relocates a co-hosted runtime (`modules.len() > 1`) back to a standalone chunk unless code splitting is disabled — so at deconflict time the runtime sits next to user code only in single-chunk (`codeSplitting: false`) builds. (This stack's own multi-chunk strict on-demand fixtures, e.g. `facade_gate_emergent_edge`, confirm it — they emit a standalone `rolldown-runtime.js` chunk.) In a single chunk on-demand finds no cross-chunk execution-order hazard, so it wraps nothing and demands no `__esmMin`; wrap-all is the frame that both co-hosts the runtime and force-includes the helper. The fix itself is mode-independent: `forces_runtime_stmt` keys on `required_runtime_helpers()`, covering on-demand and wrap-all and both the `__esmMin` and `__esm` (profiler_names) variants. ## Validation - `cargo test -p rolldown` — 1872 passed (+1 new fixture, executed under node); only the 5 known environmental failures (`cjs_module_lexer_compat` ×3 + `npm_packages/util_deprecate` need `pnpm install`; `test262_module_code` needs the submodule). No existing snapshot changed (the local `symbols_ns2` drift is pre-existing `node_modules`-resolution noise, reproducible with this change reverted, and is not committed). - `cargo fmt --all --check` — clean; `cargo clippy -p rolldown --all-targets -- -D warnings` — clean. --- Follow-up to #10104; addresses item 2 (and the item-7 maintainability note) of #10104 (comment)
…r-sensitive (#10322) ## Summary Fixes the class order-sensitivity gap found while reviewing #10104. A class can be side-effect-free for tree shaking while its definition still reads mutable global state from an extends expression, computed key, static initializer, or static block. Reordering that class across a module that patches the global can change behavior. When Oxc certifies the whole class as side-effect-free, Rolldown now runs an independent definition-time visitor that collects only GlobalVarAccess and PureAnnotation reasons. It never invokes or short-circuits on tree-shaking analysis. Therefore a PureCJS target write remains tree-shaking-only, while its RHS and later siblings are still visited. The visitor covers evaluated binding defaults, computed keys, assignment RHS, direct IIFEs, and nested classes. It skips deferred function and method bodies and instance initializers. The tree-shaking channel is structurally unchanged: the original whole-class Oxc boolean remains its only input, and the new visitor can only add order-sensitive reasons. ## Regression coverage - PureCJS-only and literal binding-default cases remain order-insensitive. - PureCJS followed by a global read or pure-annotated call is order-sensitive in either operand order. - Static-block binding defaults, computed member keys, assignment RHS, direct IIFEs, heritage expressions, and nested class-definition work are covered. - Deferred arrow/function bodies, method bodies, and instance initializers remain order-insensitive. ## Validation - cargo test -p rolldown --lib — 87 passed. - cargo test -p rolldown — 87 unit and 49 integration tests passed; the only failure was the existing missing local test262 submodule. - cargo clippy -p rolldown --lib --tests -- -D warnings — clean. - cargo fmt --all -- --check — clean. Follow-up to #10104; addresses Major 1 from #10104 (comment). Draft, rebased directly onto main.
…llector unification #10104 replaced the finalizer's private wrapped-esm init-target collector with the shared one, changing flag-off interop emission in two ways: star re-export records now iterate sorted, non-ambiguous resolved exports (ambiguous-export owners no longer receive a per-record init; order is name-order not hash-order), and named/namespace specifiers are liveness-gated per importer (an `import * as ns` consumer that only reads `ns.x` no longer inits every wrapped exporter). Two directed flag-off fixtures (no strictExecutionOrder) execute under node and observe side-effect order: - namespace_liveness_gating: a wrapped barrel star-re-exports two side-effectful wrapped leaves; the entry reads only `ns.a`. Both leaves still run (A, B) via the barrel's own init, which calls init_leaf_a and init_leaf_b. - ambiguous_star_reexport: two leaves both export `x` (ambiguous -> undefined); both owners still run (A, B) via the barrel's init. Decision: every side effect still executes — just via the barrel's init rather than redundant per-consumer init sites — and only ordering/bytes changed. The collector unification narrowed redundant per-site inits without dropping semantics. No regression found.
#10104 changed flag-off wrapped-ESM init lowering while sharing strict-order obligation machinery. Pin three resulting paths without enabling strictExecutionOrder. Keep immediate re-export barrels unwrapped and in a shared chunk while later require calls make wrapped leaf init symbols reachable. This reaches the shared collector and covers importer-local namespace liveness plus exclusion of ambiguous star exports. Also cover codeSplitting: false with an excluded re-export to a TLA-tainted leaf. The carrier wrapper must await the forwarded init before executing its later statements. All three generated outputs execute under Node.
#10104 changed flag-off wrapped-ESM init lowering while sharing strict-order obligation machinery. Pin three resulting paths without enabling strictExecutionOrder. Keep immediate re-export barrels unwrapped and in a shared chunk while later require calls make wrapped leaf init symbols reachable. This reaches the shared collector and covers importer-local namespace liveness plus exclusion of ambiguous star exports. Also cover codeSplitting: false with an excluded re-export to a TLA-tainted leaf. The carrier wrapper must await the forwarded init before executing its later statements. All three generated outputs execute under Node.
## Summary Adds three executable **flag-off** regression fixtures for wrapped-ESM init emission changes that landed with #10104. None enables `strictExecutionOrder`, and this PR changes no production source. The earlier version of this PR required each barrel directly, which wrapped the immediate importee and bypassed the shared init-target collector it claimed to cover. The two collector fixtures now keep the immediate barrel at `WrapKind::None` in a shared chunk, while later `require()` calls wrap the leaf owners and make their init symbols reachable from the main chunk. ## Coverage ### Importer-local namespace liveness `namespace_liveness_gating` imports the barrel namespace but reads only `ns.a()`. A second entry places the unwrapped barrel in a shared chunk; later requires make both wrapped leaf init symbols reachable. - Current output emits only `init_leaf_a()` at the namespace-import position. `init_leaf_b()` stays at its later explicit require position. - Pre-#10104 output emitted `init_leaf_a(), init_leaf_b()` together at the namespace-import position. - The shared barrel still evaluates both leaves exactly once; runtime order is `A, B, BEFORE_REQUIRE:a, MAIN`. ### Sorted, non-ambiguous star-export routing `ambiguous_star_reexport` has two wrapped leaves that both export `x`. The entry uses a real `export * from` record; a dynamic CommonJS re-export keeps that record included, while the second entry keeps the immediate barrel unwrapped and in another chunk. - Current output excludes ambiguous `x` from the collector's sorted, non-ambiguous export map and emits no owner init before `__reExport(...)`. - Pre-#10104 output selected an ambiguous owner and emitted `init_mod_a()` before `__reExport(...)`. - Runtime confirms `x` is absent and both owners still evaluate exactly once through the shared barrel. ### Excluded re-export to a TLA wrapper `excluded_reexport_tla` uses `codeSplitting: false`, so an inline dynamic import wraps the barrel and its static dependency chain even with strict execution order disabled. With `moduleSideEffects: false`, the carrier's unused re-export is excluded while the same TLA leaf remains live through a separate `used` import. - Current output emits `await init_leaf()` in the carrier's async wrapper before its later statement. - Pre-#10104 output emitted bare `init_leaf()`, producing `CARRIER:undefined` before the leaf's top-level await completed. - Current runtime order is `LEAF:before, LEAF:after, CARRIER:ready, BARREL:used:marker`. ## Behavior / snapshot impact Tests only: two existing fixtures are corrected, one TLA fixture is added, and their generated snapshots are pinned. No existing production code or unrelated snapshot changes. ## Validation - Rebased onto `main` at `cccf1d0d849c71e80c02fe3ff17a97192f3bf258`. - Generated all three fixtures with the macOS native binding from current-main CI [run 29795703407](https://github.com/rolldown/rolldown/actions/runs/29795703407); every `_test.mjs` passed under Node. - Compared each committed snapshot byte-for-byte with those generated assets after applying the integration harness's runtime-module hiding. - Re-ran the same sources with the pre-#10104 binding from CI [run 29559371389](https://github.com/rolldown/rolldown/actions/runs/29559371389): both collector fixtures show the obsolete extra init, and the TLA fixture logs `CARRIER:undefined`. - `vp fmt ... --check`, `typos`, JS syntax checks, JSON parsing, and `git diff --check` pass. The pre-commit `vp check --fix` hook also completed cleanly. - CI [run 29819995357](https://github.com/rolldown/rolldown/actions/runs/29819995357): Repo, Rust, Node, Cargo, WASI, native-build, and Node 20/22/24 test jobs passed. Vite Test Ubuntu and the three Ubuntu Dev Server jobs stopped before tests because the Vite `rolldown-canary` branch no longer rebases cleanly onto `vite/main` (`pnpm-lock.yaml` conflict); this is unrelated Vite checkout noise. ## AI usage disclosure Per the [AI Usage Policy](https://rolldown.rs/contribution-guide/#ai-usage-policy), OpenAI Codex was used to rebase and review the PR, design the corrected regression shapes, and verify generated output. I reviewed the final diff and validation evidence. --- Follow-up to #10104; addresses Major 2b/2c from the review discussion: #10104 (comment)
…nistic and constant-time (#10320) ## Summary Two independent, cheap fixes to the strict-execution-order cross-chunk link pass (review items 3 and 4): - **Cross-target determinism.** `add_transitive_esm_init_depended_symbols` iterated an `FxHashMap` via `.values()`, whose bucket order follows FxHash layout. FxHash is unseeded but hashes differently on 32-bit vs 64-bit, so that order — which flows into the chunk's `depended_symbols` `FxIndexSet` and thence into imported-symbol rename order (the `$1`/`$2` suffixes) in `deconflict_chunk_symbols` — could diverge between native and wasm32/WASI builds. Now iterated sorted by the owning `StmtInfoIdx`, pinning one order everywhere. It is the single hash-ordered site three review passes converged on; every other hash-ordered effect is already sorted before use. - **Quadratic strict-mode hot loop.** `module_is_in_live_chunk` did a linear `chunk.modules.contains` scan, called for every canonical referenced symbol of every included statement under strict — quadratic on large chunks. Replaced with an O(1) `module_to_chunk` + liveness check. The O(1) rewrite is verified semantically equivalent: `add_module_to_chunk` sets `module_to_chunk[m]` and pushes `m` onto `c.modules` together, and every site that later removes a module from a chunk's `modules` list preserves the invariant (`module_to_chunk[m] == Some(c) && c live ⟹ c.modules ∋ m`) by pairing the removal with clearing `module_to_chunk[m]` (unused-runtime sweep), reassigning it to the destination chunk via `add_module_to_chunk` (runtime relocation, runtime→target merge, `apply_common_chunk_merges`), or marking the emptied chunk `Removed`. No site leaves `module_to_chunk` pointed at a still-live chunk that no longer contains the module, so no removed-module set is needed. The invariant is documented in the function's doc comment. ## Behavior / snapshot impact **None.** Zero fixture snapshot changes. The determinism fix is invisible on native 64-bit (its value is native-vs-wasm parity, which the native suite cannot exercise); the O(1) change is a pure performance rewrite with identical results. ## Validation - `cargo test -p rolldown` — 1868 passed; snapshot-stable (zero deltas). The only failures are 5 pre-existing environmental ones unrelated to this change (`cjs_module_lexer_compat` ×3 and `npm_packages/util_deprecate` need an installed `cjs-module-lexer`; `test262` needs the test262 submodule). - `cargo fmt --all --check` — clean. - `cargo clippy -p rolldown --all-targets -- -D warnings` — clean. --- Follow-up to #10104; addresses the two "cheap pre-merge fixes" (items 3 and 4) from the review discussion: #10104 (comment)
Summary
This PR adds an opt-in on-demand planner for
output.strictExecutionOrderwhile preserving both the flag-off path and strict mode's existing wrap-all default.strictExecutionOrder: falsestrictExecutionOrder: truestrictExecutionOrder: true+experimental.onDemandWrapping: trueThe on-demand plan is deliberately conservative rather than minimal. It begins with predicted source-order versus chunk-order hazards, then closes over importers, trigger hosts, retained re-export paths, and cyclic chunks where eager evaluation cannot be proven safe.
This PR does not change the default behavior of
strictExecutionOrderand does not enable strict execution order globally.Fixes #10236.
Important
Strict-order output may change the entry-chunk shape. When an entry implementation must remain inert while another chunk is evaluated, Rolldown emits a small entry facade that calls the relevant
init_*()function and moves the implementation into a separate chunk. Reviewers and downstream tooling should expect this facade/implementation split.Why the analysis runs after chunking
Interop wrapping answers how a module is represented and remains a link-stage decision. Execution-order wrapping answers when an already-included module body may run, which depends on generated chunk placement, manual groups, facade elimination, shared entries, and chunk cycles.
This PR keeps link-owned
WrapKindand user-code liveness immutable, computes anOrderWrapPlanafter provisional chunking, and lowers that plan into a separateOrderWrapState. Lowering may add synthetic wrappers, init calls, runtime symbols, entry facades, and topology state; it cannot make an excluded user statement or binding live.The on-demand path then projects imports added by lowering back into the chunk graph. If those imports create a new chunk cycle, every eligible module in the cyclic chunks is added to the plan and projection repeats until stable. The process is monotone and bounded by the module count.
Correctness hardening
init_*bindings live and deconflicted for both ESM and CJS output.Tree-shaking parity here is specifically between wrap-all and on-demand. It does not claim byte identity with flag-off output and does not include the separate
wrappedModuleTreeshakingwork.Review guide
internal-docs/code-splitting/design.mdfor the invariants, mode split, conservative decisions, and projected/omitted edge inventory.order_analysis.rsfor source-order versus chunk-order prediction and the emergent-cycle fixpoint.order_wrap_state.rsandorder_wrapping.rsfor the late synthetic state and lowering boundary.esm_init_obligations.rstogether with the module finalizers and cross-chunk-link pass for the shared emit/register/project contract.Test-first sequence
The earlier test-first layers are already merged into
main:The remaining Graphite stack is:
mainsnapshots while preserving all expected-failure markers.The final tree contains 96 strict-enabled fixture configs and 200 named strict executions. Between #10287 and #10104, 21 config files only delete 44 expected-failure markers, and all 93 changed snapshots retain identical variant-heading order.
Validation
Local validation ran on rebased head
79efe909bb3f0bcd2abcc76dcd33e58069c3474c, based onmainatc4e25974b8ff7598bf507ae4a2d7602cbcbc86fd.just test-rust.cargo check --workspace --all-features --all-targets --lockedandcargo fmt --all --checkpassed.--deny warningsfor every changed Rust crate:rolldown,rolldown_common, androlldown_ecmascript_utils.just lint-nodeandjust lint-repopassed.just lint-rustis locally blocked on Rust 1.97 byclippy::ignore-without-reasonin the unchangedrolldown_plugin_isolated_declaration/tests/mod.rs; the failing path is byte-identical tomain.Frozen closure evidence
The broader correctness campaign was frozen on one build of
42628c18b:advancedChunks+ CJS +includeDependenciesRecursively: false→ cross-chunk wrapped-ESM init cycle (init_X is not a function);strictExecutionOrderdoes not help #9887 strict-path bracket passed on the branch and failed 20/20 on released[email protected]. This PR restores strict mode as an escape hatch; the non-strict path remains a separate issue, so this PR does not close [Bug]:advancedChunks+ CJS +includeDependenciesRecursively: false→ cross-chunk wrapped-ESM init cycle (init_X is not a function);strictExecutionOrderdoes not help #9887.[email protected].Fuzzer source and machine-readable evidence: https://github.com/hyf0/rolldown-order-fuzzer/tree/853fb775d1e949ced567deb0c163d044a031ba4f/.agents/evidence
Boundaries and known residuals
init_*functions with no call site (OverflowMenu$1andRadioButton$1). They were harmless on all probed routes and have no synthetic reproduction.ROLLDOWN_ORDER_DEBUG=1reproduces the per-round trace.