Skip to content

js_parser: stop storing Property under a symbol ref in the namespace export loop - #47001

Open
robobun wants to merge 6 commits into
mainfrom
robobun/a956a391/namespace-merged-fn-class-enum-fold
Open

robobun wants to merge 6 commits into
mainfrom
robobun/a956a391/namespace-merged-fn-class-enum-fold

Conversation

@robobun

@robobun robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A namespace merged with an exported function or class of its block loses its members. This throws ReferenceError: x is not defined:
    namespace F { export function fn() {} export namespace fn { export const x = 1 } }
    namespace F { export namespace fn { console.log(x) } }
  • In namespace G { export enum Y { P = F.fn.E.A, R } }, R is undefined.
  • The export loop of parse_type_script_namespace_stmt (src/js_parser/parse/parse_typescript.rs:298, :316) stored Property over the namespace's member map.

Fix

  • Remove the three stores of Property into ref_to_ts_namespace_member. No reader of that map matches Property.
  • The printer parenthesizes a negative inlined enum member left of **. The new fold can reach that SyntaxError.
  • Verified: test/bundler/transpiler/transpiler.test.js (22 new tests fail on main) and test/bundler/bundler_edgecase.test.ts (2). Self-reviewed: 11 concerns raised, 10 addressed.

Background

  • A function or class and a namespace of the same name share one symbol. ref_to_ts_namespace_member maps it to the namespace members.
  • esbuild 0.28.2 has the same stores (ts_parser.go:1690, :1701, :1745) and output. tsc is the reference.
  • Considered a guard in each arm, or one helper for all arms. Both keep a store that nothing reads.

Downsides

Notes

Where it comes from. No user reported it. The shape was found in the review of #46938, which lists it as not covered. #12144 ported this loop with an assert that the record is a namespace. #12425 crashed on that assert with this source shape. #12610 removed the assert and kept the store.

Reach. 0 of 121,210 transpiler outputs change: 20,289 units of the TypeScript test cases in test/cli/check/typescript-go/bundle.zst and the 3,953 tracked .ts, .tsx, .mts and .cts files, each under five Bun.Transpiler option sets (default, minify.syntax, all of minify, treeShaking with trimUnusedImports, inline with target bun).

The printer line.

Landing order. No pull request has to land first. If #36133, #33447, #46938 or #46693 lands before this one, the printer commit is dropped on rebase. #46938, #46693, #46553 and #33447 also take EXPECTED_VERSION 44, so each one that lands later moves up by one.

Not in this change. Each line is the behavior of a plain namespace on main.

Output text that changes for code that works. F.fn.E.A and a same-block fn.E.A print as 7 /* A */. A program that replaces F.fn at run time reads the old value, as with a plain namespace. F["fn"]() prints as F.fn(). A negative member left of ** prints as (-3) /* A */ ** n.

Measurements. Release builds of main 449877f and this branch in one container. Instructions are user-mode counts from perf_event_open (perf, valgrind, bloaty and strace are not installed there). Call counts are gdb breakpoint counts on the map functions.

Input main this PR
.text (size) 107,582,646 B 107,581,110 B (-1,536)
parse_type_script_namespace_stmt (two instantiations) 4,526 B 3,829 B
define_exported_namespace_binding 414 B 269 B
print_expr (five instantiations) +21 to +32 B each
2,000 namespace blocks with an exported function, class, variable, namespace and enum: map inserts per block 11 5
the same: map lookups per block 2 2
src/js/node/dns.ts (three declare namespace with export let): map inserts 3 0
src/js/node/dns.ts: instructions per transform (2,000 transforms, 5 interleaved rounds, spread under 1,100) 3,775,146 3,754,188 (-0.556%)
src/js/node/http2.ts (no namespace), 200 transforms 4,962,919,398 4,962,552,848 (-0.007%, run-to-run spread 1,439,323)
  • 1,626 units of the TypeScript test cases that contain a namespace and transform without an error, 5 interleaved rounds of 2,000 transforms: mean -281 instructions per transform (mean cost 158,923). 620 units are faster in all rounds. 24 are slower in all rounds. 23 of those are slower by at most 130 instructions (at most 0.31%).
  • The one unit outside the spread is generatedContextualTyping.ts: +69,436 (+1.31%). Its map holds 24 entries where main holds 36, so the table grows 3 times, not 4, and its 773 lookups probe longer.
  • No symbol other than the three in the table changes size.
  • A matrix of 68 expression contexts and 6 namespace shapes (408 programs, run under node against the output of ts.transpileModule): main differs from tsc in 12 programs, this branch in 0. With minify.syntax: 9 and 0.
  • The two-arm deletion alone against the whole parser change (debug builds): 0 differences over the same 121,210 outputs.
  • Runtime transpiler cache, a project of four 10 KiB files: the first run of this build writes 4 of 4 entries again (version 43 to 44) and prints the right result where main throws. The second run writes 0.

