Skip to content

refactor: always split runtime module first#9419

Merged
graphite-app[bot] merged 1 commit into
mainfrom
05-16-refactor_always_split_runtime_module_first
Jun 4, 2026
Merged

refactor: always split runtime module first#9419
graphite-app[bot] merged 1 commit into
mainfrom
05-16-refactor_always_split_runtime_module_first

Conversation

@IWANABETHATGUY

@IWANABETHATGUY IWANABETHATGUY commented May 16, 2026

Copy link
Copy Markdown
Member

Summary

Reworks runtime-module chunk placement (see meta/design/code-splitting.md
"Runtime Module Placement"): the runtime module is always extracted into its own
chunk first, then merged back into a proven-safe host chunk (the sole runtime
consumer / a chunk with the same reachability bitset / a consumer-set dominator),
guarded by TLA- and static-cycle checks. The initial layout is cycle-safe by
construction instead of place-by-bitset-then-peel-on-cycle.

A follow-up commit fixes a regression this surfaced: single-file output.file
(es/cjs) builds that pull in a runtime helper (e.g. a reified namespace) were
wrongly rejected with [INVALID_OPTION] InvalidOutputFile. The multi-chunk guard
in generate() counted the merged-away runtime chunk's tombstone (still in
chunk_table, but skipped at render time). It now counts only live chunks,
mirroring the render-time filter.

Why some Rollup alignment tests are skipped

Three chunking-form samples are added to
packages/rollup-tests/src/failed-tests.json. These are not workarounds for a
bug
— Rolldown's output is behaviorally correct; it just emits one fewer chunk
than Rollup.

The chunking-form suite only asserts that Rolldown emits the same number of
chunks
as Rollup (assert.strictEqual(actualChunkCount, expectedChunkCount));
it does not compare chunk contents. With the runtime now folded into a
same-bitset host chunk, these multi-entry topologies no longer need a standalone
runtime chunk, so Rolldown emits one fewer chunk than Rollup: Rollup hoists shared
content into a generated-*.js chunk, while Rolldown folds that content (plus the
runtime) into an existing entry chunk.

Each was verified by executing both Rolldown's _actual/ and Rollup's
_expected/ output and comparing observable behavior — entry exports, console
output, and computed values all match. The only differences are the chunk count
and cosmetic representation (Rolldown's namespace objects carry the
spec-compliant Symbol.toStringTag: "Module", which Rollup's frozen-object
namespaces omit).

Affected samples (both generates es and generates cjs):

  • entry-point-without-own-code — Handles entry points that contain no own code except imports and exports
  • namespace-object-import — namespace object import
  • namespace-reexports — namespace rendering with reexports

To re-check one, run the sample and diff its _actual/ against _expected/, e.g.
pnpm --filter rollup-tests test --grep "namespace-object-import: namespace object import".

🤖 Generated with Claude Code

closed #9597

IWANABETHATGUY commented May 16, 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.

@pkg-pr-new

pkg-pr-new Bot commented May 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@rolldown/browser

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/browser@9419

@rolldown/debug

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/debug@9419

rolldown

npm i https://pkg.pr.new/rolldown/rolldown@9419

@rolldown/binding-android-arm64

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-android-arm64@9419

@rolldown/binding-darwin-arm64

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-darwin-arm64@9419

@rolldown/binding-darwin-x64

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-darwin-x64@9419

@rolldown/binding-freebsd-x64

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-freebsd-x64@9419

@rolldown/binding-linux-arm-gnueabihf

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-arm-gnueabihf@9419

@rolldown/binding-linux-arm64-gnu

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-arm64-gnu@9419

@rolldown/binding-linux-arm64-musl

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-arm64-musl@9419

@rolldown/binding-linux-ppc64-gnu

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-ppc64-gnu@9419

@rolldown/binding-linux-s390x-gnu

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-s390x-gnu@9419

@rolldown/binding-linux-x64-gnu

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-x64-gnu@9419

@rolldown/binding-linux-x64-musl

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-linux-x64-musl@9419

