From 75fa8606e31093e522ba6516226a605358fcc2f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:03:39 +0000 Subject: [PATCH 1/6] js_parser: do not store `Property` under a symbol ref in the namespace 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`. --- src/js_parser/p.rs | 3 +- src/js_parser/parse/parse_typescript.rs | 4 -- src/jsc/RuntimeTranspilerCache.rs | 3 +- test/bundler/bundler_edgecase.test.ts | 66 ++++++++++++++++++++ test/bundler/transpiler/transpiler.test.js | 70 ++++++++++++++++++++++ 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index d981eb71862d..31928258caee 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -694,6 +694,7 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// In addition, there is a map from each relevant symbol reference to the data /// associated with that namespace or namespace member: "ref_to_ts_namespace_member". /// This gives enough info to be able to resolve queries into the namespace. + /// Never `Property`: storing it, as esbuild does, replaces the record of a merged namespace. pub(crate) ref_to_ts_namespace_member: HashMap, /// Arena-allocated `TSNamespaceScope`s; their global-heap maps are freed in `Drop`. pub(crate) ts_namespace_scopes: Vec>, @@ -4618,8 +4619,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O data: js_ast::ts::Data::Property, }, )?; - self.ref_to_ts_namespace_member - .insert(id.r#ref, js_ast::ts::Data::Property); } js_ast::b::B::BObject(obj) => { for prop in obj.properties.slice() { diff --git a/src/js_parser/parse/parse_typescript.rs b/src/js_parser/parse/parse_typescript.rs index eb4d49f92c69..5a38bb80a2e3 100644 --- a/src/js_parser/parse/parse_typescript.rs +++ b/src/js_parser/parse/parse_typescript.rs @@ -295,8 +295,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O data: TSNamespaceMemberData::Property, }, )?; - p.ref_to_ts_namespace_member - .insert(ref_, TSNamespaceMemberData::Property); } } StmtData::SClass(class) => { @@ -313,8 +311,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O data: TSNamespaceMemberData::Property, }, )?; - p.ref_to_ts_namespace_member - .insert(ref_, TSNamespaceMemberData::Property); } } StmtData::SNamespace(ns) => { diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 8d40a1bcfd28..4542bfb11006 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -77,7 +77,8 @@ bun_core::declare_scope!(cache, visible); /// Version 42: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. /// Version 43: A `bun:` specifier that names no builtin is printed as it is written. -const EXPECTED_VERSION: u32 = 43; +/// Version 44: A TypeScript namespace keeps its members when it merges with an exported function or class. +const EXPECTED_VERSION: u32 = 44; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index d6cfb2141007..6276b7ee9f64 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -1931,6 +1931,72 @@ describe("bundler", () => { stdout: "{\n Y: [class Y],\n} 2 1", }, }); + itBundled("edgecase/TypeScriptNamespaceSiblingFunctionLaterBlock", { + files: { + "/entry.ts": ` + namespace X { + export function Y() { + return 2; + } + export namespace Y { + export const Z = 1; + export enum E { + A = 7, + } + } + } + namespace X { + export namespace Y { + export const W = Z + 1; + } + } + namespace N { + export enum Q { + P = X.Y.E.A, + R, + } + } + console.log(X.Y(), X.Y.Z, X.Y.W, N.Q.P, N.Q.R); + `, + }, + run: { + stdout: "2 1 2 7 8", + }, + }); + itBundled("edgecase/TypeScriptNamespaceSiblingClassLaterBlock", { + files: { + "/entry.ts": ` + namespace X { + export class Y { + toJSON() { + return 2; + } + } + export namespace Y { + export const Z = 1; + export enum E { + A = 7, + } + } + } + namespace X { + export namespace Y { + export const W = Z + 1; + } + } + namespace N { + export enum Q { + P = X.Y.E.A, + R, + } + } + console.log(new X.Y().toJSON(), X.Y.Z, X.Y.W, N.Q.P, N.Q.R); + `, + }, + run: { + stdout: "2 1 2 7 8", + }, + }); itBundled("edgecase/TypeScriptNamespaceSiblingEnum", { files: { "/entry.ts": ` diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 4724a1bd2a31..ede2355f0b86 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2163,6 +2163,76 @@ export default class { ts.expectPrinted_(input3, output3); }); + describe("a namespace that merges with an exported function or class of its block", () => { + // Runs the output and returns the value of `result`. + const run = (code, result) => new Function(`${ts.parsed(code)};\nreturn ${result};`)(); + + describe.each([ + ["function", "export function fn() {}"], + ["async function", "export async function fn() {}"], + ["generator function", "export function* fn() {}"], + ["function with overloads", "export function fn(a: string): void; export function fn(a?: any) {}"], + ["class", "export class fn {}"], + ["abstract class", "export abstract class fn {}"], + ])("after an exported %s", (_, declaration) => { + const pair = `${declaration} export namespace fn { export const x = 1; export enum E { A = 7 } }`; + + it("a later block of the namespace reads a member of the first block", () => { + // The outer `x` is not the one that the later block names. + const code = `var x = "outer";\nnamespace F { ${pair} }\nnamespace F { export namespace fn { export const y = x } }`; + expect(ts.parsed(code)).toContain("fn.y = fn.x;"); + expect(run(code, "F.fn.y")).toBe(1); + }); + + it("an enum of another namespace counts on from an enum member", () => { + const code = `namespace F { ${pair} }\nnamespace G { export enum Y { P = F.fn.E.A, R } }`; + expect(run(code, "G.Y")).toEqual({ P: 7, R: 8, 7: "P", 8: "R" }); + }); + + it("an enum of a later namespace in the same block counts on from an enum member", () => { + const code = `namespace F { ${pair} export namespace G { export enum Y { P = fn.E.A, R } } }`; + expect(run(code, "F.G.Y")).toEqual({ P: 7, R: 8, 7: "P", 8: "R" }); + }); + }); + + it("prints the value of an enum member", () => { + const out = ts.parsed( + "namespace F { export function fn() {} export namespace fn { export enum E { A = 7, B = 'b' } } export const a = [fn.E.A, fn.E.B] }\nexport const b = [F.fn.E.A, F.fn.E.B];", + ); + expect(out).toContain(`F.a = [7 /* A */, "b" /* B */];`); + expect(out).toEndWith(`export const b = [7 /* A */, "b" /* B */]`); + }); + + it("a later block reads a member of the first block when bun runs the file", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `namespace F { export class fn {} export namespace fn { export const x = 1 } } + namespace F { export namespace fn { console.log(x) } }`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, exitCode }).toEqual({ stdout: "1\n", exitCode: 0 }); + void stderr; + }); + + // Control: only an enum member folds. These reads print the same without the members. + it("prints a call, a construction and a member that is not an enum as before", () => { + const out = ts.parsed( + "namespace F { export function fn() {} export class C { static s = 1 } export function plain() {} export namespace fn { export const v = 1 } export namespace C { export const w = 1 } }\nexport const a = [F.fn(), new F.C(), F.C.s, F.plain(), F.fn.v, F.C.w, typeof F.fn, F.fn.name];", + ); + expect(out).toEndWith( + "export const a = [F.fn(), new F.C, F.C.s, F.plain(), F.fn.v, F.C.w, typeof F.fn, F.fn.name]", + ); + }); + }); + const input4 = `export enum x { y }`; const output4 = `export var x; ((x) => { From eddee5c2609cc1976d1c4129f4e6ddb1dbcf9235 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:06:23 +0000 Subject: [PATCH 2/6] js_parser: the namespace export loop reads the symbol ref map and does 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. --- src/js_parser/parse/parse_typescript.rs | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/src/js_parser/parse/parse_typescript.rs b/src/js_parser/parse/parse_typescript.rs index 5a38bb80a2e3..a21859fbbae1 100644 --- a/src/js_parser/parse/parse_typescript.rs +++ b/src/js_parser/parse/parse_typescript.rs @@ -18,19 +18,6 @@ use bun_ast::{ }; use bun_core::strings; -// `ts::Data` carries only Copy payloads but lacks a `derive(Clone)` upstream; -// local helper so we can re-insert values fetched from `ref_to_ts_namespace_member`. -#[inline] -fn clone_ts_member_data(d: &TSNamespaceMemberData) -> TSNamespaceMemberData { - match d { - TSNamespaceMemberData::Property => TSNamespaceMemberData::Property, - TSNamespaceMemberData::Namespace(m) => TSNamespaceMemberData::Namespace(*m), - TSNamespaceMemberData::EnumNumber(n) => TSNamespaceMemberData::EnumNumber(*n), - TSNamespaceMemberData::EnumString(s) => TSNamespaceMemberData::EnumString(*s), - TSNamespaceMemberData::EnumProperty => TSNamespaceMemberData::EnumProperty, - } -} - impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { pub(crate) fn parse_type_script_decorators(&mut self) -> Result { let p = self; @@ -316,38 +303,34 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O StmtData::SNamespace(ns) => { if ns.is_export { let ref_ = ns.name.ref_; - if let Some(member_data) = p.ref_to_ts_namespace_member.get(&ref_) { - let member_data = clone_ts_member_data(member_data); + if let Some(&data) = p.ref_to_ts_namespace_member.get(&ref_) { // SAFETY: original_name is arena-owned, valid for 'a. let ns_name: &[u8] = p.symbols[ref_.inner_index() as usize].original_name.slice(); exported_members.put( ns_name, TSNamespaceMember { - data: clone_ts_member_data(&member_data), + data, loc: ns.name.loc, }, )?; - p.ref_to_ts_namespace_member.insert(ref_, member_data); } } } StmtData::SEnum(ns) => { if ns.is_export { let ref_ = ns.name.ref_; - if let Some(member_data) = p.ref_to_ts_namespace_member.get(&ref_) { - let member_data = clone_ts_member_data(member_data); + if let Some(&data) = p.ref_to_ts_namespace_member.get(&ref_) { // SAFETY: original_name is arena-owned, valid for 'a. let enum_name: &[u8] = p.symbols[ref_.inner_index() as usize].original_name.slice(); exported_members.put( enum_name, TSNamespaceMember { - data: clone_ts_member_data(&member_data), + data, loc: ns.name.loc, }, )?; - p.ref_to_ts_namespace_member.insert(ref_, member_data); } } } From 74776c7b23b77b8753437e51666031799eafc8e4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:29:31 +0000 Subject: [PATCH 3/6] js_printer: parenthesize a negative inlined enum member left of `**` `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. --- src/js_printer/lib.rs | 3 ++- test/bundler/transpiler/transpiler.test.js | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 3366c8906499..4eafbca4a3ce 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1809,7 +1809,8 @@ pub(crate) mod __gated_printer { } // "**" can't contain certain unary expressions Op::Code::BinPow => { - match &e.left.data { + // An inlined enum member prints as its value, which can be a negative number. + match e.left.unwrap_inlined().data { ExprData::EUnary(left) => { if Op::Code::unary_assign_target(left.op) == js_ast::AssignTarget::None { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index ede2355f0b86..d2c489f0d458 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2203,6 +2203,13 @@ export default class { expect(out).toEndWith(`export const b = [7 /* A */, "b" /* B */]`); }); + it("a negative enum member left of ** is in parentheses", () => { + const code = + "namespace F { export function fn() {} export namespace fn { export enum E { A = -3 } } }\nnamespace G { export const f = (n: number) => F.fn.E.A ** n }"; + expect(ts.parsed(code)).toContain("G.f = (n) => (-3) /* A */ ** n;"); + expect(run(code, "G.f(2)")).toBe(9); + }); + it("a later block reads a member of the first block when bun runs the file", async () => { await using proc = Bun.spawn({ cmd: [ @@ -3406,6 +3413,14 @@ console.log(
);`), expectParseError("await -x ** 0", "Unexpected **"); }); + it("exponentiation of an inlined enum member", () => { + ts.expectPrinted_( + "enum E { A = -1, B = 2 }\nx = [E.A ** 2, E['A'] ** 2, E.B ** 2];", + 'var E;\n((E) => {\n E[E["A"] = -1] = "A";\n E[E["B"] = 2] = "B";\n})(E ||= {});\n' + + "x = [(-1) /* A */ ** 2, (-1) /* A */ ** 2, 2 /* B */ ** 2];\n", + ); + }); + it("for-of loop variable named async", () => { // "\u0061sync" is the identifier `async`, which is legal as a for-of loop // variable, but printing it as the raw token sequence `async of` is a From b8600d2fee2f7a84a62fdd3aed3790b9d09dcfd4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:36:17 +0000 Subject: [PATCH 4/6] test: hold the tsc result for a later block of a merged namespace 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. --- test/bundler/transpiler/transpiler.test.js | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index d2c489f0d458..42a1b97d4c20 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2238,6 +2238,36 @@ export default class { "export const a = [F.fn(), new F.C, F.C.s, F.plain(), F.fn.v, F.C.w, typeof F.fn, F.fn.name]", ); }); + + // A later block of the merged namespace now has the gaps that a later block of a plain + // namespace has. Each row holds the value that tsc gives. + describe("a later block of the namespace", () => { + const merged = members => `namespace F { export function fn() {} export namespace fn { ${members} } }\n`; + const later = body => `namespace F { export namespace fn { ${body} } }`; + + // A write to an export of the first block is not rewritten. It goes to the outer `x`. + it.todo("writes to a member of the first block", () => { + const code = `var x = "outer";\n${merged("export let x = 1")}${later("x = 5; x++; export const y = x")}`; + expect(run(code, "[F.fn.y, F.fn.x, x]")).toEqual([6, 6, "outer"]); + }); + + // Where no renamer runs, the closure argument and the parameter have the same name. + it.todo("reads a member of the first block under a parameter with the name of the namespace", () => { + const code = merged("export const x = 1") + later("export function g(fn: any) { return x }"); + expect(run(code, `F.fn.g({ x: "param" })`)).toBe(1); + }); + + // A bare read of an enum of the first block stays a run-time read. + it.todo("counts on from a member of an enum of the first block", () => { + const code = merged("export enum E { A = 7 }") + later("export enum Y { P = E.A, R }"); + expect(run(code, "F.fn.Y")).toEqual({ P: 7, R: 8, 7: "P", 8: "R" }); + }); + + it.todo("reads an enum member of the first block under a parameter with the name of the namespace", () => { + const code = merged("export enum E { A = 7 }") + later("export function f(fn: any) { return E.A }"); + expect(run(code, `F.fn.f({ E: { A: "param" } })`)).toBe(7); + }); + }); }); const input4 = `export enum x { y }`; From 4a52b2d51beab88abd21341ac01903dbeeaac6d4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:57:42 +0000 Subject: [PATCH 5/6] test: assert stderr of the spawned run, and state what each todo gives 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. --- test/bundler/transpiler/transpiler.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 42a1b97d4c20..8ac6d0d03213 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2225,8 +2225,7 @@ export default class { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, exitCode }).toEqual({ stdout: "1\n", exitCode: 0 }); - void stderr; + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "1\n", stderr: "", exitCode: 0 }); }); // Control: only an enum member folds. These reads print the same without the members. @@ -2245,24 +2244,25 @@ export default class { const merged = members => `namespace F { export function fn() {} export namespace fn { ${members} } }\n`; const later = body => `namespace F { export namespace fn { ${body} } }`; - // A write to an export of the first block is not rewritten. It goes to the outer `x`. + // Gives [1, 1, 6]. A write to an export of the first block is not rewritten and goes to the outer `x`. it.todo("writes to a member of the first block", () => { const code = `var x = "outer";\n${merged("export let x = 1")}${later("x = 5; x++; export const y = x")}`; expect(run(code, "[F.fn.y, F.fn.x, x]")).toEqual([6, 6, "outer"]); }); - // Where no renamer runs, the closure argument and the parameter have the same name. + // Gives "param". Where no renamer runs, the closure argument and the parameter have the same name. it.todo("reads a member of the first block under a parameter with the name of the namespace", () => { const code = merged("export const x = 1") + later("export function g(fn: any) { return x }"); expect(run(code, `F.fn.g({ x: "param" })`)).toBe(1); }); - // A bare read of an enum of the first block stays a run-time read. + // Gives `R: undefined`. A bare read of an enum of the first block stays a run-time read. it.todo("counts on from a member of an enum of the first block", () => { const code = merged("export enum E { A = 7 }") + later("export enum Y { P = E.A, R }"); expect(run(code, "F.fn.Y")).toEqual({ P: 7, R: 8, 7: "P", 8: "R" }); }); + // Gives "param". The read of `E.A` stays a run-time read, under the parameter with the name of the closure argument. it.todo("reads an enum member of the first block under a parameter with the name of the namespace", () => { const code = merged("export enum E { A = 7 }") + later("export function f(fn: any) { return E.A }"); expect(run(code, `F.fn.f({ E: { A: "param" } })`)).toBe(7); From 97999edd3066160539045c6dd49edb26ce712872 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:57:57 +0000 Subject: [PATCH 6/6] ci: retrigger