Self-review. 11 concerns raised, 10 addressed.

  • The printer line has its own bullet, and the text says what it changes for a plain enum.
  • The esbuild output for the plain enum form is stated, because a maintainer asked about it on js_printer: parenthesize inlined negative enum value on LHS of ** #36133.
  • The pull requests that carry the same printer line are named, with the landing order.
  • The change from a ReferenceError to undefined for a bare enum read of a later block is stated.
  • The reach is stated, and the second example in Problem is a form that this change repairs.
  • The rows that a differential run against tsc found are in the table above.
  • The printer line has a test that does not depend on the namespace fold (exponentiation of an inlined enum member).
  • Four it.todo rows hold the tsc values for the later-block forms.
  • The bundled write that no longer fails loudly is stated.
  • The read under a binding with the name of the namespace is stated, with its owner.
  • Not done: a tracking issue for the import X = F.fn form. It is a separate defect and is named above.

Tests. transpiler.test.js has 23 new tests and 4 it.todo rows. 22 of the 23 fail on main, and the control passes. Without the printer commit, 2 of them fail. bundler_edgecase.test.ts has 2 new tests, and both fail on main with ReferenceError: Z is not defined.

Also ran (debug build, all pass): the rest of transpiler.test.js and bundler_edgecase.test.ts, esbuild/{ts,dce,importstar_ts,tsconfig}, bundler_{merged_enum,decorator_metadata,minify,regressions,naming}, transpiler/{decorator-metadata,decorators,runtime-transpiler,ts-enum-redecl-panic,template-literal,scope-mismatch-panic}, cli/run/transpiler-cache, regression/issue/{25648,28170,13251}. cargo clippy -p bun_js_parser -p bun_js_printer is clean. test/internal/source-lints: 180 pass. The 3 tests of sema-parser-recursion.test.ts time out at 5 s on the debug build and pass on the release build.

…e export loop

A namespace that merges with an exported function or class of the same
block registers its member map under the symbol of that function or
class. The export loop of `parse_type_script_namespace_stmt` then stored
`Property` under the same symbol and replaced the map. A later block of
the namespace got an empty map, and a read of an enum member through
the merged name did not fold.

No reader of `ref_to_ts_namespace_member` matches `Property`. Remove the
three stores: the function arm, the class arm, and
`define_exported_namespace_binding`.
…s not write it

The namespace arm and the enum arm stored the record that they just read
back under the same key. Remove both stores. `ts::Data` is `Copy`, so
`clone_ts_member_data` has no caller left.
`enum E { A = -3 }` followed by `E.A ** n` printed `-3 ** n`, which is
a SyntaxError. The `**` arm now looks through the inlined enum to the
number.

A read of an enum member through a namespace that merges with an
exported function or class folds since the two parser commits before
this one, so it reaches the same arm.
A later block of a namespace that merges with an exported function or
class now gets the members of the first block. It has the gaps that a
later block of a plain namespace has: a write to an export of the first
block, a read under a binding with the name of the namespace, and a bare
read of an enum of the first block. Four `it.todo` rows hold the values
that tsc gives.
@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main 449877f and on bun 1.4.3. Save this as a.ts and run bun a.ts:

namespace F { export function fn() {} export namespace fn { export const x = 1 } }
namespace F { export namespace fn { console.log(x) } }

bun throws ReferenceError: x is not defined. The output of ts.transpileModule prints 1. With this branch, bun prints 1.

Pull request: #47001. The tests that fail on main are in test/bundler/transpiler/transpiler.test.js and test/bundler/bundler_edgecase.test.ts.

@robobun

robobun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:13 PM PT - Oct 26th, 2026

✅ @robobun, your commit 97999edd3066160539045c6dd49edb26ce712872 passed in Build #133203! 🎉


🧪   To try this PR locally:

bunx bun-pr 47001

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

bun-47001 --bun

@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between b8600d2 and 97999ed.

Files selected for processing: 1
  • test/bundler/transpiler/transpiler.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

Walkthrough

The parser changes how exported members are retained when TypeScript namespaces merge with functions or classes. The printer checks unwrapped enum values when formatting exponentiation expressions. New bundler and transpiler tests cover these changes.

Changes

TypeScript namespace merging

Layer / File(s) Summary
Retain members across namespace merges
src/js_parser/p.rs, src/js_parser/parse/parse_typescript.rs, src/jsc/RuntimeTranspilerCache.rs, test/bundler/bundler_edgecase.test.ts, test/bundler/transpiler/transpiler.test.js
The parser no longer stores exported function and class bindings as Property entries in the namespace reference map. It copies stored namespace member data into parent namespaces. Tests cover later namespace blocks, enum values, runtime execution, and unchanged handling of calls, constructions, and non-enum properties. Four follow-up cases remain marked todo. The runtime transpiler cache version increases to 44.
Check unwrapped enum values for exponentiation
src/js_printer/lib.rs, test/bundler/transpiler/transpiler.test.js
The printer applies exponentiation precedence rules to the unwrapped left operand. Tests expect negative inlined enum values to be parenthesized before **, while positive values remain unparenthesized.

