Skip to content

fix(code-splitting): collect eager order reasons for on-demand wrapping#10387

Merged
graphite-app[bot] merged 1 commit into
mainfrom
fix/strict-order-eager-reasons
Jul 23, 2026
Merged

fix(code-splitting): collect eager order reasons for on-demand wrapping#10387
graphite-app[bot] merged 1 commit into
mainfrom
fix/strict-order-eager-reasons

Conversation

@hyfdev

@hyfdev hyfdev commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

Strict on-demand wrapping relies on per-statement StmtEvalAnalyzer facts to mark modules execution-order-sensitive. Oxc can validly certify an outer IIFE, member operation, binding pattern, assignment, or manually pure invocation as side-effect-free and stop there, but that certification can hide a nested global read from the separate scheduling decision. The reader module then remains eager and can run before a patch module after code splitting.

This adds an independent eager-evaluation order-reason walk, run only for strict on-demand builds and only while the module and the current statement are not already known to be sensitive. It collects scheduling metadata without changing tree_shaking_flags. Ordinary builds, wrap-all builds, and cross-module tree-shaking re-analysis retain their existing paths, and the class-definition collector keeps its legacy behavior outside the new strict on-demand statement pass.

The walk covers immediately invoked function literals, member children, binding defaults and computed keys, assignment RHS, PureCJS RHS, and call/new/tagged-template expressions covered by manualPureFunctions. Deferred function and method bodies and unconstructed instance fields remain deferred, and a getter behind a propertyReadSideEffects: false read stays invisible, matching the documented TopLevelImportReadDetector contract.

Tests

  • New execution fixture strict_execution_order/eager_read_nested_in_pure_expression: the reader's IIFE-wrapped nested global read is split into its own chunk and the output is executed under node. At the merge base (adcebe1) it fails with reader evaluated before patch: value = 5 — the unwrapped reader chunk is hoisted above the patch assignment — and with this change it passes in both wrapping modes.

  • The analyzer table tests keep one witness per collector branch (IIFE callee arms, member children, binding positions, assignment RHS, PureCJS RHS, manual-pure call/new/tagged-template) plus deferred-boundary, getter-contract, and default/wrap-all non-leak controls. The full 60-form syntax matrix stays in the external order-fuzzer release gate; spellings that exercise an already-witnessed branch are deliberately not duplicated in-repo.

  • The conditional/logical IIFE-callee arms decide the outcome only when a manual-pure member root certifies the enclosing read; bare conditional/logical-callee IIFEs are already order-sensitive through UnknownSideEffect because Oxc never certifies such callees. test_conditional_and_logical_iife_callees_behind_manual_pure_member pins both facts and fails if the arms are removed.

  • Follow-up from review: the walk now also covers the remaining eager-execution forms reachable behind a certified manual-pure member root — the construction-time positions of a directly newed class literal (constructor parameters/body, instance field and accessor initializers), directly invoked literal template tags, and assignment-expression callees including logical assignments. Each arm has a decider witness in test_eager_walk_covers_constructed_classes_literal_tags_and_assignment_callees, verified by per-arm ablation. The literal-tag entry is gated to strict on-demand, unlike the suggested ungated form, so the legacy class-mode collector never starts entering tag bodies in ordinary builds — same isolation rule as the other extended arms.

  • TODO (order-fuzzer side, tracked outside this repo): add the three forms above to the eager-evaluation release matrix; the current 60-form matrix predates them.

Regression bracket

The order-fuzzer release-gap audit maintains a 60-form eager-evaluation matrix: 49 catch cases across IIFE traversal, member children, binding patterns, assignment RHS, computed object keys, and manual-pure call/new/tagged-template forms, plus 11 boundary controls (rolldown-order-fuzzer@77f196b). For the same inputs, wrap-all and disabled code splitting pass throughout, and the preview build of this change passes the complete matrix. Three separate manualPureFunctions child-effect deletion failures are intentionally not addressed here because they are ordinary tree-shaking correctness bugs, not on-demand wrapping decisions.

Validation

  • cargo test --workspace --exclude rolldown_binding — full Rust suite green at the current head with no snapshot drift (the preview CI run skipped Cargo Test under the trigger: preview label)
  • cargo clippy -p rolldown --lib --tests -- -D warnings, cargo fmt -p rolldown
  • New fixture verified red at merge base adcebe1 and green at head, in both on-demand and wrap-all modes

@netlify

netlify Bot commented Jul 22, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs canceled.

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

@rolldown/browser

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/browser@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/browser@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/debug

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/debug@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/debug@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

rolldown

pnpm add https://pkg.pr.new/rolldown/rolldown@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown@10387 -D
yarn add https://pkg.pr.new/rolldown/[email protected] -D

