Skip to content

js_parser: apply exports.eliminate/replace to function and class declarations - #33378

Open
robobun wants to merge 5 commits into
mainfrom
farm/83cf3649/exports-replace-without-dce
Open

robobun wants to merge 5 commits into
mainfrom
farm/83cf3649/exports-replace-without-dce

Conversation

@robobun

@robobun robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

const t = new Bun.Transpiler({
  loader: "ts",
  deadCodeElimination: false,
  exports: { eliminate: ["f"] },
});

t.transformSync(`export function f() {}\nexport const keep = 1;`);
// "export function f() {}\nexport const keep = 1;\n"   <- f survives, silently
t.scan(`export function f() {}\nexport const keep = 1;`).exports;
// ["f", "keep"]

export class f {} behaves the same. export const f = () => {} and export { f } are eliminated correctly, and everything works once deadCodeElimination is turned on. exports.replace is ignored in exactly the same cases.

exports.eliminate/exports.replace is the API frameworks use to strip server-only exports out of client bundles, so the declaration form deciding whether the strip happens means functions and classes, the exports people most expect to strip, survive into the client artifact whenever a tool turns DCE off.

Cause

s_function and s_class fold two unrelated decisions into one mark_as_dead flag:

let mark_as_dead = p.options.features.dead_code_elimination
    && data.func.flags.contains(flags::Function::IsExport)
    && p.options.features.replace_exports.count() > 0
    && p.is_export_to_eliminate(data.func.name.unwrap().ref_);

It decides both whether to suppress usage counts while visiting the body and whether to eliminate or replace the export at all, and it is &&-ed with dead_code_elimination. The export const path (visit_decls) and s_export_default already keep those separate: the replacement is unconditional, and only is_control_flow_dead is gated on DCE.

Fix

should_replace_export no longer looks at dead_code_elimination; mark_as_dead becomes should_replace_export && dead_code_elimination.

Auditing the sibling sites for the same assumption turned up four more bugs in replace_exports, all fixed here.

Namespace members

should_replace_export also requires enclosing_namespace_arg_ref.is_none(), in all three sites (s_function, s_class, s_local). A declaration exported from a TypeScript namespace is a property of the namespace object, not a module export, and each site got this wrong in its own way when a member happened to share a name with an eliminate/replace target:

const t = new Bun.Transpiler({ loader: "ts", exports: { eliminate: ["f"] } });

t.transformSync(`import g from "g";\nexport namespace N { export function f() { g(); } }`);
// before: "...((N) => {\n  function f() {}\n  N.f = f;\n})(N ||= {});\n"   <- g() gone

t.transformSync(`import g from "g";\nexport namespace N { export const f = g(); }`);
// before: "...((N) => {})(N ||= {});\n"   <- member and its side effect both gone

s_function/s_class marked the body dead while the namespace branch still emitted the statement, so the body came out empty. s_local dropped the declaration outright, taking the initializer with it.

Declarations with no initializer

visit_decls handles a decl without an initializer in a separate branch, and that branch read replace_decl_and_possibly_remove's return value with the opposite polarity of the branch right above it. The helper returns false for Delete (leaving the decl alone) and true for Replace/Inject (which write a value into it), so:

new Bun.Transpiler({ loader: "ts", exports: { eliminate: ["f"] } })
  .transformSync("export let f;");
// panic: called `Option::unwrap()` on a `None` value   <- aborts the process

new Bun.Transpiler({ loader: "ts", exports: { replace: { f: 42 } } })
  .transformSync("export let f;\nexport const keep = 1;");
// "export const keep = 1;\n"   <- f silently dropped instead of `export let f = 42;`

Delete fell into visit_decl, which unwraps decl.value; Replace/Inject took the continue arm and threw the rewritten decl away. Matching the polarity of the initialized branch fixes both.

export default

  • export default class X {} with a replacement value kept the class. s_export_default writes the replacement into data.value, then the class lowering below reassigns data.value and undoes it. The replaced statement is now pushed and the lowering skipped, the same shape the function branch already uses.
  • export default with an inject entry (replace: { default: ["__N_SSG", true] }) emitted nothing when deadCodeElimination was on, because the is_control_flow_dead check returns before the injection runs. The injection is Delete-safe, so it runs on that path too.