Suggested reviewers: jarred-sumner, alii

Priority: Normal

Merge Risk: Minimal · up to 97999

No actionable merge-blocking risk was found in the reviewed changes; normal test checks remain appropriate.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: stopping Property entries from being stored under a symbol reference in the namespace export loop. It is specific and related to the changeset.
Description check Passed The description provides a detailed problem statement, fix, scope, known limitations, performance data, and verification results. Although it does not use the template headings exactly, it fully cover…
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.
Full details: Description check

Explanation

The description provides a detailed problem statement, fix, scope, known limitations, performance data, and verification results. Although it does not use the template headings exactly, it fully covers what the PR does and how it was verified.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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 findings, I also checked that no reader of ref_to_ts_namespace_member branches on Property (the only remaining Property constructions are the three exported_members.put calls, so the deleted stores had no consumer for non-merged bindings), that unwrap_inlined() returns the expression unchanged for anything other than EInlinedEnum (the ** printer change is a no-op for existing shapes), and that the scope-snapshot rollback in src/js_parser/p.rs still prunes stale map entries, so the Copy read-only arms do not change rollback behavior.

Extended reasoning...

The change removes three Property inserts into the parser's namespace-member map, makes the SNamespace/SEnum export arms read-only, adds a ** left-operand parenthesization for inlined negative enum members in the printer, and bumps the runtime transpiler cache version; it touches no security-sensitive surface. The inline findings cover the semantic deviations from tsc in later merged-namespace blocks and test nits, so a human look is already signaled; this note only records what else was examined.

Comment thread src/js_parser/parse/parse_typescript.rs Resolved
Comment thread src/js_parser/parse/parse_typescript.rs Resolved
Comment thread src/js_printer/lib.rs Resolved
The spawned `bun -e` check now compares stdout, stderr and the exit code
in one assertion. Each `it.todo` row of a later block has a comment with
the value that bun gives today.

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

The latest push addresses the two nits from my earlier pass (combined stdout/stderr/exitCode assertion, observed value on the fourth todo); I re-ran the review on this version and found no new bugs, but since this is a deliberate deviation from esbuild in namespace-merge semantics, a maintainer should still make the call on the later-block trade-off already discussed inline.

What was reviewed this run:

  • Every reader of ref_to_ts_namespace_member (p.rs:2225, :2289, :4734, :9437 and the three enum sites in visit_stmt.rs) matches only Namespace/Enum* variants, so the three deleted Property stores had no consumer and their removal only stops overwriting a merged namespace's map.
  • The ** left-operand check now goes through unwrap_inlined(); Expr is by-value so no borrow issue, and the arms below it are unchanged.
  • EXPECTED_VERSION is bumped for the changed transpiler output; no CODEOWNERS entry covers the changed paths.
Extended reasoning...

The change deletes three inserts of ts::Data::Property into ref_to_ts_namespace_member in src/js_parser/p.rs and src/js_parser/parse/parse_typescript.rs, drops a now-unused clone helper, makes the ** parenthesization check in src/js_printer/lib.rs look through EInlinedEnum, bumps the runtime transpiler cache version to 44, and adds bundler and transpiler tests. It touches no security-sensitive surface. The source diff is small and I confirmed no reader of the map matches Property, but the fix intentionally diverges from esbuild and extends the pre-existing plain-namespace later-block gaps (silent wrong value where main threw) to function/class-merged namespaces, which the author documents as todos; that trade-off is a maintainer judgment, so defer rather than approve. The newest commit only addressed test nits; the substantive inline threads from the prior run remain open with no reply.

robobun added a commit that referenced this pull request Nov 1, 2026
…now does

The build for --aot asks the checker which enum reads the transpiler
replaces by values (resolve_member_path). The checker left a bare name
that another part of the namespace exports to run time, as the
transpiler did. The transpiler follows that name now, so the checker
does too. Without this an --aot executable and plain Bun printed
different values in
test/aot/parallel/enums/enums-namespaces-read-paths-brackets.ts.

is_declared_around is gone. It kept three other names out as well:
- The variable that a function of a block leaves behind in sloppy code.
  The first name must now be bound at run time to an enum or a
  namespace, and that keeps this one out.
- An enum of a switch clause that a later clause reads, and an enum
  that is the body of an if or a loop. The transpiler puts their values
  in place, so the checker agrees with it there now.

One name is followed that the transpiler does not follow yet: a
namespace that shares a block with an exported function or class of its
name, read by the bare name from another part. The checker already
follows it by the full path and in its own block. #47001 changes the
transpiler, and a program with a Todo line holds the difference until
then.

Tests: three fixtures of test/aot/checker-queries, two programs of
test/aot/parallel/enums, and a line of the minifying measure for
ts/SiblingNamespaceEnumShadowedArgument, where bun run reads M.Z under
a parameter M (#41984). Two programs of test/aot/parallel/enums said in
their first line that plain Bun differs. Each line says what holds now.

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