Skip to content

feat: drop no-op init calls for empty wrapped-ESM modules#9678

Merged
graphite-app[bot] merged 1 commit into
mainfrom
06-09-feat_remove_empty_esmmin_call
Jun 9, 2026
Merged

feat: drop no-op init calls for empty wrapped-ESM modules#9678
graphite-app[bot] merged 1 commit into
mainfrom
06-09-feat_remove_empty_esmmin_call

Conversation

@IWANABETHATGUY

@IWANABETHATGUY IWANABETHATGUY commented Jun 9, 2026

Copy link
Copy Markdown
Member

Summary

Follow-up to the review nit on #9669: when a wrapped ESM module tree-shakes down to an
empty body, rolldown still emitted a no-op call to its __esm/__esmMin wrapper, e.g.

function en() { return "en"; }
__esmMin((() => {}))();   // ← dead weight: empty closure, calling it does nothing

This PR marks such no-op init calls @__PURE__ so the default minify: 'dce-only'
pass drops them (and, once the wrapper is unreferenced, the wrapper declaration and the
__esm/__esmMin runtime helper too).

Before

var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
function en() { return "en"; }
__esmMin((() => {}))();

After

function en() { return "en"; }

It also collapses the require(esm) sequence form (init_x(), __toCommonJS(x_exports))
__toCommonJS(x_exports) via the same mechanism.

Approach

  1. A small generate-stage pre-pass, compute_init_is_noop, flags wrapped (WrapKind::Esm)
    modules whose __esm closure body is provably empty — every top-level statement is a
    hoisted function declaration or a source-less export clause, so nothing lands inside the
    closure. The classification runs in parallel (par_iter_enumerated).
  2. The finalizer sets pure = true on the init CallExpression at the three emission sites
    when the callee module is flagged; dce-only does the rest.

The classifier is deliberately conservative (a non-qualifying statement only ever keeps a
redundant init call — it never drops a needed one), and a debug_assert! in the finalizer
cross-checks every flagged module against the actual stmts_inside_closure, so any
misclassification fails loudly across the fixture suite. Guards exclude shimmed missing
exports (their x = void 0 lands in the closure) and concatenated wrapper groups.

Out of scope (kept on purpose)

The empty inlined dynamic import Promise.resolve().then(() => Object.freeze({ __proto__: null }))
is not removed — Rollup keeps it for import('./empty.js') of an empty module, so it's
Rollup-equivalent and stays.

Why the detection lives in its own pass (and not elsewhere)

The flag is consumed cross-module — an importer marks init_importee() pure based on the
importee's status — which constrains where it can be computed.

Not in the link stage (cross_module_optimization.rs)

That pass runs before the two inputs the decision needs even exist:

  • is_included is produced by tree-shaking (include_statements), which runs after
    cross_module_optimization. A module that's tree-shaken away has no init to reason about.
  • concatenated_wrapped_module_kind is assigned during the generate stage's
    chunk/concatenation analysis. We must restrict the optimization to standalone wrappers
    (ConcatenateWrappedModuleKind::None): in a concatenated group the shared init_* runs the
    whole group's closure, so one module's empty body wouldn't prove the call is a no-op. That
    isn't knowable before chunks are formed.

Folding it here would force the decision with incomplete information (pre-tree-shaking,
pre-chunking).

Not in the finalizer (module_finalizers/)

finalize_modules runs the per-module finalizers in parallel (par_iter_mut_enumerated):

  1. Immutable borrow — the finalizer holds &LinkingMetadata / &LinkingMetadataVec
    (ScopeHoistingFinalizerContext), read-only, so it cannot write the flag mid-pass.
  2. Determinism — even if it could, an importer finalizing concurrently with its importee
    could read a not-yet-computed (default false) value → non-deterministic @__PURE__
    placement → flaky snapshots.

So the flag must be settled as a barrier before finalization begins.

The fit