@rolldown/binding-openharmony-arm64

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-openharmony-arm64@9419

@rolldown/binding-wasm32-wasi

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-wasm32-wasi@9419

@rolldown/binding-win32-arm64-msvc

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-win32-arm64-msvc@9419

@rolldown/binding-win32-x64-msvc

npm i https://pkg.pr.new/rolldown/rolldown/@rolldown/binding-win32-x64-msvc@9419

commit: 6b923db

@IWANABETHATGUY IWANABETHATGUY force-pushed the 05-16-refactor_always_split_runtime_module_first branch from 943891d to 554178f Compare June 3, 2026 14:13
@IWANABETHATGUY IWANABETHATGUY changed the base branch from graphite-base/9419 to main June 3, 2026 14:13
@netlify

netlify Bot commented Jun 3, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs ready!

Name Link
🔨 Latest commit 35054a1
🔍 Latest deploy log https://app.netlify.com/projects/rolldown-rs/deploys/6a2135dc1aeb160008b1b6f1
😎 Deploy Preview https://deploy-preview-9419--rolldown-rs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@IWANABETHATGUY IWANABETHATGUY marked this pull request as ready for review June 3, 2026 15:58
@codspeed-hq

codspeed-hq Bot commented Jun 3, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing 05-16-refactor_always_split_runtime_module_first (24361d4) with main (696cad1)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 (8739a75) during the generation of this report, so 696cad1 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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.

Pull request overview

Refactors Rolldown’s code-splitting pipeline so the runtime module is always extracted into a dedicated “standalone runtime” chunk first, then optionally merged back into a proven-safe host chunk (with cycle/TLA safety checks). This addresses static-cycle regressions like #9597 and aligns option validation with render-time behavior when chunks are tombstoned after optimization.

Changes:

  • Implement standalone-first runtime chunk extraction and a new try_merge_runtime_chunk() path to merge the runtime into a safe host (or keep it standalone).
  • Fix single-file (output.file) validation to count only live (rendered) chunks, ignoring tombstoned/removed chunks.
  • Update design docs and test fixtures/snapshots; add a regression test for #9597 and skip a few Rollup-alignment tests that assert only chunk-count parity.

Reviewed changes

Copilot reviewed 39 out of 40 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/rollup-tests/src/status.md Updates aggregated Rollup test status counts after new skips.
packages/rollup-tests/src/status.json Updates machine-readable Rollup test status counts after new skips.
packages/rollup-tests/src/failed-tests.json Adds three chunking-form samples to the skip list (chunk-count mismatch vs Rollup).
meta/design/code-splitting.md Updates runtime placement design notes to reflect standalone-first runtime chunking and safe merge strategy.
crates/rolldown/tests/rolldown/topics/preserve_semantic_of_entries_exports/named_export_in_wrapped_and_shared_entries/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/topics/exports/entry_esm_named_named/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/topics/exports/entry_esm_named_default/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/topics/exports/entry_cjs_named_named/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/topics/exports/common_esm_named_named/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/topics/exports/common_cjs_named_default/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/optimization/chunk_merging/dynamic_entry_merged_in_common_chunk2/artifacts.snap Snapshot updates reflecting runtime merge into an existing host chunk.
crates/rolldown/tests/rolldown/optimization/chunk_merging/dynamic_entry_merged_in_common_chunk/artifacts.snap Snapshot updates reflecting runtime merge into an existing host chunk.
crates/rolldown/tests/rolldown/issues/9597/node3.js Adds reproduction fixture module for #9597 regression test.
crates/rolldown/tests/rolldown/issues/9597/node2.cjs Adds reproduction fixture module for #9597 regression test.
crates/rolldown/tests/rolldown/issues/9597/node1.js Adds reproduction fixture module for #9597 regression test.
crates/rolldown/tests/rolldown/issues/9597/node0.cjs Adds reproduction fixture module for #9597 regression test.
crates/rolldown/tests/rolldown/issues/9597/_test.mjs Adds regression assertion for absence of static import cycles and runtime smoke test.
crates/rolldown/tests/rolldown/issues/9597/_config.json Adds test harness config for #9597 fixture.
crates/rolldown/tests/rolldown/issues/8595/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/issues/3650/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/issues/1722/2/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/issues/1722/1/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/function/experimental/strict_execution_order/issue_8910/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/function/experimental/strict_execution_order/issue_5303/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/function/advanced_chunks/tags_initial/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/function/advanced_chunks/split_node_modules/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/function/advanced_chunks/include_dependencies_recursively/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/function/advanced_chunks/basic_cjs/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/code_splitting/issue_8809/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/code_splitting/issue_8184/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/code_splitting/issue_5276/artifacts.snap Snapshot updates reflecting runtime merge into an existing host chunk.
crates/rolldown/tests/rolldown/code_splitting/issue_5276_2/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/rolldown/code_splitting/facade_elimination_with_shared_cjs_dep/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/esbuild/splitting/splitting_hybrid_esm_and_cjs_issue617/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/tests/esbuild/default/export_forms_with_minify_identifiers_and_no_bundle/diff.md Updates expected diff output for new chunk/runtime layout.
crates/rolldown/tests/esbuild/default/export_forms_with_minify_identifiers_and_no_bundle/artifacts.snap Snapshot updates reflecting new runtime placement/merging behavior.
crates/rolldown/src/stages/generate_stage/mod.rs Fixes single-file output validation by counting only live (non-tombstoned) chunks.
crates/rolldown/src/stages/generate_stage/manual_code_splitting.rs Removes manual-stage runtime extraction and ensures recursively included dependencies skip already-assigned modules (e.g., runtime).
crates/rolldown/src/stages/generate_stage/code_splitting.rs Introduces extract_standalone_runtime_chunk() and ensures it runs before manual/normal chunk materialization, plus final merge attempt.
crates/rolldown/src/stages/generate_stage/chunk_optimizer.rs Replaces runtime rehoming with try_merge_runtime_chunk() that merges runtime into a safe host using consumer/bitset/dominator checks and cycle/TLA constraints.
Files not reviewed (1)
  • crates/rolldown/tests/rolldown/function/advanced_chunks/split_node_modules/artifacts.snap: Language not supported

Comment thread meta/design/code-splitting.md Outdated

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.

Pull request overview

Copilot reviewed 39 out of 40 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • crates/rolldown/tests/rolldown/function/advanced_chunks/split_node_modules/artifacts.snap: Language not supported

@hyf0

hyf0 commented Jun 4, 2026

Copy link
Copy Markdown
Member

Reviewed this PR — the standalone-first runtime placement is a good direction and the cycle/TLA reasoning holds up. I found one confirmed correctness regression (verified with a live A/B run) and two lower-priority items (reasoned from the code, but not rigorously verified). Details below.


🔴 Confirmed (verified): runtime merge ignores preserveEntrySignatures and leaks helper exports onto an entry chunk

try_merge_runtime_chunk can fold the runtime module into a user-defined entry chunk without consulting preserveEntrySignatures. When another chunk reads a helper from that entry cross-chunk, the helper symbols (__esmMin, __toESM, …) — plus internal symbols like init_lib — get published onto the entry's public export surface, changing its signature.

The facade / common-chunk merge path guards exactly this via can_merge_without_changing_entry_signature (chunk_optimizer.rs:816-818), but the runtime merge path has no such guard. Note also that find_single_runtime_bitset_host (chunk_optimizer.rs:1246-1272) does not require ChunkKind::Common, so it can select a user entry as the host; and runtime_merge_target_is_allowed (1289-1294) only rejects ManualCodeSplitting.

Minimal reproduction (verified)

Drop this in as crates/rolldown/tests/rolldown/issues/repro_sig/ and run
cargo test -p rolldown --test integration repro_sig -- --ignored:

_config.json