Hoisting

Eliminating a hoistable export now happens with deadCodeElimination off as well, which reaches the parts.pop().expect("unreachable") in _parse that assumes append_part appended a part. Without that, export class f {} + eliminate: ["f"] aborts the process, and a non-leading one silently steals an unrelated part and reorders the output. This PR carries the identical hunk as #33376 (append_part reports whether it appended) because the tests here reach that path; if #33376 lands first, this rebases away cleanly.

Verification

bun bd test test/bundler/transpiler/transpiler.test.js — 229 pass, 0 fail. On main, 34 of the new assertions fail, each for the right reason:

case on main
export function f(){} + eliminate, DCE off f survives
export class f {} + eliminate, DCE off f survives
lone export class f {} + eliminate panic: unreachable, child aborts
console.log("a");console.log("b");export class f {} + eliminate prints b before a
export let f; + eliminate panic: called Option::unwrap() on a None value
export let f; + replace / inject export silently dropped
export default class X {} + replace: { default: 42 } class survives
export default X + replace: { default: ["__N_SSG", true] }, DCE on nothing emitted
export namespace N { export function f(){ g(); } } + eliminate body emptied
export namespace N { export const f = g(); } + eliminate member and g() gone

The new block asserts the full matrix: deadCodeElimination in [true, false] x 4 initialized declaration forms (function, class, const, export clause) x eliminate / replace / inject, plus uninitialized let/var, the three export default value forms, and the namespace members. Every source leads with two statements so the assertions pin statement order too; every cell's transformSync output and scan().exports is now identical across both deadCodeElimination settings. The three shapes that abort on main run in a spawned child so the failure is reported rather than taking the runner down.

Wider suites
test/bundler/transpiler/            3862 pass   0 fail   (4204 tests, 19 files)
bundler_edgecase + decorator_metadata + feature_flag   150 pass   0 fail
cargo check -p bun_js_parser        no warnings

test/bundler/transpiler/macro-test.test.ts is excluded from that count: on a debug build the whole-directory run trips a pre-existing JSC assertion (ASSERTION FAILED: !m_topGCOwnedDataScope in Heap::clearConcurrentRetainedDataIfPossible), which reproduces on a pristine main checkout and is unrelated to this change. The file passes on its own (11/11).


[review] gate passed · iteration 3 · 5 files touched