@rolldown/binding-android-arm64

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-android-arm64@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-android-arm64@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-darwin-arm64

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-darwin-arm64@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-darwin-arm64@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-darwin-x64

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-darwin-x64@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-darwin-x64@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-freebsd-x64

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-freebsd-x64@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-freebsd-x64@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-linux-arm-gnueabihf

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-arm-gnueabihf@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-arm-gnueabihf@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-linux-arm64-gnu

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-arm64-gnu@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-arm64-gnu@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-linux-arm64-musl

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-arm64-musl@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-arm64-musl@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-linux-ppc64-gnu

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-ppc64-gnu@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-ppc64-gnu@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-linux-s390x-gnu

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-s390x-gnu@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-s390x-gnu@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-linux-x64-gnu

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-x64-gnu@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-x64-gnu@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-linux-x64-musl

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-x64-musl@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-x64-musl@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-openharmony-arm64

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-openharmony-arm64@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-openharmony-arm64@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-wasm32-wasi

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-wasm32-wasi@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-wasm32-wasi@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-win32-arm64-msvc

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-win32-arm64-msvc@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-win32-arm64-msvc@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

@rolldown/binding-win32-x64-msvc

pnpm add https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-win32-x64-msvc@10387 -D
npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-win32-x64-msvc@10387 -D
yarn add https://pkg.pr.new/rolldown/rolldown/@rolldown/[email protected] -D

commit: a2ac5f7

@hyfdev
hyfdev marked this pull request as ready for review July 23, 2026 05:34
Copilot AI review requested due to automatic review settings July 23, 2026 05:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codspeed-hq

codspeed-hq Bot commented Jul 23, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 7 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing fix/strict-order-eager-reasons (1c30ff0) with main (0d45611)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 (4930a00) during the generation of this report, so 0d45611 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@IWANABETHATGUY

Copy link
Copy Markdown
Member

The collector still misses three eager-execution forms. They're all reachable through the same route this PR already models — the manual-pure member-root computed key from test_conditional_and_logical_iife_callees_behind_manual_pure_member, where the regular analyzer certifies without descending and the collector becomes the only detector. In each case the collector prunes exactly the position that runs, so a split reader can stay unwrapped and observe pre-patch globals:

1. new class {...}() skips construction-time positions

visit_immediately_invoked_function has no ClassExpression arm, and visit_class_definition intentionally skips constructor bodies and non-static field/accessor initializers — correct for defining a class, but construction runs those immediately. The bare spelling is fine (oxc never certifies a class-literal new, so UnknownSideEffect already covers it), and /* @__PURE__ */ new class {}() self-flags via new_expr.pure. The hole is only the nested-behind-certification route, e.g.:

function make() {}
const v = make[new class { x = /* @__PURE__ */ globalThis.__orderRead?.() ?? 5 }().k];

I'd add an extended_top_level_reasons-gated arm to the helper covering the constructor body plus instance field/accessor values. Putting it in the helper rather than visit_new_expression means new (cond ? class A {} : class B {})() works for free through the existing recursion.

2. Directly invoked function-literal tags aren't entered

Evaluating ((s) => globalThis.read())`x` calls the tag immediately, but visit_tagged_template_expression only walks, and the walk prunes at the function boundary. Bare spellings are safe — I checked oxc 0.141 and there's no certification path for literal tags (TaggedTemplateExpression has no pure field; only manual-pure tags are certified, and those the collector already flags) — so again only the nested route is exposed. The fix is a one-liner mirroring the call/new visitors:

self.visit_immediately_invoked_function(&tagged.tag);

No gating needed here since the function/arrow arms are ungated legacy behavior.

3. Assignment-expression callees

(x = () => globalThis.read())() and logical assignments like (x ??= f)() — the latter is an AssignmentExpression node, so the LogicalExpression arm doesn't catch it. The helper never recurses into the RHS and the general walk defers the arrow body. Same reachability story as the other two.

None of these are blockers — they're in the same likelihood class as the conditional/logical arms this PR adds, so treating them as follow-ups would be consistent. But each fix is one small arm (or one line) plus one witness test in the style you already established, so folding them in here would be cheap. Either way they should land in the order-fuzzer matrix: the preview build passing the complete 60-form matrix tells me the matrix currently lacks all three forms as well.

A few smaller things:

  • The is_strict_execution_order_enabled() && is_on_demand_wrapping_enabled() gate is now spelled out in impl_visit.rs and again (minus the meta check) in the test helper. A small is_strict_on_demand_wrapping() helper on the options would remove the drift risk.
  • The updated PureAnnotation doc comment says the flag covers manualPureFunctions, but that's only true in the extended walk — the regular analyzer and class-mode collector deliberately don't set it for manual-pure calls. Worth a qualifying clause so nobody assumes default builds carry that reason.

Approving with the above as suggested follow-ups.

hyfdev commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Merge activity

  • Jul 23, 8:22 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.
  • Jul 23, 8:52 AM UTC: hyfdev added this pull request to the Graphite merge queue.
  • Jul 23, 9:08 AM UTC: Merged by the Graphite merge queue.