The pre-pass sits in the generate stage after tree-shaking and chunk generation (so
is_included and concatenated_wrapped_module_kind are known) but immediately before the
parallel finalize_modules (so every importer reads a settled, deterministic value).

Testing

IWANABETHATGUY commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

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.

@graphite-app graphite-app Bot changed the base branch from 06-09-test_simplify_9651_repro_fixture to graphite-base/9678 June 9, 2026 02:16
@graphite-app graphite-app Bot force-pushed the 06-09-feat_remove_empty_esmmin_call branch from 285d751 to 2a22ac7 Compare June 9, 2026 02:21
@graphite-app graphite-app Bot force-pushed the graphite-base/9678 branch from 8edcff3 to 018c529 Compare June 9, 2026 02:21
@graphite-app graphite-app Bot changed the base branch from graphite-base/9678 to main June 9, 2026 02:22
@graphite-app graphite-app Bot force-pushed the 06-09-feat_remove_empty_esmmin_call branch from 2a22ac7 to 2eee509 Compare June 9, 2026 02:22
@netlify

netlify Bot commented Jun 9, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs canceled.

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

@IWANABETHATGUY IWANABETHATGUY marked this pull request as ready for review June 9, 2026 04:07
@codspeed-hq

codspeed-hq Bot commented Jun 9, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing 06-09-feat_remove_empty_esmmin_call (42ca1ed) with main (45e3623)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 (2e03404) during the generation of this report, so 45e3623 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@IWANABETHATGUY IWANABETHATGUY force-pushed the 06-09-feat_remove_empty_esmmin_call branch from 2eee509 to 5626436 Compare June 9, 2026 04:55
@IWANABETHATGUY IWANABETHATGUY changed the title feat: remove empty esmMin call feat: drop no-op init calls for empty wrapped-ESM modules Jun 9, 2026
@IWANABETHATGUY IWANABETHATGUY force-pushed the 06-09-feat_remove_empty_esmmin_call branch from 5626436 to 42ca1ed Compare June 9, 2026 04:58

IWANABETHATGUY commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

Merge activity

  • Jun 9, 5:34 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.
  • Jun 9, 5:39 AM UTC: IWANABETHATGUY added this pull request to the Graphite merge queue.
  • Jun 9, 5:44 AM UTC: Merged by the Graphite merge queue.

## Summary

Follow-up to the review nit on #9669: when a wrapped ESM module tree-shakes down to an
empty body, rolldown still emitted a no-op call to its `__esm`/`__esmMin` wrapper, e.g.

```js
function en() { return "en"; }
__esmMin((() => {}))();   // ← dead weight: empty closure, calling it does nothing
```

This PR marks such no-op init calls `@__PURE__` so the default `minify: 'dce-only'`
pass drops them (and, once the wrapper is unreferenced, the wrapper declaration and the
`__esm`/`__esmMin` runtime helper too).

**Before**
```js
var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
function en() { return "en"; }
__esmMin((() => {}))();
```
**After**
```js
function en() { return "en"; }
```

It also collapses the `require(esm)` sequence form `(init_x(), __toCommonJS(x_exports))`
→ `__toCommonJS(x_exports)` via the same mechanism.

## Approach

1. A small generate-stage pre-pass, `compute_init_is_noop`, flags wrapped (`WrapKind::Esm`)
   modules whose `__esm` closure body is provably empty — every top-level statement is a
   hoisted function declaration or a source-less export clause, so nothing lands inside the
   closure. The classification runs in parallel (`par_iter_enumerated`).
2. The finalizer sets `pure = true` on the init `CallExpression` at the three emission sites
   when the callee module is flagged; `dce-only` does the rest.

The classifier is deliberately conservative (a non-qualifying statement only ever *keeps* a
redundant init call — it never drops a needed one), and a `debug_assert!` in the finalizer
cross-checks every flagged module against the *actual* `stmts_inside_closure`, so any
misclassification fails loudly across the fixture suite. Guards exclude shimmed missing
exports (their `x = void 0` lands in the closure) and concatenated wrapper groups.