fails on main (without fix)
ASAN without fix: 34 failed, 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/transpiler.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (897b7f6cc)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [5.11ms]
(pass) Bun.Transpiler > normalizes \r\n [5.72ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [3.77ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [6.94ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.44ms]
(pass) Bun.Transpiler > property access inlining > works [2.02ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.90ms]
(pass) Bun.Transpiler > TypeScript > import Foo = Baz.Bar [2.39ms]
(pass) Bun.Transpiler > TypeScript > ternary should parse correctly when parsing typescript fails [2.58ms]
(pass) B
... (truncated)

release without fix: 34 failed, 22 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [0.09ms]
(pass) Bun.Transpiler > normalizes \r\n [0.12ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [0.08ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [0.12ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [0.03ms]
(pass) Bun.Transpiler > property access inlining > works [0.03ms]
(pass) Bun.Transpiler > property access inlining > works nested [0.03ms]
(pass) Bun.Transpiler > TypeScript > import Foo = Baz.Bar [0.08ms]
(pass) Bun.Transpiler > TypeScript > ternary should parse correctly when parsing typescript fails [0.04ms]
(pass) Bun.Transpiler > TypeScript > contextual keywords used as plain identifiers keep their statements [0.25ms]
(pass) Bun.Transpiler > TypeScript > does not crash when export default abstract is an expression followed by a class [0.22ms]
(pass) Bun.Transpiler > TypeScript > scope tracking stays balanced when a contextual keyword starts a larger expression [0.22ms]
(pass) Bun.Transpiler > TypeScript > scope tracking stays balan
... (truncated)
passes on PR (with fix)
ASAN with fix: 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/transpiler.test.js
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (897b7f6cc)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [4.85ms]
(pass) Bun.Transpiler > normalizes \r\n [6.00ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [4.25ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [7.62ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.57ms]
(pass) Bun.Transpiler > property access inlining > works [2.38ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.54ms]
(pass) Bun.Transpiler > TypeScript > import Foo = Baz.Bar [2.51ms]
(pass) Bun.Transpiler > TypeScript > ternary should parse correctly when parsing typescript fails [2.79ms]
(pass) B
... (truncated)

release with fix: 22 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     897b7f6cc4
  features     (none)

22 deps, 106 codegen, 1168 objects in 815ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [12.00ms]
[2/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [3.00ms]
[3/1231] gen ErrorCode+*.h
[4/1231] gen bindgenv2
[5/1231] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [7.00ms]
[6/1231] fetch zlib
[zlib] up to date
[7/1231] fetch picohttpparser
[picohttpparser] up to date
[8/1231] fetch libj
... (truncated)
diff hotspot
src/js_parser/p.rs                         |  10 +-
 src/js_parser/parse/parse_entry.rs         |  12 +-
 src/js_parser/visit/mod.rs                 |   6 +-
 src/js_parser/visit/visit_stmt.rs          | 128 +++++++++++---------
 test/bundler/transpiler/transpiler.test.js | 182 +++++++++++++++++++++++++++++
 5 files changed, 273 insertions(+), 65 deletions(-)

gate history · 1 passed · 0 rejected · iteration 3

evidence per changed file
file                                        reads  edits  tests
src/js_parser/p.rs                              4      1      0
src/js_parser/parse/parse_entry.rs              1      2      0
src/js_parser/visit/mod.rs                      4      1      0
src/js_parser/visit/visit_stmt.rs              14     16      0
test/bundler/transpiler/transpiler.test.js      9     13      0

@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:57 AM PT - Jul 11th, 2026

❌ @robobun, your commit 897b7f6 has 3 failures in Build #71849 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33378

That installs a local version of the PR into your bun-33378 executable, so you can run:

bun-33378 --bun

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR changes append_part to return whether a part was appended, updates its callers to conditionally move statements based on that result, and adjusts export lowering so replacement exports are emitted independently of dead-code elimination. New transpiler tests cover eliminate/replace behavior.

Changes

Export Replacement Independent of Dead Code Elimination

Layer / File(s) Summary
append_part returns appended flag
src/js_parser/p.rs
append_part now returns Result<bool, Error>, computes an appended flag from part_stmts emptiness, and returns that flag.
Callers gate part popping on append result
src/js_parser/parse/parse_entry.rs
SClass and SExportDefault handling capture the boolean from append_part and only move popped parts into before when both should_move and appended are true.
Default export replacement macro and dead-path emission
src/js_parser/visit/visit_stmt.rs
s_export_default adds inject_default_replacement! for replace_exports lookups, invokes it on control-flow-dead paths for default expression, function, and class exports, and changes the class replacement branch to emit the replacement expression directly when present.
Function, class, and local export replacement gating
src/js_parser/visit/visit_stmt.rs
s_function and s_class derive should_replace_export and mark_as_dead from export eligibility, adjust cleanup and emission branches, add direct replacement injection for functions, and refactor s_local to use should_replace_export when selecting visit_decls behavior.
Transpiler tests for eliminate/replace independence
test/bundler/transpiler/transpiler.test.js
New transpiler tests cover exports.eliminate and exports.replace across deadCodeElimination settings, default export replacement, hoisting regressions, and namespace-member name collision cases.

Related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the core change: applying exports.eliminate/replace to function and class declarations.
Description check ✅ Passed The description is detailed and includes the problem, fix, and verification, though it uses different headings than the template.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bundler/transpiler/transpiler.test.js`:
- Around line 1663-1679: The namespace regression test is only covering the
default deadCodeElimination behavior, so it misses the DCE-off path this change
is meant to protect. Move the existing “does not touch a namespace member that
happens to share the name” assertions into the same deadCodeElimination loop
used by the surrounding Bun.Transpiler tests, or duplicate them for both true
and false so the namespace function/class cases are exercised under both
settings.
- Around line 1626-1659: The default-export cases in the transpiler tests only
verify transform output, but they should also assert the scanned exports list
like the nearby forms-based tests. Update the three defaultValues cases in
transpiler.test.js to call transpiler.scan(code).exports in addition to
transformSync, using the same Transpiler setup in each test. This is especially
important for the eliminate/replace/inject default-export paths so regressions
in how default and __N_SSG are reported through scanning are caught
consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 69944db9-6915-4410-9666-7dfd2b315b83

📥 Commits

Reviewing files that changed from the base of the PR and between fb50cce and 757cc85.

📒 Files selected for processing (4)
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/transpiler/transpiler.test.js

Comment thread test/bundler/transpiler/transpiler.test.js Outdated
Comment thread test/bundler/transpiler/transpiler.test.js Outdated
Comment thread src/js_parser/visit/visit_stmt.rs

@claude claude Bot 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.

Thanks for extending the namespace guard to s_local and adding the const case to the test — that closes out my earlier note. I don't see any remaining issues, but the control-flow restructuring in s_export_default/s_function/s_class (splitting mark_as_dead from should_replace_export, the early return that skips class lowering, and the new inject_default_replacement! on dead paths) is subtle enough that a human should sign off.

Extended reasoning...

Overview

This PR fixes Bun.Transpiler's exports.eliminate/exports.replace so they apply to export function and export class declarations regardless of the deadCodeElimination setting, and stops them from incorrectly matching TypeScript namespace members. It touches four files: src/js_parser/p.rs (append_part now returns whether it appended), src/js_parser/parse/parse_entry.rs (callers gate the hoisting parts.pop() on that return), src/js_parser/visit/visit_stmt.rs (splits mark_as_dead into should_replace_export + DCE across s_function/s_class/s_local, adds a macro to inject default replacements on dead paths, and makes the export default class replacement branch push and return early to avoid lowering overwriting the replacement), and test/bundler/transpiler/transpiler.test.js (a comprehensive matrix over DCE on/off × 4 declaration forms × eliminate/replace/inject, plus default-export forms, ordering, namespace non-interference, and a spawned subprocess for the previously-aborting case).

Since my last review, commit 9dd04bc addressed my inline note by adding enclosing_namespace_arg_ref.is_none() to the s_local gate and a const assertion to the namespace test, and also folded in both CodeRabbit nits (default-export .scan().exports assertions and moving the namespace test inside the DCE loop).

Security risks

None identified. This is transpiler output-shaping logic; no auth, crypto, filesystem, or network surface. The closest security-adjacent angle is that exports.eliminate is used by frameworks to strip server-only code from client bundles, and this PR makes that stripping more reliable (previously function/class exports could leak through with DCE off).

Level of scrutiny

Medium-high. The parser visitor is a critical, high-traffic code path where control-flow ordering matters (statement hoisting, is_control_flow_dead restore, lowering vs. replacement). The refactor is well-reasoned and the removed if mark_as_dead inside else if !mark_as_dead was provably dead, and || was_export_inside_namespace is now subsumed by !should_replace_export since the two flags are mutually exclusive — but verifying those equivalences requires tracing several interacting booleans across ~200 lines. That's beyond what I'd auto-approve.

Other factors

Test coverage is excellent: the new describe block asserts exact output for the full DCE × form × operation matrix, includes .scan().exports assertions, pins statement ordering (catching the earlier parts.pop() mis-hoist), spawns a subprocess for the abort case so a regression fails the child rather than the runner, and covers all three namespace-member forms. The PR description documents each failure mode on main and the wider suites that pass. All prior review feedback (mine and CodeRabbit's) is addressed. No outstanding comments.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js_parser/visit/visit_stmt.rs (1)

806-833: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use .expect(...) instead of bare .unwrap() for consistency with the new macro.

The invariant here ("mark_for_replace implies replace_exports has a "default" entry") is identical to the one the new inject_default_replacement! macro documents via .expect("infallible: mark_for_replace implies an entry"). This restructured block still uses a bare .unwrap(), which loses the diagnostic message if the invariant is ever violated by a future refactor.

♻️ Proposed fix
             let entry = p
                 .options
                 .features
                 .replace_exports
                 .get_ptr(b"default")
                 .cloned()
-                .unwrap();
+                .expect("infallible: mark_for_replace implies an entry");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js_parser/visit/visit_stmt.rs` around lines 806 - 833, The
`mark_for_replace` path in `visit_stmt` still uses a bare `.unwrap()` when
reading the `"default"` entry from `replace_exports`, which is inconsistent with
the new `inject_default_replacement!` invariant handling. Replace that unwrap
with an `.expect(...)` message matching the documented invariant, and keep the
logic in the `ReplaceableExport::Replace` branch aligned with the macro’s
pattern so future violations fail with a clear diagnostic.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bundler/transpiler/transpiler.test.js`:
- Around line 1661-1675: The subprocess test in the hoistable `it.each(...)`
case drops captured `stderr` from the assertion and is not marked concurrent.
Update this test to use `.concurrent` like the other process-spawning cases, and
change the final expectation to assert the combined `{ stdout, stderr, exitCode
}` result from `Promise.all(...)` using the existing `proc.stdout.text()`,
`proc.stderr.text()`, and `proc.exited` values so failures include diagnostic
output.

---

Outside diff comments:
In `@src/js_parser/visit/visit_stmt.rs`:
- Around line 806-833: The `mark_for_replace` path in `visit_stmt` still uses a
bare `.unwrap()` when reading the `"default"` entry from `replace_exports`,
which is inconsistent with the new `inject_default_replacement!` invariant
handling. Replace that unwrap with an `.expect(...)` message matching the
documented invariant, and keep the logic in the `ReplaceableExport::Replace`
branch aligned with the macro’s pattern so future violations fail with a clear
diagnostic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 275014c5-4329-48ea-80a7-b506def258c2

📥 Commits

Reviewing files that changed from the base of the PR and between 757cc85 and 810a2a2.

📒 Files selected for processing (2)
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/transpiler/transpiler.test.js

Comment thread test/bundler/transpiler/transpiler.test.js Outdated
Comment thread src/js_parser/visit/visit_stmt.rs
@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status

Build 71849 on the rebased sha 897b7f6c finished 283 passed, 3 failed. All three failures are unrelated to this diff; two are known main breaks already owned by other sessions, and the third is an environment failure.

lane failure why not this diff
debian 13 x64-asan (1 of 20 shards) test-worker-message-port-transfer-terminate.js: ASSERTION FAILED: !scope.exception() || !result in JSC::JSObject::getOwnPropertyDescriptor during Worker termination 19/20 x64-asan shards passed; passes 13/13 locally with this build; tracked as a main break
alpine 3.23 x64-baseline next-build.test.ts: bun --bun next build segfaults in a tokio-runtime-w thread (Next.js's own SWC napi module; process_dlopen(4) in the crash report) tracked as a main break
darwin 14 aarch64 test-tonic.test.ts: rustup could not choose a version of cargo to run box has no default rustup toolchain; tonic server never builds

Every code path this PR touches is gated on replace_exports being non-empty, which only the Bun.Transpiler JS API sets; the runtime transpiler constructs it as Default::default(), so the runtime path these tests go through is byte-identical to main.

test/bundler/transpiler/transpiler.test.js (this PR's tests) is not in any failure annotation.

Previously (pre-rebase)

Build 68633 on e7fd535e finished 285 passed, 1 failed, zero test-failure annotations. The single red was :darwin: 26 aarch64 - test-bun dying on buildkite-agent artifact download timed out after 120s before running a test; its sibling shard downloaded the same artifact and exited 0.

Local verification

bun bd test test/bundler/transpiler/transpiler.test.js    229 pass   0 fail
cargo check -p bun_js_parser                              no warnings

Fail-before, with src/ reverted to main: exit 1 with 34 failing assertions, each for the reason listed in the table in the PR description.

Notes for review

robobun added 5 commits July 11, 2026 04:46
…arations

exports.eliminate and exports.replace were silently ignored for
`export function f() {}` and `export class f {}` unless the unrelated
deadCodeElimination option was enabled. s_function and s_class computed one
mark_as_dead flag that gated both the usage-count suppression and the
replacement itself, and it was ANDed with dead_code_elimination. The
`export const` and `export {}` paths already applied the replacement
unconditionally and only gated the dead marking.

Split the two: should_replace_export no longer looks at dead_code_elimination,
matching visit_decls and s_export_default. It also requires that we are not
inside a namespace, since a namespace member is a property of the namespace
object rather than a module export; marking one dead emptied its body while
still emitting the statement.

Two more replace_exports paths that depended on the same flag:

- `export default class X {}` with a replacement value kept the class, because
  the class lowering below reassigned data.value after the replacement was
  written. Push the replaced statement and skip the lowering.
- `export default` with an inject entry (`["__N_SSG", true]`) emitted nothing
  when dead_code_elimination was on, because the dead check returned before the
  injection ran.

Eliminating a hoistable export now happens with dead_code_elimination off too,
which reaches the parts.pop() in _parse that assumes append_part appended a
part. That fix is the same as #33376: append_part reports whether it appended.
s_local took the same shortcut s_function and s_class did: an `export const f`
inside a namespace matched a replace_exports entry named `f` and was dropped,
taking its initializer's side effects with it and never assigning `N.f`.

Also make the fail-before for these tests a clean assertion failure rather than
a process abort: every source now leads with two statements, which turns the
hoisting bug into a visible reordering instead of a pop on an empty parts list.
The bare hoistable shapes that do abort run in a spawned child.

Add scan() assertions to the default-export cases and run the namespace case
under both deadCodeElimination settings.
The three `mark_for_replace` branches in s_export_default each re-did the
`get_ptr(b"default")` lookup with a bare unwrap. Hoist it into one macro next to
the injection helper so the invariant is stated once.

Assert stderr in the spawned transpiler test so an abort shows its panic trace,
and run those two cases concurrently.
visit_decls handles a decl without an initializer in its own branch, and that
branch read `replace_decl_and_possibly_remove`'s return value with the opposite
polarity of the branch right above it. The helper returns false for `Delete`
(leaving the decl alone) and true for `Replace`/`Inject` (which write a value
into it), so `export let f;` with `exports.eliminate` fell into `visit_decl`,
which unwraps `decl.value`, and aborted:

    panic: called `Option::unwrap()` on a `None` value

while `exports.replace` took the `continue` arm and dropped the rewritten decl,
silently removing the export instead of substituting it. Match the polarity of
the initialized branch.
@robobun
robobun force-pushed the farm/83cf3649/exports-replace-without-dce branch from e7fd535 to 897b7f6 Compare July 11, 2026 04:52

@claude claude Bot 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.

I didn't find any bugs, and the two issues I raised earlier (namespace guard on s_local, inverted polarity in visit_decls no-initializer branch) are both addressed with tests. That said, this reworks control flow across five sites in the parser visit pass (s_function, s_class, s_local, s_export_default, append_part) and changes when statements are emitted vs. dropped — worth a human look before merging.

Checked: should_replace_export split from mark_as_dead in s_function/s_class — the removed || was_export_inside_namespace disjunct is subsumed by the new enclosing_namespace_arg_ref.is_none() guard.
Checked: inject_default_replacement! on the three is_control_flow_dead early-return paths — mark_for_replace is only set for a non-replace entry, so Delete emits nothing and Inject emits the renamed var; Replace never reaches that branch.
Checked: append_part bool return — both SClass/SExportDefault callers gate the parts.pop() on it; the third caller (default arm) discards it, which is fine.
Checked: the swapped arms in visit_decls — visit_decl now only runs after Replace/Inject writes a value, so the decl.value.unwrap() in visit_decl is safe.

Extended reasoning...

Overview

This PR fixes Bun.Transpiler's exports.eliminate/exports.replace so they apply to export function/export class regardless of deadCodeElimination, plus four sibling bugs uncovered while auditing: namespace members were incorrectly matched, export let f; (no initializer) crashed on eliminate and dropped on replace, export default class + replace was undone by class lowering, and inject on export default emitted nothing under DCE. It also carries the append_part return-bool fix from #33376 since eliminating a hoistable export now reaches parts.pop() on an empty list. Five files: visit_stmt.rs (the bulk), visit/mod.rs (polarity swap), p.rs + parse_entry.rs (append_part), and ~180 lines of new tests in transpiler.test.js.

Security risks

None identified. This is transpiler output-shaping logic (which exports survive/get replaced), not auth/crypto/permissions. The replace_exports map is populated from Bun.Transpiler options controlled by the caller, not from parsed source. No new untrusted-input parsing.

Level of scrutiny

High. visit_stmt.rs is the core visitor pass — every JS/TS file Bun transpiles goes through s_function/s_class/s_local/s_export_default. The changes reorder control flow around statement emission, add early returns, and change when is_control_flow_dead gates behavior. A mistake here affects every transpiled file, not just those using exports.eliminate. The new test matrix is thorough (DCE on/off × 4 declaration forms × eliminate/replace/inject, plus default-export forms, uninitialized let/var, and namespace members), and the author verified 34 assertions fail on main for the right reasons. But the number of independent behavioral changes (five distinct bugs) and the subtlety of the mark_as_dead/should_replace_export split warrant a maintainer's read.

Other factors

  • Both of my prior inline findings (s_local namespace guard, visit_decls polarity) were fixed in follow-up commits with test rows added.
  • All CodeRabbit nits are marked addressed and resolved.
  • CI on the latest push is green except an unrelated darwin artifact-download timeout (per the author's CI-status comment; ASAN lanes covering test/bundler/transpiler/ passed).
  • The append_part hunk overlaps #33376; whichever lands first, the other rebases away.
  • The one thing I'd want a human to sanity-check is the s_export_default class branch: it now pushes *stmt and returns before class lowering runs. That's the stated intent (lowering would overwrite data.value), and the test asserts export default 42 output for export default class X {} + replace, so it's covered — but skipping lowering entirely is the kind of change worth a second pair of eyes.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up from #38287: that PR routes every statement s_export_default emits through a new append_export_default helper (it splits export default into a binding plus an export clause when a lowered top-level using wraps the module body). The stmts.push(*stmt) this PR adds to the class branch's replace path should go through that helper as well, whichever of the two lands second.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Cross reference: #38527 makes Replace entries visit the value they discard as dead code (visit_decls with-initializer branch and the setup of s_export_default). It overlaps with this PR in two places, the Replace arm of the export default class branch (same change in both) and a macro added after restore_dead! in s_export_default; whichever lands second has a small rebase there and no semantic interaction. Everything else this PR fixes (uninitialized declarations, export default inject, function / class declarations without DCE) is intentionally left to this PR.

alii pushed a commit that referenced this pull request Aug 15, 2026
### Problem
- `visit_decl` in `src/js_parser/visit/mod.rs` took three adjacent
`bool` parameters (`was_anonymous_named_expr`, `could_be_const_value`,
`could_be_macro`), and the second of its two callers passed them
positionally: `visit_decl(decl, false, was_const && !is_after, false)`.
Nothing at that call says which flag is which.
- This is the one `bare_bool_args` finding recorded for this file in
`mordant-baseline.toml`.
- The shape has already bitten once: the Zig version of this call site
passed `was_const and !is_after` in the `was_anonymous_named_expr` slot
(`src/js_parser/visit/visit.zig` before d451445); the Rust port put
it in the right slot. Two open PRs (#34089, #35548) each add a fourth
positional bool to the same call.

### Fix
- Adds `VisitDeclOpts` to `src/js_parser/parser.rs`, next to
`VisitArgsOpts`, with one documented field per former parameter. Same
shape as the existing `VisitArgsOpts` / `ParenExprOpts` option structs;
an options struct rather than three enums because
`was_anonymous_named_expr` is handed straight on to
`maybe_keep_expr_symbol_name` as a `bool`, and `dylint.toml` already
treats several-bool structs as this repo's option-bag style.
- `visit_decl` now takes `opts: VisitDeclOpts` and destructures it on
entry; the rest of its body is unchanged. Both callers build the struct
with every field named, using the same expressions as before, in the
same order.
- Removes the `"bare_bool_args:src/js_parser/visit/mod.rs" = 1` line
from `mordant-baseline.toml`. Running the baseline writer scoped to
`bun_js_parser` (`MORDANT_BASELINE_WRITE=1 cargo dylint --all -p
bun_js_parser`) rewrites the file byte for byte identical to what is
committed.
- No behavior change is intended. Verified with a debug build:
- `bun bd test test/bundler/transpiler/`: 3870 pass, 0 fail. The only
red was `jsx-production.test.ts`, whose 32 concurrent parent+child
debug-build spawns exceed the 5s per-test timeout on an 8 core box; all
32 pass with `--timeout 120000`.
- `bun bd test test/bundler/bundler_minify.test.ts
test/bundler/esbuild/dce.test.ts test/regression/issue/26360.test.ts
test/regression/issue/22656.test.ts` (keepNames, const inlining,
macros): 128 pass, 0 fail.
- `bun bd test test/bundler/bundler_edgecase.test.ts`: 134 pass, 0 fail.
- `cargo dylint --all -p bun_js_parser -- --keep-going` with the
baseline line removed: on the previous code it reports the finding
quoted below as over the baseline; on this branch it reports nothing and
`target/mordant/over-baseline.txt` is not written.
- No test is added: a signature refactor has no observable behavior to
pin, so there is nothing a new test could fail on before this change.
The existing suites above are the coverage; the lint run is what
distinguishes before from after.

### Background
- `visit_decls` visits every `decl` of a `let`/`const`/`var` statement.
For each one it visits the initializer and then calls `visit_decl`,
which does the bookkeeping that depends on facts only the caller
observed: whether the initializer was an anonymous function or class
before visiting (so the binding's name can be attached to it via
`maybe_keep_expr_symbol_name`), whether this is a `const` still inside
the scope's leading run of `const`s (so the value may be recorded in
`const_values` for inlining), and whether visiting the initializer ran a
macro (so the macro result may be recorded or destructured into
`const_values`). The three fields of `VisitDeclOpts` are those three
facts.
- The second caller runs on the `exports.replace` / `exports.eliminate`
path of `Bun.Transpiler` for a declaration with no initializer. Its
replace/delete branches are inverted today (`eliminate` on `export let
x;` hits an `unwrap` on `None`); that is pre-existing and already
addressed by #33378, so it is left alone here.
- `mordant-baseline.toml` is a ratchet: it records the per (lint, file)
counts of findings that predate the lint job, and the job fails only on
findings above those counts. Fixing a finding means deleting its line so
it cannot come back.

<details>
<summary>Lint output on the previous code with the baseline line
removed</summary>

```
warning: `visit_decl` takes bools `was_anonymous_named_expr`, `could_be_const_value` and `could_be_macro`, and 1 of its 2 calls passes bare `true`/`false` for at least two of them. At `visit_decl(.., false, .., false)` nothing says which is which
   --> src/js_parser/visit/mod.rs:517:19
    |
517 |     pub(crate) fn visit_decl(
    |                   ^^^^^^^^^^
    |
note: one of those calls
   --> src/js_parser/visit/mod.rs:417:29
    |
417 | ...                   self.visit_decl(decl, false, was_const && !is_after, false);
    |                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    = help: give `was_anonymous_named_expr`, `could_be_const_value` and `could_be_macro` a two-variant enum each, or an options struct, so every call names what it sets
    = note: `bare_bool_args` over the mordant baseline (0 recorded for src/js_parser/visit/mod.rs)

warning: mordant: 1 finding(s) over the baseline in bun_js_parser
```

On this branch the same command finishes with no warnings.
</details>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant