Skip to content

fix: preserve export keyword on multi-declarator exports under keepNames#9974

Merged
shulaoda merged 1 commit into
mainfrom
06-26-fix_preserve_export_keyword_on_multi-declarator_exports_under_keepnames
Jun 26, 2026
Merged

fix: preserve export keyword on multi-declarator exports under keepNames#9974
shulaoda merged 1 commit into
mainfrom
06-26-fix_preserve_export_keyword_on_multi-declarator_exports_under_keepnames

Conversation

@shulaoda

Copy link
Copy Markdown
Member

What this solves

fixes #9973

With keepNames: true, a multi-declarator export loses its export keyword. The exported names are silently dropped from the bundle, or the build fails with MISSING_EXPORT when another module imports them by name. Single-declarator exports are unaffected.

// input
export const a = 1, b = 2;
export const c = 3;

// before (keepNames: true)  ->  a, b are gone
const c = 3;
export { c };

// after (keepNames: true)
const a = 1;
const b = 2;
const c = 3;
export { a, b, c };

Root cause

visit_declaration is shared by both a standalone declaration statement and an export's inner declaration (oxc calls it from walk_statement and from walk_export_named_declaration). Under keepNames it splits a multi-declarator VariableDeclaration into separate non-export statements and empties the original declarators. For an export, this runs first and clears the list, so the export-aware split in visit_export_named_declaration (declarations.len() > 1) is skipped and the export is lost.

The fix

visit_export_named_declaration sets a flag before walking its inner declaration. visit_declaration consumes the flag (std::mem::take) and skips the non-export split for that direct declaration, so the export-aware split runs instead. Consuming the flag keeps it scoped to the direct child only, so nested multi-declarator declarations inside an export's initializer still split.

Alternatives considered

  • Doing the export-aware split before the walk: would move declarators out early and skip nested transforms (dynamic import / require transpose / defer).
  • Detecting the export parent by AST address comparison: relies on oxc GetAddress aliasing internals, fragile.

Tests

Added tests/rolldown/issues/9973: a module imports the multi-declarator export by name and asserts the values, which fails to build (MISSING_EXPORT) without this fix. Full rolldown integration suite passes with no other snapshot changes.

@netlify

netlify Bot commented Jun 26, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs canceled.

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

@codspeed-hq

codspeed-hq Bot commented Jun 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 7 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing 06-26-fix_preserve_export_keyword_on_multi-declarator_exports_under_keepnames (1ad76e4) with main (2875a55)

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.

Comment thread crates/rolldown/src/utils/tweak_ast_for_scanning.rs
@shulaoda shulaoda merged commit 53c1763 into main Jun 26, 2026
35 checks passed
@shulaoda shulaoda deleted the 06-26-fix_preserve_export_keyword_on_multi-declarator_exports_under_keepnames branch June 26, 2026 06:05
IWANABETHATGUY added a commit that referenced this pull request Jun 26, 2026
The previous split was fragile and error-prone by construction. The split
DECISION was made deep in the tree (`visit_declaration`,
`visit_export_named_declaration`), but the APPLICATION -- replacing one
statement with several -- happens at the statement-list level, bridged by a
deferred, address-keyed `statement_replace_map` + `statement_stack`. That
indirection let two independent deciders write the same key: because
`visit_declaration` also fires for an export''s inner declaration, the
bare-var and export deciders raced and silently dropped the `export` keyword
on multi-declarator exports under keepNames (#9973). #9974 refereed the race
with a `next_declaration_is_exported` flag -- papering over the collision
with shared mutable state rather than removing it, leaving the same shape
ready to break the next time a statement form is added.

Remove the fragile mechanism, not just another instance of the bug:
consolidate the decision into a single `split_multi_declarator` helper,
called once per statement from the three slots that own statement placement
(`visit_program`, `visit_statements`, `visit_statement`). Each statement is
inspected exactly once with full context, so export-vs-bare is decided by
node type -- no deferred map, no flag, and the collision becomes
structurally impossible. Removes `statement_replace_map`, `statement_stack`,
`next_declaration_is_exported`, and the `visit_declaration` /
`visit_export_named_declaration` overrides.

Pure refactor: output is byte-identical (full integration suite passes with
zero snapshot changes).
graphite-app Bot pushed a commit that referenced this pull request Jun 26, 2026
#9982)

## Why

The previous pre-scan split was **fragile and error-prone by construction** — this refactor removes the mechanism, not just another instance of the bug.

- The split *decision* was made deep in the tree (`visit_declaration`, `visit_export_named_declaration`), but the *application* — replacing one statement with several — happens at the statement-list level. The two were bridged by a deferred, address-keyed `statement_replace_map` + `statement_stack`.
- That indirection let **two independent deciders write the same map key**. Because `visit_declaration` also fires for an export''s inner declaration, the bare-var and export deciders raced — silently dropping the `export` keyword on multi-declarator exports under `keepNames` (#9973).
- #9974 refereed the race with a `next_declaration_is_exported` boolean — papering over the collision with shared mutable state rather than removing it. The same shape (two overlapping mutators coordinated by a flag) stays just as easy to break the next time a statement form is added.

## What

Consolidate the decision into a single `split_multi_declarator(stmt)` helper, called once per statement from the three slots that own statement placement — `visit_program`, `visit_statements`, `visit_statement`. Each statement is inspected exactly once, with full context, so export-vs-bare is decided by matching the node type. No deferred map, no side-channel flag, and the collision becomes **structurally impossible** (one decider per slot).

## Removed

- `statement_replace_map` + `statement_stack` (deferred, address-keyed replacement)
- `next_declaration_is_exported` (the #9974 referee flag)
- the `visit_declaration` and `visit_export_named_declaration` overrides

## Behavior

Pure refactor — output is byte-identical. `cargo test -p rolldown --test integration` passes (1768 tests) with **zero snapshot changes**; clippy clean.
@rolldown-guard rolldown-guard Bot mentioned this pull request Jul 1, 2026
shulaoda added a commit that referenced this pull request Jul 1, 2026
## [1.1.4] - 2026-07-01

### 🚀 Features

- disable `experimental.lazyBarrel` by default (#10071) by @shulaoda

### 🐛 Bug Fixes

- dev: disable lazy barrel in dev mode (#10060) by @shulaoda
- generate: keep full JSON interface under preserveModules namespa… (#10056) by @IWANABETHATGUY
- check finalize_other_specifiers in its own Debug attribute (#10032) by @shulaoda
- serialize the KeepAssign unused minify option as "keep_assign" (#10031) by @shulaoda
- keep fragments after the newline fragment in MagicString::last_line (#10023) by @shulaoda
- generate: undeclared JSON named exports under preserveModules (#10020) (#10027) by @IWANABETHATGUY
- deconflict: rename CJS-wrapped locals that shadow chunk-root bindings (#9921) by @IWANABETHATGUY
- rolldown: keep entry facade when a shared chunk holds another entry's module (#9997) by @hyf0
- treeshake: also bail JSON default split when the object escapes (#9996) by @IWANABETHATGUY
- don't classify await in a strict-mode function as top-level await (#9987) by @shulaoda
- avoid spurious leading newline in addon hooks (banner/footer/intro/outro) (#9989) by @shulaoda
- handle JSON default mutation bailouts (#9972) by @TheAlexLichter
- plugin: make lazy hook metadata enumerable (#9991) by @TheAlexLichter
- dev: make init errors in lazy-compiled modules catchable (#9981) by @h-a-n-a
- treeshake: keep computed-key side effects on namespace member access (#9986) by @shulaoda
- binding: validate replace plugin delimiters length instead of panicking (#9984) by @shulaoda
- reconstruct nested rest patterns in into_expression (#9980) by @IWANABETHATGUY
- reconstruct rest patterns as spread in into_expression (#9976) by @shulaoda
- preserve export keyword on multi-declarator exports under keepNames (#9974) by @shulaoda
- deterministically keep the shortest name for deduplicated assets (#9948) by @x1024
- treeshake: apply @__NO_SIDE_EFFECTS__ to cross-chunk namespace calls (#9960) by @IWANABETHATGUY

### 🚜 Refactor

- drop redundant program scope enter/leave in finalizer (#10049) by @shulaoda
- deconflict: extract collect_chunk_scope_captured_names (#10006) by @IWANABETHATGUY
- unify pre-scan multi-declarator split into one decision site (#9982) by @IWANABETHATGUY
- common: return bool from SymbolRef::is_not_reassigned (#9962) by @IWANABETHATGUY

### 📚 Documentation

- rolldown: remove outdated comment for removing parenthesized expression (#10062) by @Dunqing
- use GitHub-flavored alert for Etiquette note in contribution guide (#10012) by @IWANABETHATGUY
- replace: explain the delimiters left and right boundaries (#9985) by @shulaoda
- ast-mutation: remove stale Address Use section after pre-scan refactor (#9983) by @IWANABETHATGUY
- remove fathom (#9968) by @mdong1909
- contribution-guide: code-format main branch references (#9966) by @IWANABETHATGUY
- contribution-guide: fix stale REPL note and tidy wording (#9957) by @hyf0
- contribution-guide: clarify when to discuss before opening a PR (#9955) by @hyf0

### ⚡ Performance

- disable preserve_parens across all parse paths (#10057) by @Dunqing
- common: inline declared_symbols with SmallVec (#9920) by @IWANABETHATGUY
- common: pack TaggedSymbolRef into 8 bytes (#9919) by @IWANABETHATGUY
- sourcemap: skip newline scan on the no-sourcemap join fast path (#9936) by @Boshen

### 🧪 Testing

- dev: error in lazy module should be catchable (#9975) by @sapphi-red
- dev: reject unknown lazy compile modules (#9969) by @sapphi-red

### ⚙️ Miscellaneous Tasks

- deps: update actions/cache action to v6 (#10001) by @renovate[bot]
- trigger vite ecosystem-ci from PR comments (#10058) by @shulaoda
- deps: update napi to v3.10.0 (#10063) by @renovate[bot]
- remove unused From impl for RolldownLabelSpan (#10055) by @shulaoda
- remove dead Diagnostic::with_kind method (#10054) by @shulaoda
- remove unused StatementExt methods (#10053) by @shulaoda
- remove unused ExpressionExt methods (#10052) by @shulaoda
- remove commented-out re_export_all_names field (#10051) by @shulaoda
- deps: update pnpm to v11.9.0 (#10047) by @renovate[bot]
- remove the unused BindingGenerateHmrPatchReturn napi type (#10034) by @shulaoda
- remove the dead inline_entry_chunk_wrapping scaffolding (#10037) by @shulaoda
- deps: bump oxc_resolver to 11.22.0 (#10045) by @Boshen
- remove never-constructed MatchImportKind::_Ignore variant (#10041) by @shulaoda
- remove the unused ScheduledBuild napi struct (#10033) by @shulaoda
- remove dead compute_hmr_update_single method (#10040) by @shulaoda
- drop the redundant visited.insert in manual code splitting (#10038) by @shulaoda
- remove the dead output_assets vector in render_chunk_to_assets (#10036) by @shulaoda
- remove the unused From<String>/Display impls for BindingLogLevel (#10035) by @shulaoda
- deps: upgrade oxc to 0.138.0 and migrate to per-type AST construction (#10018) by @shulaoda
- deps: update rust crates (#9911) by @renovate[bot]
- deps: update test262 submodule for tests (#10016) by @rolldown-guard[bot]
- deps: update github actions (#9999) by @renovate[bot]
- deps: update npm packages (#10000) by @renovate[bot]

### ◀️ Revert

- "fix(plugin): make lazy hook metadata enumerable (#9991)" (#10005) by @shulaoda

### ❤️ New Contributors

* @x1024 made their first contribution in [#9948](#9948)

Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.com>
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]: keepNames drops the export keyword on multi-declarator exports (export const a = 1, b = 2)

2 participants