…ng (#10387)

## Summary

Strict on-demand wrapping relies on per-statement `StmtEvalAnalyzer` facts to mark modules execution-order-sensitive. Oxc can validly certify an outer IIFE, member operation, binding pattern, assignment, or manually pure invocation as side-effect-free and stop there, but that certification can hide a nested global read from the separate scheduling decision. The reader module then remains eager and can run before a patch module after code splitting.

This adds an independent eager-evaluation order-reason walk, run only for strict on-demand builds and only while the module and the current statement are not already known to be sensitive. It collects scheduling metadata without changing `tree_shaking_flags`. Ordinary builds, wrap-all builds, and cross-module tree-shaking re-analysis retain their existing paths, and the class-definition collector keeps its legacy behavior outside the new strict on-demand statement pass.

The walk covers immediately invoked function literals, member children, binding defaults and computed keys, assignment RHS, PureCJS RHS, and call/new/tagged-template expressions covered by `manualPureFunctions`. Deferred function and method bodies and unconstructed instance fields remain deferred, and a getter behind a `propertyReadSideEffects: false` read stays invisible, matching the documented `TopLevelImportReadDetector` contract.

## Tests

- New execution fixture `strict_execution_order/eager_read_nested_in_pure_expression`: the reader's IIFE-wrapped nested global read is split into its own chunk and the output is executed under node. At the merge base (`adcebe1`) it fails with `reader evaluated before patch: value = 5` — the unwrapped reader chunk is hoisted above the patch assignment — and with this change it passes in both wrapping modes.
- The analyzer table tests keep one witness per collector branch (IIFE callee arms, member children, binding positions, assignment RHS, PureCJS RHS, manual-pure call/new/tagged-template) plus deferred-boundary, getter-contract, and default/wrap-all non-leak controls. The full 60-form syntax matrix stays in the external order-fuzzer release gate; spellings that exercise an already-witnessed branch are deliberately not duplicated in-repo.
- The conditional/logical IIFE-callee arms decide the outcome only when a manual-pure member root certifies the enclosing read; bare conditional/logical-callee IIFEs are already order-sensitive through `UnknownSideEffect` because Oxc never certifies such callees. `test_conditional_and_logical_iife_callees_behind_manual_pure_member` pins both facts and fails if the arms are removed.

- Follow-up from review: the walk now also covers the remaining eager-execution forms reachable behind a certified manual-pure member root — the construction-time positions of a directly `new`ed class literal (constructor parameters/body, instance field and accessor initializers), directly invoked literal template tags, and assignment-expression callees including logical assignments. Each arm has a decider witness in `test_eager_walk_covers_constructed_classes_literal_tags_and_assignment_callees`, verified by per-arm ablation. The literal-tag entry is gated to strict on-demand, unlike the suggested ungated form, so the legacy class-mode collector never starts entering tag bodies in ordinary builds — same isolation rule as the other extended arms.
- TODO (order-fuzzer side, tracked outside this repo): add the three forms above to the eager-evaluation release matrix; the current 60-form matrix predates them.

## Regression bracket

The order-fuzzer release-gap audit maintains a 60-form eager-evaluation matrix: 49 catch cases across IIFE traversal, member children, binding patterns, assignment RHS, computed object keys, and manual-pure call/new/tagged-template forms, plus 11 boundary controls (rolldown-order-fuzzer@77f196b). For the same inputs, wrap-all and disabled code splitting pass throughout, and the preview build of this change passes the complete matrix. Three separate `manualPureFunctions` child-effect deletion failures are intentionally not addressed here because they are ordinary tree-shaking correctness bugs, not on-demand wrapping decisions.

## Validation

- `cargo test --workspace --exclude rolldown_binding` — full Rust suite green at the current head with no snapshot drift (the preview CI run skipped Cargo Test under the `trigger: preview` label)
- `cargo clippy -p rolldown --lib --tests -- -D warnings`, `cargo fmt -p rolldown`
- New fixture verified red at merge base `adcebe1` and green at head, in both on-demand and wrap-all modes
@graphite-app
graphite-app Bot force-pushed the fix/strict-order-eager-reasons branch from a2ac5f7 to 4de88b1 Compare July 23, 2026 08:53
@graphite-app
graphite-app Bot merged commit 4de88b1 into main Jul 23, 2026
35 checks passed
@graphite-app
graphite-app Bot deleted the fix/strict-order-eager-reasons branch July 23, 2026 09:08
IWANABETHATGUY added a commit that referenced this pull request Jul 24, 2026
…re chains (#10427)

Fixes #10397, at both drop paths named in the issue's Cause section.

One note for review. Two unit tests from #10387 encoded the old behavior
as their premise, their comments said so explicitly, and they now expect
conservative retention in both analysis dimensions.

---------

Co-authored-by: IWANABETHATGUY <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants