Skip to content

js_parser: fix exports.eliminate/replace on a declaration with no initializer and on namespace members - #44179

Closed
robobun wants to merge 4 commits into
farm/368105d9/transpiler-replace-string-arenafrom
robobun/a2625c90/export-no-initializer
Closed

robobun wants to merge 4 commits into
farm/368105d9/transpiler-replace-string-arenafrom
robobun/a2625c90/export-no-initializer

Conversation

@robobun

@robobun robobun commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • exports.eliminate aborts the process for an exported declaration with no initializer. export let A with eliminate: ["A"] ends in panic: called `Option::unwrap()` on a `None` value.
  • exports.replace drops the export: export let A with replace: { A: 1 } prints an empty module.
  • Cause: the arm of visit_decls for a declaration with no value (src/js_parser/visit/mod.rs:601) reads the result of replace_decl_and_possibly_remove the wrong way round, and visit_decl unwraps the missing value.

Fix

  • The arm drops the declaration when the helper returns false and visits it otherwise, as the arm with an initializer does.
  • A member of a TypeScript namespace is not an export of the module. visit_decls, s_function and s_class no longer apply an entry to it.
  • Verified: test/bundler/transpiler/transpiler.test.js, 4 new tests. All 4 fail without the src/ change. The file passes.
  • Self-reviewed: the stack on Bun.Transpiler: allocate exports.replace string values in the instance arena #38541 and the rows with a string value come from that review.

Background

Downsides

  • Two outputs of main change for callers that rely on them: replace: { A: 1 } on export let A prints export let A = 1;, and an entry no longer changes a member of a namespace.
  • No exports option: release .text has the same size, and 0 instructions are added.
Notes

Found by fuzzing. There is no issue and no user report.

Repro

new Bun.Transpiler({ loader: "ts", exports: { eliminate: ["A"] } }).transformSync("export let A");
Build eliminate: ["A"] replace: { A: 1 }
1.3.13, 1.3.14 Segmentation fault at address 0x8
1.4.0, 1.4.2, main a4f1429 panic: called `Option::unwrap()` on a `None` value, exit 134 prints an empty module
this PR prints an empty module prints export let A = 1;

The Zig source had the same arm, so the logic was never correct, and the tests are not in test/regression/issue.

Outputs that change (27 inputs, ts loader, release builds of the base a90fada and of e87d32d)

9 inputs abort on the base and print a module now. 12 print other text. 6 print the same text.

Input Option base this PR
export let A eliminate: ["A"] abort empty module
export let A, B = 1 eliminate: ["A"] abort export let B = 1;
export let A; A = 1; eliminate: ["A"] abort A = 1;
namespace NS { export let A } eliminate: ["A"] abort the namespace with no member
export let A replace: { A: 1 } empty module export let A = 1;
export let A replace: { A: "bar" } empty module export let A = "bar";
export let A replace: { A: ["N", 1] } empty module export let N = 1;
export let A, B, C replace: { B: 1 } export let A; and export let C; export let A;, export let B = 1; and export let C;
export let A; A = 2; replace: { A: 1 } A = 2; export let A = 1; and A = 2;
namespace NS { export let A = 1 } eliminate: ["A"] the namespace with no member NS.A = 1
namespace NS { export let A = 1 } replace: { A: 2 } NS.A = 2 NS.A = 1
namespace NS { export let A = 1 } replace: { A: ["N", 2] } NS.N = 2 NS.A = 1
namespace NS { export function A() { return f() } } eliminate: ["A"] function A() {} the function with its body
namespace NS { export class K { m() { return f() } } } replace: { K: 1 } m() {} the class with the body of m

A member of a namespace

s_local selects visit_decls::<true> for a member of a namespace too, because its gate tests is_export and the size of the map only. s_function and s_class visited such a member as dead code and then kept the statement, so its body was empty. A member is a property of the namespace object. It is not an export of the module, so no entry applies to it.

visit_decls does the test once, and only in the instantiation for true. The first version had the test in s_local, where the visit is selected. That cost s_local 3 instructions for each statement with no exports option (188,400 to 192,000 for 1100 statements), so it moved.

The test with a string and #38541

replace with a string, after the load of another module loads a CommonJS file of 50 lines between new Bun.Transpiler and transformSync. Without #38541 the string value of the entry is a freed node at that time. On main the path with no initializer never read the value, because it dropped the export. With this PR it reads the value, so this PR is on top of #38541.

Measurements (release builds of the base a90fada and of e87d32d, linux x64)

What Result
.text 58,143,477 B on both (llvm-size -A)
s_local 7004 to 7049 B (ts), 7084 to 7129 B (js) (nm -S)
s_function, s_class 3043 to 3071 B, 1162 to 1172 B, both instantiations
Instructions with no exports option s_local: 188,400 on both for 1100 statements (ts), 164,091 on both for 1003 (js). s_function: 10,284 on both (ts), 6700 on both (js). s_class: 6430 on both (ts), 3750 on both (js) (gdb step count, JIT off)
visit_decl, visit_binding, replace_decl_and_possibly_remove 0 of 6 function bodies differ (normalized objdump, sha256)

