Skip to content

fix(rolldown): drop the unused runtime module after entry-level external flattening#10237

Merged
graphite-app[bot] merged 1 commit into
mainfrom
fix/9374-dead-runtime-chunk
Jul 12, 2026
Merged

fix(rolldown): drop the unused runtime module after entry-level external flattening#10237
graphite-app[bot] merged 1 commit into
mainfrom
fix/9374-dead-runtime-chunk

Conversation

@IWANABETHATGUY

@IWANABETHATGUY IWANABETHATGUY commented Jul 12, 2026

Copy link
Copy Markdown
Member

What is this PR solving?

Top of the #9374 stack (#10238#10239 ← this).

For a star re-export chain that ends at an external module:

// entry_a.js
export * from './entry_b.js';
export const a = 'a';

// entry_b.js
export * from 'external';
export const b = 'b';

both entry chunks emit a literal export * from "external" and never call a runtime helper — yet the build still ships a dead rolldown-runtime-*.js chunk that exports __reExport/__exportAll to nobody, with a bare import of it in every entry.

Root cause: tree-shaking includes the runtime helpers at link time, when entry_b still counts as having dynamic exports. The generate stage later proves the whole chain can be represented statically (find_entry_level_external_module marks the records EntryLevelExternal and re-propagates has_dynamic_exports = false — including to transitive star importers since #10239; finalized_module_namespace_ref_usage drops the namespace objects that only served the chain), so the module finalizer emits no helper call — but by then the runtime module is already included and already placed in a chunk, and nothing un-includes it.

The fix

Add sweep_unused_runtime_module (new pass at the tail of generate_chunks(), after the walk-back passes and before chunk exec-order assignment). It re-derives runtime helper demand from the same post-walk-back facts the finalizer renders from, through four channels: per-module depended_runtime_helper flags (with ReExport discounted unless some included star importee still has has_dynamic_exports — CommonJS importees always do, so wrapped-CJS star re-exports are unaffected), the namespace-object channel gated on namespace_included (calling the LinkingMetadata::ns_star_external_re_export_emitted rule extracted in #10238, so the prediction cannot drift from the emission), runtime-owned symbols referenced by included statements, and entry-chunk synthesized references.

If zero demand remains, the runtime module is un-included, its symbols are purged from used_symbol_refs (which severs every cross-chunk edge — all downstream consumers are liveness-filtered), and its now-empty chunk is tombstoned via the same PostChunkOptimizationOperation::Removed lifecycle the chunk optimizer uses. Any surviving demand, or any bail-out (tree-shaking disabled, side-effectful dev/HMR runtime), leaves everything exactly as tree-shaking decided.

What other alternatives have you explored?

Teaching the link stage to skip the eager helper inclusion for the statically decidable cases was rejected: the flattening decision depends on chunk shapes (dynamic entries, SimulateFacadeChunk), so a link-time copy of the predicate would have to stay in sync with the generate stage forever, and drift in the optimistic direction produces broken output (a rendered helper that was never included) rather than dead bytes. Recomputing demand after the walk-back, where all facts are final, fails conservative instead.

Effects on existing tests

Six snapshots change, all in the fixed direction: 6992, 7115, 7233, 7233_chain lose their dead runtime files and the bare imports/requires of them (the same bug shape under preserveModules, in both ESM and CJS output); 4472/5923 lose the last phantom //#region markers (the bulk of the marker cleanup happened in #10239).

Tests

New fixture crates/rolldown/tests/rolldown/issues/9374/ — fails on this PR's base (#10239's head still ships the runtime chunk; verified) and passes here. It uses minify: false so vestigial namespace declarations would also surface in the snapshot. Verified the legitimate cases still keep the runtime: import * as ns observation over the same chain (helpers are genuinely used to merge the external's exports into the namespace), and star re-exports of CJS modules (__commonJSMin wrapper kept).

internal-docs/code-splitting/implementation.md is updated with the new pipeline ordering and the sweep's liveness invariants.

fixes #9374

@netlify

netlify Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs canceled.

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

@IWANABETHATGUY
IWANABETHATGUY force-pushed the fix/9374-dead-runtime-chunk branch from 688e45a to 56e28c5 Compare July 12, 2026 14:57
@IWANABETHATGUY
IWANABETHATGUY changed the base branch from main to graphite-base/10237 July 12, 2026 15:14
@IWANABETHATGUY
IWANABETHATGUY force-pushed the fix/9374-dead-runtime-chunk branch from 56e28c5 to 254ac7a Compare July 12, 2026 15:14
@IWANABETHATGUY
IWANABETHATGUY changed the base branch from graphite-base/10237 to fix/entry-level-external-importer-walkback July 12, 2026 15:14

IWANABETHATGUY commented Jul 12, 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.

@codspeed-hq

codspeed-hq Bot commented Jul 12, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 7 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing fix/9374-dead-runtime-chunk (6800254) with main (dcf6eef)

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.

graphite-app Bot pushed a commit that referenced this pull request Jul 12, 2026
…rule into LinkingMetadata (#10238)

### What is this PR solving?

Refactor only, no behavior change. Groundwork for the #9374 stack (#10239, #10237).

The module finalizer decided inline whether an `export * from '<external>'` record emits a `__reExport(ns, <external>)` call when the namespace object renders: in ESM output the call is skipped for `EntryLevelExternal` records (the re-export is representable as a chunk-level `export * from` statement) unless the namespace object is genuinely observed (`ModuleNamespaceIncludedReason::Unknown`); in CJS-family formats it is always emitted.

This PR extracts that condition into `LinkingMetadata::ns_star_external_re_export_emitted` so that any pass needing to *predict* the emission (the unused-runtime sweep added upstack in #10237) calls the same rule as the emitter instead of re-deriving it — the two cannot drift apart.

Verified pure: the full integration suite passes with zero snapshot drift on this commit.
graphite-app Bot pushed a commit that referenced this pull request Jul 12, 2026
…rule into LinkingMetadata (#10238)

### What is this PR solving?

Refactor only, no behavior change. Groundwork for the #9374 stack (#10239, #10237).

The module finalizer decided inline whether an `export * from '<external>'` record emits a `__reExport(ns, <external>)` call when the namespace object renders: in ESM output the call is skipped for `EntryLevelExternal` records (the re-export is representable as a chunk-level `export * from` statement) unless the namespace object is genuinely observed (`ModuleNamespaceIncludedReason::Unknown`); in CJS-family formats it is always emitted.

This PR extracts that condition into `LinkingMetadata::ns_star_external_re_export_emitted` so that any pass needing to *predict* the emission (the unused-runtime sweep added upstack in #10237) calls the same rule as the emitter instead of re-deriving it — the two cannot drift apart.

Verified pure: the full integration suite passes with zero snapshot drift on this commit.
@graphite-app
graphite-app Bot changed the base branch from fix/entry-level-external-importer-walkback to graphite-base/10237 July 12, 2026 15:36
graphite-app Bot pushed a commit that referenced this pull request Jul 12, 2026
…porters (#10239)

### What is this PR solving?

Part of the #9374 stack (#10238 ← this ← #10237).

`find_entry_level_external_module` flattens star re-export chains that end at an external module into chunk-level re-exports, marks the records `EntryLevelExternal`, and re-propagates `has_dynamic_exports = false` for the affected modules. A BFS is supposed to extend that invalidation to every *transitive* star importer — a module whose own star chain passes through a seed derived its flag from the seed's pre-flattening value.

That BFS was dead on arrival: it tested set membership on pop —

```rust
while let Some(idx) = q.pop_front() {
  if !invalidated_modules.insert(idx) { continue; }
  ...
  q.extend(module.importers_idx.iter());
}
```

— but the seeds are pre-inserted into `invalidated_modules`, so every seed skipped itself and no importer was ever enqueued. Only the modules with a *direct* external star re-export were ever walked back.

Consequences for a chain like `entry_a → export * → entry_b → export * → 'external'`:

- `entry_a` kept a stale `has_dynamic_exports = true`, so `finalized_module_namespace_ref_usage` retained its namespace object. The finalizer rendered `var entry_a_exports = /* @__PURE__ */ __exportAll({...})` for nothing; the default dce-only minifier usually deleted it again, but the unbalanced `//#region` markers it left behind are visible in several existing snapshots.
- In chains of **three or more** modules, the middle module's stale flag made the finalizer emit a genuinely dead `__reExport(a_exports, b_exports)` call — a call expression, so not pure-droppable — which shipped to users.

The fix enqueues importers on first discovery instead of testing membership on pop.

### Effects on existing tests

Five snapshots change, all region-marker corrections from the namespace declarations that no longer render-then-vanish (`5923`, `6992`, `7115`, `7233`, `7233_chain`). The dead `rolldown-runtime` files still visible in those snapshots are removed by #10237 upstack — inclusion is already frozen when this pass runs, so fixing the flag alone cannot un-include the runtime module.
@graphite-app
graphite-app Bot force-pushed the fix/9374-dead-runtime-chunk branch from 254ac7a to 516592f Compare July 12, 2026 15:40
@graphite-app
graphite-app Bot force-pushed the graphite-base/10237 branch from 3466c21 to dcf6eef Compare July 12, 2026 15:40
@graphite-app
graphite-app Bot changed the base branch from graphite-base/10237 to main July 12, 2026 15:41
@graphite-app
graphite-app Bot force-pushed the fix/9374-dead-runtime-chunk branch from 516592f to 6800254 Compare July 12, 2026 15:41

@hyfdev hyfdev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. I performed an additional adversarial review of the runtime sweep across helper demand, namespace handling, wrappers, side effects, and cross-chunk references, and found no correctness issues. The relevant regression fixtures also pass.

IWANABETHATGUY commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

Merge activity

  • Jul 12, 4:23 PM 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 12, 4:23 PM UTC: IWANABETHATGUY added this pull request to the Graphite merge queue.
  • Jul 12, 4:29 PM UTC: Merged by the Graphite merge queue.

…nal flattening (#10237)

### What is this PR solving?

Top of the #9374 stack (#10238#10239 ← this).

For a star re-export chain that ends at an external module:

```js
// entry_a.js
export * from './entry_b.js';
export const a = 'a';

// entry_b.js
export * from 'external';
export const b = 'b';
```

both entry chunks emit a literal `export * from "external"` and never call a runtime helper — yet the build still ships a dead `rolldown-runtime-*.js` chunk that exports `__reExport`/`__exportAll` to nobody, with a bare import of it in every entry.

Root cause: tree-shaking includes the runtime helpers at link time, when `entry_b` still counts as having dynamic exports. The generate stage later proves the whole chain can be represented statically (`find_entry_level_external_module` marks the records `EntryLevelExternal` and re-propagates `has_dynamic_exports = false` — including to transitive star importers since #10239; `finalized_module_namespace_ref_usage` drops the namespace objects that only served the chain), so the module finalizer emits no helper call — but by then the runtime module is already included and already placed in a chunk, and nothing un-includes it.

### The fix

Add `sweep_unused_runtime_module` (new pass at the tail of `generate_chunks()`, after the walk-back passes and before chunk exec-order assignment). It re-derives runtime helper demand from the same post-walk-back facts the finalizer renders from, through four channels: per-module `depended_runtime_helper` flags (with `ReExport` discounted unless some included star importee still has `has_dynamic_exports` — CommonJS importees always do, so wrapped-CJS star re-exports are unaffected), the namespace-object channel gated on `namespace_included` (calling the `LinkingMetadata::ns_star_external_re_export_emitted` rule extracted in #10238, so the prediction cannot drift from the emission), runtime-owned symbols referenced by included statements, and entry-chunk synthesized references.

If zero demand remains, the runtime module is un-included, its symbols are purged from `used_symbol_refs` (which severs every cross-chunk edge — all downstream consumers are liveness-filtered), and its now-empty chunk is tombstoned via the same `PostChunkOptimizationOperation::Removed` lifecycle the chunk optimizer uses. Any surviving demand, or any bail-out (tree-shaking disabled, side-effectful dev/HMR runtime), leaves everything exactly as tree-shaking decided.

### What other alternatives have you explored?

Teaching the link stage to skip the eager helper inclusion for the statically decidable cases was rejected: the flattening decision depends on chunk shapes (dynamic entries, `SimulateFacadeChunk`), so a link-time copy of the predicate would have to stay in sync with the generate stage forever, and drift in the optimistic direction produces broken output (a rendered helper that was never included) rather than dead bytes. Recomputing demand after the walk-back, where all facts are final, fails conservative instead.

### Effects on existing tests

Six snapshots change, all in the fixed direction: `6992`, `7115`, `7233`, `7233_chain` lose their dead runtime files and the bare imports/requires of them (the same bug shape under `preserveModules`, in both ESM and CJS output); `4472`/`5923` lose the last phantom `//#region` markers (the bulk of the marker cleanup happened in #10239).

### Tests

New fixture `crates/rolldown/tests/rolldown/issues/9374/` — fails on this PR's base (#10239's head still ships the runtime chunk; verified) and passes here. It uses `minify: false` so vestigial namespace declarations would also surface in the snapshot. Verified the legitimate cases still keep the runtime: `import * as ns` observation over the same chain (helpers are genuinely used to merge the external's exports into the namespace), and star re-exports of CJS modules (`__commonJSMin` wrapper kept).

`internal-docs/code-splitting/implementation.md` is updated with the new pipeline ordering and the sweep's liveness invariants.

fixes #9374
@graphite-app
graphite-app Bot force-pushed the fix/9374-dead-runtime-chunk branch from 6800254 to e108c30 Compare July 12, 2026 16:24
@graphite-app
graphite-app Bot merged commit e108c30 into main Jul 12, 2026
33 of 34 checks passed
@graphite-app
graphite-app Bot deleted the fix/9374-dead-runtime-chunk branch July 12, 2026 16:29
@rolldown-guard rolldown-guard Bot mentioned this pull request Jul 15, 2026
shulaoda added a commit that referenced this pull request Jul 15, 2026
## [1.2.0] - 2026-07-15

### 🚀 Features

- dev: skip shipping factories for newly imported top-level modules (#10223) by @h-a-n-a
- dev: per-client ship map for HMR patch sizing (#10208) by @h-a-n-a
- dev: client-side HMR (#10164) by @h-a-n-a
- dev: send a full-reload update to clients when a tsconfig changes (#10262) by @shulaoda
- treat `import.meta['url']` and `import.meta['ROLLUP_FILE_URL_*']` as side-effect free (#10267) by @sapphi-red
- rewrite `import.meta['url']` (#10251) by @sapphi-red
- add `FILE_NOT_FOUND` error (#10220) by @sapphi-red
- treat `import.meta.ROLLUP_FILE_URL_*` as side-effect free (#10217) by @sapphi-red

### 🐛 Bug Fixes

- sourcemap: preserve unmapped boundaries during composition (#10254) by @hyfdev
- `[format]` in `*FileNames` option for ESM format should be `es` instead of `esm` (#10214) by @sapphi-red
- sourcemap: preserve coarse mappings during composition (#10249) by @hyfdev
- rolldown_plugin_vite_import_glob: support tsconfig paths with `import.meta.glob` (#10167) by @sapphi-red
- dev: clear tsconfig caches for bare full builds (#10276) by @shulaoda
- dev: force a full rebuild when a tsconfig changes (#10261) by @shulaoda
- treat rooted drive-less module ids as absolute in preserveModules naming (#10235) by @IWANABETHATGUY
- watch: rebuild when tsconfig files change (#10258) by @shulaoda
- watch: drop tsconfig-merged transform options on each rebuild (#10257) by @shulaoda
- incorrect `EMPTY_IMPORT_META` warning for `import.meta.ROLLUP_FILE_URL_*` for CJS output (#10221) by @sapphi-red
- deconflict: rename CJS locals shadowing wrapped-ESM namespace objects (#9970) by @IWANABETHATGUY
- rolldown: drop the unused runtime module after entry-level external flattening (#10237) by @IWANABETHATGUY
- rolldown: re-propagate has_dynamic_exports to transitive star importers (#10239) by @IWANABETHATGUY
- tree-shaking: tree-shake destructured dynamic import namespace bindings (#10213) by @logaretm
- s390x: use json-escape-simd 3.1.1 for big-endian JSON escaping fix (#10211) by @satyamg1620

### 🚜 Refactor

- dev: move full-reload to client side (#10207) by @h-a-n-a
- readability follow-ups to the ReplaceWith migration (#10286) by @IWANABETHATGUY
- replace take_in-then-write-back with ReplaceWith and by-value moves (#10285) by @Boshen
- share the main resolver's cache with the transformer's tsconfig lookups (#10205) by @shulaoda
- rolldown: extract the ns star-external __reExport emission rule into LinkingMetadata (#10238) by @IWANABETHATGUY
- rolldown: unify link/generate diagnostics into a Diagnostics accumulator (#10234) by @IWANABETHATGUY
- sourcemap_filenames: drop dead sourcemap-filename plumbing (#10189) by @IWANABETHATGUY
- extract external import symbol merging into a method (#10224) by @IWANABETHATGUY
- rolldown: skip CJS namespace merging under strict execution order (#10203) by @hyfdev
- resolve the manual tsconfig per file instead of once at startup (#10200) by @shulaoda
- rolldown: route interop ESM init emission through a shared init-target view (#10202) by @hyfdev
- rolldown: collapse vestigial wrap-kind state and share chunk sort helper (#10201) by @hyfdev

### 📚 Documentation

- show plugin kinds in JSDoc and each hook's description (#10218) by @sapphi-red
- add an explanation about removing imports from external modules without any messages (#10215) by @sapphi-red

### ⚡ Performance

- sourcemap: owned merge in SourceJoiner::join (4005->5 allocs/chunk) (#10250) by @Boshen
- avoid redundant sourcemap string copies in collapse and minify paths (#10093) by @Boshen

### 🧪 Testing

- code-splitting: establish strict-order review baselines (#10287) by @hyfdev
- dev: add hot API test cases (#10181) by @h-a-n-a
- code-splitting: normalize strict execution order variants (#10277) by @hyfdev
- code-splitting: harden strict execution order coverage (#10252) by @hyfdev
- code-splitting: add strict execution order regressions (#10253) by @hyfdev

### ⚙️ Miscellaneous Tasks

- deps: update github actions (#10241) by @renovate[bot]
- deps: update oxc to 0.140.0 (#10274) by @shulaoda
- update Yunfei's GitHub username (#10275) by @hyfdev
- deps: update napi (#10260) by @renovate[bot]
- deps: update test262 submodule for tests (#10266) by @rolldown-guard[bot]
- deps: update dependency vite-plus to v0.2.4 (#10256) by @renovate[bot]
- deps: update napi (#10240) by @renovate[bot]
- deps: update oxc resolver to v11.24.2 (#10245) by @renovate[bot]
- deps: update rust crates (#10244) by @renovate[bot]
- disable Renovate updates for idna_adapter (#10248) by @shulaoda
- deps: update oxc resolver to v11.24.1 (#10232) by @renovate[bot]
- deps: update rust crate oxc_sourcemap to v8.1.1 (#10233) by @renovate[bot]
- deps: update dependency rolldown-plugin-dts to ^0.27.0 (#10206) by @renovate[bot]
- deps: upgrade sugar_path to v3 (#10230) by @hyfdev
- add `dist-*` to `.gitignore` in sourcemap-filenames/hash-final-content fixture (#10216) by @sapphi-red
- deps: update dependency rust to v1.97.0 (#10209) by @renovate[bot]

### ❤️ New Contributors

* @satyamg1620 made their first contribution in [#10211](#10211)

Co-authored-by: shulaoda <[email protected]>
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.

[Bug]: rolldown/runtime is false-positively injected

2 participants