{
  "config": {
    "input": [
      { "name": "main", "import": "./main.js" },
      { "name": "lib", "import": "./lib.js" }
    ],
    "format": "cjs",
    "exports": "named",
    "strictExecutionOrder": true,
    "preserveEntrySignatures": "strict",
    "minifyInternalExports": false
  }
}

lib.js (entry — declared signature is { foo, bar })

export const foo = 'foo_value';
export const bar = 42;

main.js (second entry — imports lib's exports and independently pulls runtime helpers)

import assert from 'node:assert';
import { foo, bar } from './lib.js';
assert.strictEqual(foo, 'foo_value');
assert.strictEqual(bar, 42);
export { foo, bar };

Equivalent JS-API options: input: { main, lib }, output: { format: 'cjs', exports: 'named', preserveEntrySignatures: 'strict', minifyInternalExports: false } (+ experimental.strictExecutionOrder).

Observed result (A/B), with explicit preserveEntrySignatures: "strict"

I ran the identical fixture on this PR's HEAD (24361d4b) and on pre-PR main (645ed627):

pre-PR mainlib.js exports exactly its declared signature ✅ (runtime/helpers/init_lib are isolated in a separate lib2.js chunk):

Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const require_lib = require("./lib2.js");
require_lib.init_lib();
exports.bar = require_lib.bar;
exports.foo = require_lib.foo;

This PR — runtime merged into the strict entry; helpers + init_lib leak onto lib.js ❌:

Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
// HIDDEN [\0rolldown/runtime.js]
//#region lib.js
var foo, bar;
var init_lib = __esmMin((() => { foo = "foo_value"; bar = 42; }));
//#endregion
init_lib();
exports.__esmMin = __esmMin;   // ← leaked
exports.__toESM = __toESM;     // ← leaked
exports.bar = bar;
exports.foo = foo;
Object.defineProperty(exports, "init_lib", { enumerable: true, get: () => init_lib }); // ← leaked

Why this matters beyond opt-in strict

This also reproduces under the default preserveEntrySignatures: "exports-only", which is strict for entries that have exports — and it's already visible in this PR's own committed snapshots, e.g. topics/exports/entry_esm_named_named/artifacts.snap and entry_esm_named_default/artifacts.snap, where the lib entry gained __esmMin/__toESM/init_lib exports vs main. So default-config multi-entry builds with CJS interop / reified namespaces are affected, not just explicit strict.

Suggested fix

Add a signature guard to the runtime merge target selection: reject a candidate that is a user entry with preserve_entry_signature != AllowExtension when the merge would re-export helpers (i.e. more than one runtime consumer). Equivalently, only allow merging the runtime into such an entry when it is the sole runtime consumer (helpers stay internal, which is already the safe single-consumer case). Chunk.preserve_entry_signature already exists, so the guard is cheap. (Worth also checking whether the facade-elimination interaction that pulls the lib body into the entry should respect the same guard.)


🟡 Two lower-priority items (NOT rigorously verified — reasoned from the code only)

1. Perf: the runtime-symbol scan now runs unconditionally and largely redundantly. try_merge_runtime_chunk(None) is now called on every code-split build (code_splitting.rs:970), and in collect_runtime_consumer_chunks the || chain evaluates the expensive chunk_references_runtime_symbol (per-symbol canonical_ref_resolving_namespace scan, chunk_optimizer.rs:1333-1363) before the cheaper dependencies.contains(&runtime_module_idx) (1318). Since patch_module_dependencies already populates dependencies with the runtime owner for these same referenced symbols, the scan looks redundant on this path. Likely a cheap win to reorder those || operands (or gate the scan). I have not benchmarked this, and the existing CodSpeed presets are all single-entry so they don't exercise it — would be good to add a multi-entry/dynamic-import bench.

2. Test coverage: 3 chunking-form Rollup samples moved from passing → skipped. entry-point-without-own-code, namespace-object-import, namespace-reexports (×cjs/es, failed-tests.json; passed 1213→1207). The chunking-form suite only asserts chunk count, not contents, so a "one fewer chunk" delta gets skipped rather than characterized. The PR's explanation (runtime folded into a same-bitset host → one fewer chunk, behavior unchanged) is plausible and consistent with the code (the merge only ever removes the runtime chunk, never merges two user chunks), but I did not independently diff the actual _actual/ vs _expected/ output for these three. A content-aware rolldown-side fixture pinning the intended topology would make the "benign" claim durable.

(Also minor: the design-doc "Regression coverage" list omits issues/9597, the headline new fixture this PR adds.)

@hyf0 hyf0 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. Please take a look at the high risk issue mentioned in #9419 (comment)

@IWANABETHATGUY IWANABETHATGUY force-pushed the 05-16-refactor_always_split_runtime_module_first branch from 24361d4 to 54c8a13 Compare June 4, 2026 08:16
@IWANABETHATGUY

Copy link
Copy Markdown
Member Author

Reviewed this PR — the standalone-first runtime placement is a good direction and the cycle/TLA reasoning holds up. I found one confirmed correctness regression (verified with a live A/B run) and two lower-priority items (reasoned from the code, but not rigorously verified). Details below.

🔴 Confirmed (verified): runtime merge ignores preserveEntrySignatures and leaks helper exports onto an entry chunk

try_merge_runtime_chunk can fold the runtime module into a user-defined entry chunk without consulting preserveEntrySignatures. When another chunk reads a helper from that entry cross-chunk, the helper symbols (__esmMin, __toESM, …) — plus internal symbols like init_lib — get published onto the entry's public export surface, changing its signature.

The facade / common-chunk merge path guards exactly this via can_merge_without_changing_entry_signature (chunk_optimizer.rs:816-818), but the runtime merge path has no such guard. Note also that find_single_runtime_bitset_host (chunk_optimizer.rs:1246-1272) does not require ChunkKind::Common, so it can select a user entry as the host; and runtime_merge_target_is_allowed (1289-1294) only rejects ManualCodeSplitting.

Minimal reproduction (verified)

Drop this in as crates/rolldown/tests/rolldown/issues/repro_sig/ and run cargo test -p rolldown --test integration repro_sig -- --ignored:

_config.json

{
  "config": {
    "input": [
      { "name": "main", "import": "./main.js" },
      { "name": "lib", "import": "./lib.js" }
    ],
    "format": "cjs",
    "exports": "named",
    "strictExecutionOrder": true,
    "preserveEntrySignatures": "strict",
    "minifyInternalExports": false
  }
}

lib.js (entry — declared signature is { foo, bar })

export const foo = 'foo_value';
export const bar = 42;

main.js (second entry — imports lib's exports and independently pulls runtime helpers)

import assert from 'node:assert';
import { foo, bar } from './lib.js';
assert.strictEqual(foo, 'foo_value');
assert.strictEqual(bar, 42);
export { foo, bar };

Equivalent JS-API options: input: { main, lib }, output: { format: 'cjs', exports: 'named', preserveEntrySignatures: 'strict', minifyInternalExports: false } (+ experimental.strictExecutionOrder).

Observed result (A/B), with explicit preserveEntrySignatures: "strict"

I ran the identical fixture on this PR's HEAD (24361d4b) and on pre-PR main (645ed627):

pre-PR mainlib.js exports exactly its declared signature ✅ (runtime/helpers/init_lib are isolated in a separate lib2.js chunk):

Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const require_lib = require("./lib2.js");
require_lib.init_lib();
exports.bar = require_lib.bar;
exports.foo = require_lib.foo;

This PR — runtime merged into the strict entry; helpers + init_lib leak onto lib.js ❌:

Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
// HIDDEN [\0rolldown/runtime.js]
//#region lib.js
var foo, bar;
var init_lib = __esmMin((() => { foo = "foo_value"; bar = 42; }));
//#endregion
init_lib();
exports.__esmMin = __esmMin;   // ← leaked
exports.__toESM = __toESM;     // ← leaked
exports.bar = bar;
exports.foo = foo;
Object.defineProperty(exports, "init_lib", { enumerable: true, get: () => init_lib }); // ← leaked

Why this matters beyond opt-in strict

This also reproduces under the default preserveEntrySignatures: "exports-only", which is strict for entries that have exports — and it's already visible in this PR's own committed snapshots, e.g. topics/exports/entry_esm_named_named/artifacts.snap and entry_esm_named_default/artifacts.snap, where the lib entry gained __esmMin/__toESM/init_lib exports vs main. So default-config multi-entry builds with CJS interop / reified namespaces are affected, not just explicit strict.

Suggested fix

Add a signature guard to the runtime merge target selection: reject a candidate that is a user entry with preserve_entry_signature != AllowExtension when the merge would re-export helpers (i.e. more than one runtime consumer). Equivalently, only allow merging the runtime into such an entry when it is the sole runtime consumer (helpers stay internal, which is already the safe single-consumer case). Chunk.preserve_entry_signature already exists, so the guard is cheap. (Worth also checking whether the facade-elimination interaction that pulls the lib body into the entry should respect the same guard.)

🟡 Two lower-priority items (NOT rigorously verified — reasoned from the code only)

1. Perf: the runtime-symbol scan now runs unconditionally and largely redundantly. try_merge_runtime_chunk(None) is now called on every code-split build (code_splitting.rs:970), and in collect_runtime_consumer_chunks the || chain evaluates the expensive chunk_references_runtime_symbol (per-symbol canonical_ref_resolving_namespace scan, chunk_optimizer.rs:1333-1363) before the cheaper dependencies.contains(&runtime_module_idx) (1318). Since patch_module_dependencies already populates dependencies with the runtime owner for these same referenced symbols, the scan looks redundant on this path. Likely a cheap win to reorder those || operands (or gate the scan). I have not benchmarked this, and the existing CodSpeed presets are all single-entry so they don't exercise it — would be good to add a multi-entry/dynamic-import bench.

2. Test coverage: 3 chunking-form Rollup samples moved from passing → skipped. entry-point-without-own-code, namespace-object-import, namespace-reexports (×cjs/es, failed-tests.json; passed 1213→1207). The chunking-form suite only asserts chunk count, not contents, so a "one fewer chunk" delta gets skipped rather than characterized. The PR's explanation (runtime folded into a same-bitset host → one fewer chunk, behavior unchanged) is plausible and consistent with the code (the merge only ever removes the runtime chunk, never merges two user chunks), but I did not independently diff the actual _actual/ vs _expected/ output for these three. A content-aware rolldown-side fixture pinning the intended topology would make the "benign" claim durable.

(Also minor: the design-doc "Regression coverage" list omits issues/9597, the headline new fixture this PR adds.)

addressed

IWANABETHATGUY commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

Merge activity

  • Jun 4, 8:20 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 4, 8:22 AM UTC: IWANABETHATGUY added this pull request to the Graphite merge queue.
  • Jun 4, 8:27 AM UTC: Merged by the Graphite merge queue.

## Summary

Reworks runtime-module chunk placement (see `meta/design/code-splitting.md` →
"Runtime Module Placement"): the runtime module is always extracted into its own
chunk first, then merged back into a proven-safe host chunk (the sole runtime
consumer / a chunk with the same reachability bitset / a consumer-set dominator),
guarded by TLA- and static-cycle checks. The initial layout is cycle-safe by
construction instead of place-by-bitset-then-peel-on-cycle.

A follow-up commit fixes a regression this surfaced: single-file `output.file`
(es/cjs) builds that pull in a runtime helper (e.g. a reified namespace) were
wrongly rejected with `[INVALID_OPTION] InvalidOutputFile`. The multi-chunk guard
in `generate()` counted the merged-away runtime chunk's tombstone (still in
`chunk_table`, but skipped at render time). It now counts only live chunks,
mirroring the render-time filter.

## Why some Rollup alignment tests are skipped

Three `chunking-form` samples are added to
`packages/rollup-tests/src/failed-tests.json`. **These are not workarounds for a
bug** — Rolldown's output is behaviorally correct; it just emits one fewer chunk
than Rollup.

The `chunking-form` suite only asserts that Rolldown emits the **same number of
chunks** as Rollup (`assert.strictEqual(actualChunkCount, expectedChunkCount)`);
it does not compare chunk contents. With the runtime now folded into a
same-bitset host chunk, these multi-entry topologies no longer need a standalone
runtime chunk, so Rolldown emits one fewer chunk than Rollup: Rollup hoists shared
content into a `generated-*.js` chunk, while Rolldown folds that content (plus the
runtime) into an existing entry chunk.

Each was verified by **executing** both Rolldown's `_actual/` and Rollup's
`_expected/` output and comparing observable behavior — entry exports, console
output, and computed values all match. The only differences are the chunk count
and cosmetic representation (Rolldown's namespace objects carry the
spec-compliant `Symbol.toStringTag: "Module"`, which Rollup's frozen-object
namespaces omit).

Affected samples (both `generates es` and `generates cjs`):

- `entry-point-without-own-code` — Handles entry points that contain no own code except imports and exports
- `namespace-object-import` — namespace object import
- `namespace-reexports` — namespace rendering with reexports

To re-check one, run the sample and diff its `_actual/` against `_expected/`, e.g.
`pnpm --filter rollup-tests test --grep "namespace-object-import: namespace object import"`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

closed #9597
@graphite-app graphite-app Bot force-pushed the 05-16-refactor_always_split_runtime_module_first branch from 19c6ece to 35054a1 Compare June 4, 2026 08:22
@graphite-app graphite-app Bot merged commit 35054a1 into main Jun 4, 2026
34 checks passed
@graphite-app graphite-app Bot deleted the 05-16-refactor_always_split_runtime_module_first branch June 4, 2026 08:27
@sapphi-red

Copy link
Copy Markdown
Member

@IWANABETHATGUY Would you run https://github.com/sapphi-red/zarara with the changes to ensure there's no regression? (if you haven't)

@IWANABETHATGUY

Copy link
Copy Markdown
Member Author

@IWANABETHATGUY Would you run https://github.com/sapphi-red/zarara with the changes to ensure there's no regression? (if you haven't)

I have run it twice locally, each time with 6000 seeds. Not sure if it is enough.

@sapphi-red

Copy link
Copy Markdown
Member

Ah, ok. That should be sufficient.

shulaoda added a commit that referenced this pull request Jun 9, 2026
#9685)

### Summary

Restore the `rolldown-runtime` name for the standalone runtime chunk, which was accidentally dropped in #9419.

### Background

#9419 ("refactor: always split runtime module first") reworked how the runtime module is placed: it is now always extracted into its own chunk first and merged back into a safe host when possible. When it stays standalone, the chunk is created in `extract_standalone_runtime_chunk` (`crates/rolldown/src/stages/generate_stage/code_splitting.rs`) via `Chunk::new(None, …)`. The old code path passed `Some("rolldown-runtime".into())` as the name, so that name was lost in the rewrite.

Consequently the standalone runtime chunk is now emitted as a generic `chunk-[hash].js` rather than `rolldown-runtime-[hash].js`. The content, hash and placement are identical — only the name changed.

### Why it matters

The `rolldown-runtime` name is the documented, recognizable name for the runtime chunk ("why there's always a runtime.js chunk"), and downstream tooling relies on it. It surfaced as a `@vitejs/plugin-rsc` ecosystem-ci failure: a test that excludes the runtime chunk by name (`imports.filter((f) => !f.includes('rolldown-runtime'))`) no longer matched, so a vendor chunk wrongly looked like it had a non-runtime import.

### Fix

Pass `Some("rolldown-runtime".into())` as the chunk name in `extract_standalone_runtime_chunk`, restoring the pre-#9419 name. The runtime placement logic from #9419 is otherwise unchanged.
@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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: circular import generated when using dynamic import and require

6 participants