## Out of scope (kept on purpose)

The empty inlined dynamic import `Promise.resolve().then(() => Object.freeze({ __proto__: null }))`
is **not** removed — Rollup keeps it for `import('./empty.js')` of an empty module, so it's
Rollup-equivalent and stays.

## Why the detection lives in its own pass (and not elsewhere)

The flag is consumed **cross-module** — an importer marks `init_importee()` pure based on the
*importee's* status — which constrains where it can be computed.

### Not in the link stage (`cross_module_optimization.rs`)

That pass runs **before** the two inputs the decision needs even exist:

- **`is_included`** is produced by tree-shaking (`include_statements`), which runs *after*
  `cross_module_optimization`. A module that's tree-shaken away has no init to reason about.
- **`concatenated_wrapped_module_kind`** is assigned during the generate stage's
  chunk/concatenation analysis. We must restrict the optimization to standalone wrappers
  (`ConcatenateWrappedModuleKind::None`): in a concatenated group the shared `init_*` runs the
  *whole group's* closure, so one module's empty body wouldn't prove the call is a no-op. That
  isn't knowable before chunks are formed.

Folding it here would force the decision with incomplete information (pre-tree-shaking,
pre-chunking).

### Not in the finalizer (`module_finalizers/`)

`finalize_modules` runs the per-module finalizers in parallel (`par_iter_mut_enumerated`):

1. **Immutable borrow** — the finalizer holds `&LinkingMetadata` / `&LinkingMetadataVec`
   (`ScopeHoistingFinalizerContext`), read-only, so it *cannot write* the flag mid-pass.
2. **Determinism** — even if it could, an importer finalizing concurrently with its importee
   could read a not-yet-computed (default `false`) value → non-deterministic `@__PURE__`
   placement → flaky snapshots.

So the flag must be settled as a barrier *before* finalization begins.

### The fit

The pre-pass sits in the generate stage **after** tree-shaking and chunk generation (so
`is_included` and `concatenated_wrapped_module_kind` are known) but **immediately before** the
parallel `finalize_modules` (so every importer reads a settled, deterministic value).

## Testing

- Updated 5 fixture snapshots (incl. the #9651 regression); each remains runnable.
- Full `rolldown` + `esbuild` integration suites pass; the `init_is_noop` debug-assert holds
  across all ~1.6k fixtures.
- `just lint-rust` clean (clippy `--deny warnings`, fmt, `cargo check --all-features`).
@graphite-app graphite-app Bot force-pushed the 06-09-feat_remove_empty_esmmin_call branch from 5606e6b to 9ba6aeb Compare June 9, 2026 05:39
@graphite-app graphite-app Bot merged commit 9ba6aeb into main Jun 9, 2026
34 checks passed
@graphite-app graphite-app Bot deleted the 06-09-feat_remove_empty_esmmin_call branch June 9, 2026 05:44
@shulaoda shulaoda mentioned this pull request Jun 11, 2026
@rolldown-guard rolldown-guard Bot mentioned this pull request Jun 11, 2026
shulaoda pushed a commit that referenced this pull request Jun 11, 2026
## [1.1.1] - 2026-06-11

### 🚀 Features

- ecmascript_utils: introduce AstFactory for AST construction (#9682) by @hyf0
- drop no-op init calls for empty wrapped-ESM modules (#9678) by @IWANABETHATGUY

### 🐛 Bug Fixes

- resolver: honor package.json#type for .jsx/.tsx extensions (#9690) (#9699) by @IWANABETHATGUY
- hmr: report the full-reload reason for the invalidate-loop case (#9708) by @hyf0
- explicit `moduleSideEffects` from a hook must take priority over the `package.json#sideEffects` (#9688) by @sapphi-red
- keep the `rolldown-runtime` name for the standalone runtime chunk (#9685) by @shulaoda
- lazy-barrel: request all exports for entry barrels on first encounter (#9672) by @shulaoda
- finalizer: skip init_*() for tree-shaken wrapped ESM owners (#9669) by @IWANABETHATGUY
- order `chunk.imports` by execution order (#9654) by @chuganzy
- vite-resolve: preserve slash separators for Sass partial exports (#9546) by @cjc0013
- cross-chunk CJS wrapper shadowed by author-local binding (#9648) by @IWANABETHATGUY

### 🚜 Refactor

- precompute wrapped-ESM init metadata in generate stage (#9712) by @IWANABETHATGUY
- ecmascript_utils: fold construction ext traits onto AstFactory and delete AstSnippet (#9702) by @hyf0
- finalizer: finish ScopeHoistingFinalizer migration to AstFactory (#9701) by @hyf0
- finalizer: migrate module_finalizers/mod.rs to AstFactory (#9700) by @hyf0
- hmr: migrate hmr finalizer to AstFactory (#9695) by @hyf0
- plugin: migrate vite_build_import_analysis to AstFactory (#9693) by @hyf0
- scanner: migrate tweak_ast_for_scanning to AstFactory (#9683) by @hyf0
- always split runtime module first (#9419) by @IWANABETHATGUY

### 📚 Documentation

- tsconfig: correct auto-discovery resolution to match TypeScript (#9714) by @shulaoda
- design: plan to unify all internal AST construction (#9673) by @hyf0
- tsconfig: align reference resolution docs with TypeScript behavior (#9641) by @shulaoda

### ⚡ Performance

- avoid per-module join Strings in scope-hoisting concatenation (#9645) by @Boshen
- avoid intermediate Strings in the ESM export clause (#9644) by @Boshen
- reuse a scratch buffer for facade namespace names in the scanner (#9642) by @Boshen
- reuse the import-matching tracker stack across named imports (#9643) by @Boshen
- avoid cloning the per-chunk export-items map in render_chunk_exports (#9639) by @Boshen
- avoid CompactStr allocation in sorted-exports membership check (#9640) by @Boshen

### 🧪 Testing

- add more `moduleSideEffects` precedence tests (#9689) by @sapphi-red
- 9651: drop shimMissingExports from regression fixture (#9674) by @IWANABETHATGUY

### ⚙️ Miscellaneous Tasks

- update react repo links (#9711) by @iiio2
- remove outdated pnpm configurations (#9666) by @btea
- deps: update github actions to v2.81.5 (#9665) by @renovate[bot]
- testing: migrate test harness off deprecated inlineDynamicImports (#9710) by @IWANABETHATGUY
- hmr: delete commented-out dead code left from old register-module codegen (#9707) by @hyf0
- deps: update napi-rs toolchain (#9706) by @shulaoda
- deps: update rollup submodule for tests to v4.61.1 (#9676) by @rolldown-guard[bot]
- deps: update test262 submodule for tests (#9677) by @rolldown-guard[bot]
- deps: update oxc to 0.135.0 (#9670) by @Boshen
- pass ALGOLIA_APP_ID and ALGOLIA_API_KEY to void deploy (#9667) by @Boshen
- deps: update github actions (#9662) by @renovate[bot]
- deps: update rust crates (#9663) by @renovate[bot]
- deps: update rolldown-plugin-dts to v0.25.2 (#9661) by @renovate[bot]
- deps: update typos to v1.47.2 (#9660) by @renovate[bot]
- deps: update docs dependencies (#9657) by @bddjr
- deps: update typos to v1.47.1 (#9655) by @renovate[bot]
- deploy website in its own workflow, only on docs output change (#9650) by @Boshen
- publish to pkg.pr.new add pm and `commentWithDev` option (#9638) by @btea

### ❤️ New Contributors

* @chuganzy made their first contribution in [#9654](#9654)
* @cjc0013 made their first contribution in [#9546](#9546)
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.

3 participants