valgrind, strace and perf are not on the machine that ran this, so the instruction counts come from gdb.

#33378

#33378 has the same swap among other changes: it applies the entries to function and class declarations when dead code elimination is off. It conflicts with main since August. This PR is the part for a declaration with no initializer. The hunk of #33378 in visit_decls becomes empty when it rebases over this PR.

Left for later

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I traced the inverted return handling against replace_decl_and_possibly_remove (src/js_parser/p.rs:6832): Delete returns false and the decl is dropped; Replace/Inject set decl.value = Some(..) before returning true, so the fall-through into visit_decl no longer unwraps a None. The .expect("unreachable") on declare_symbol for an Inject name (p.rs:6854) was already reached by the old no-initializer arm and by the with-initializer arm, so this change does not make it newly reachable.

Extended reasoning...

The diff is a small control-flow fix in the exported-declaration branch of visit_decls in src/js_parser/visit/mod.rs plus three new tests in test/bundler/transpiler/transpiler.test.js; it touches no security-sensitive surface. The inline finding about the sibling with-initializer arm lacking the namespace guard is the reason a human should weigh the scope, so this note only records what else was checked and ruled out.

Comment thread src/js_parser/visit/mod.rs Outdated
@robobun

robobun commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: open at e87d32d, on top of #38541 (a90fada). Waits for CI and review.

Reproduced on main (a4f1429) and on Bun 1.4.2:

new Bun.Transpiler({ loader: "ts", exports: { eliminate: ["A"] } }).transformSync("export let A");
// panic: called `Option::unwrap()` on a `None` value, exit 134

Verification with bun bd test test/bundler/transpiler/transpiler.test.js -t "an export with no initializer":

src/ from the base (main + #38541):  0 pass, 4 fail   (the child process aborts with the panic above)
src/ from this PR:                   4 pass, 0 fail   (full file: 242 pass, 0 fail)

When #38541 merges, I rebase this PR onto main and change its base.

…ith no initializer

The arm of visit_decls for a declaration with no initializer read the
result of replace_decl_and_possibly_remove the wrong way round. An
eliminated export went to visit_decl, which unwraps the missing value
and aborts the process. A replaced export was dropped from the module.

The arm now does what the arm for a declaration with an initializer
does: drop the declaration when the helper returns false, visit it
otherwise. A namespace member with no value is left alone, so that it
prints what it prints today.
…namespace alone

s_local selected the visit that applies the entries for each exported
declaration, and a member of a TypeScript namespace is one. So
`namespace NS { export let A = 1 }` lost `NS.A` with
`eliminate: ["A"]`, and got `NS.A = 2` with `replace: { A: 2 }`. The
visitors of a function and of a class skip a member of a namespace.

s_local now selects that visit at module level only. The guard in the
arm for a declaration with no initializer is not necessary any more.
@robobun
robobun force-pushed the robobun/a2625c90/export-no-initializer branch from 82db491 to 1eed084 Compare September 29, 2026 01:31
@robobun

robobun commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:50 PM PT - Sep 28th, 2026

✅ @robobun, your commit e87d32d6a829bbba3a6aefb6ec548a8967df0fdc passed in Build #121484! 🎉


🧪   To try this PR locally:

bunx bun-pr 44179

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

bun-44179 --bun

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the polarity fix itself: replace_decl_and_possibly_remove already runs visit_expr on the Replace/Inject value and visit_decl only does const-value/name bookkeeping, so the no-initializer arm does not visit the value twice, and a Delete entry now hits continue 'outer before the decl.value.unwrap() in visit_decl. Moving the namespace exclusion into the visit_decls::<true> selection in s_local covers both arms of visit_decls, and the namespace assignment loop below it still sees the decls untouched.

Extended reasoning...

The change flips the boolean read of replace_decl_and_possibly_remove in the no-initializer arm of visit_decls and gates the replace/eliminate-aware path in s_local on not being inside a TypeScript namespace, plus table-driven transpiler tests; no security-sensitive surface. Not approved because a verified finding on the s_function/s_class siblings is being posted inline and further verified findings were dropped from the post.

Comment thread src/js_parser/visit/visit_stmt.rs Outdated
…that is a member of a namespace

s_function and s_class visited a member of a namespace as dead code
when its name was in the entries, and then kept the statement. So
`namespace NS { export function A() { return f() } }` with
`eliminate: ["A"]` printed `function A() {}`.

Both now look at the entries at module level only, as s_local does.

The tests of the declaration with no initializer run in one child
process.

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

Code review found no issues

No high-confidence issues detected in this change.

The test in s_local, where the visit is selected, cost s_local 3
instructions for each statement with no exports option. visit_decls
now does the test once, and only in the instantiation that applies the
entries.
@robobun robobun changed the title js_parser: fix exports.eliminate/replace on an exported declaration with no initializer js_parser: fix exports.eliminate/replace on a declaration with no initializer and on namespace members Sep 29, 2026

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: consolidated into #44137 on request, so that there is one PR and no stack. #44137 has the four commits of this PR unchanged (the swap of the arm in visit_decls, and the three commits for a member of a namespace) and their tests.

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.

2 participants