From 53316b60e6621e36f07f6a97bb7c99fd8d4f57ed Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 26 May 2026 08:53:32 +0000 Subject: [PATCH 1/7] Evaluate private method call receivers once in decorator lowering When a class is lowered for standard decorators, `recv.#m(args)` was rewritten to `__privateGet(recv, _m).call(recv, args)`, duplicating the receiver expression. Side effects in the receiver ran twice, and for chained calls like `o.#m().#m().#m()` the duplication compounded, so printed output grew exponentially with chain length (a 44-link chain from the fuzzer made the printer allocate without bound). Reuse `this` and identifier receivers directly; capture any other receiver in a temporary (`__privateGet(_obj = recv, _m).call(_obj, args)`) declared alongside the other lowering variables. --- src/js_parser/lower/lower_decorators.rs | 61 +++++++++++++- test/bundler/transpiler/es-decorators.test.ts | 79 +++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 5c847a25266c..6795c5755c6f 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -756,7 +756,28 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(info) = map.get(&pi.ref_.inner_index()).copied() { let mut obj_expr = tgt_idx.target; self.rewrite_private_accesses_in_expr(&mut obj_expr, map); - let private_access = self.private_get_expr(obj_expr, &info, expr_loc); + // `x.#m(...)` becomes `__privateGet(x, _m).call(x, ...)`, which + // references the receiver twice. Only identifiers and `this` can + // be repeated safely; any other receiver is captured in a + // temporary so its side effects run once and nested private + // calls don't duplicate the whole subtree (the duplication is + // exponential in the length of a chain like `o.#m().#m().#m()`). + let (get_obj, this_arg) = match &obj_expr.data { + js_ast::ExprData::EIdentifier(id) => { + let obj_ref = id.ref_; + (obj_expr, self.use_ref(obj_ref, obj_expr.loc)) + } + js_ast::ExprData::EThis(_) => { + (obj_expr, self.new_expr(E::This {}, obj_expr.loc)) + } + _ => { + let tmp_ref = self.generate_temp_ref(Some(b"_obj")); + let write = self.assign_to(tmp_ref, obj_expr, expr_loc); + let read = self.use_ref(tmp_ref, expr_loc); + (write, read) + } + }; + let private_access = self.private_get_expr(get_obj, &info, expr_loc); let call_target = self.new_expr( E::Dot { target: private_access, @@ -769,7 +790,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let bump = self.arena; let orig_args = e.args.slice_mut(); let mut new_args = BumpVec::with_capacity_in(1 + orig_args.len(), bump); - new_args.push(obj_expr); + new_args.push(this_arg); for arg in orig_args.iter_mut() { self.rewrite_private_accesses_in_expr(arg, map); new_args.push(*arg); @@ -1026,6 +1047,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let p = self; let bump = p.arena; + // Receiver-capture temporaries created by `rewrite_private_accesses_in_expr` + // land in `temp_refs_to_declare`; everything pushed past this point is + // declared in a `var` statement alongside the other lowering variables + // right before output assembly. + let temp_refs_before = p.temp_refs_to_declare.len(); + // ── Phase 1: Setup ─────────────────────────────── let mut class_name_ref: Ref; let mut class_name_loc: bun_ast::Loc; @@ -2353,6 +2380,36 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O class.has_decorators = false; class.should_lower_standard_decorators = false; + // Declare receiver-capture temporaries generated while rewriting private + // member calls (`__privateGet(_obj = recv, ...).call(_obj, ...)`). + if p.temp_refs_to_declare.len() > temp_refs_before { + let mut capture_refs = BumpVec::::new_in(bump); + for temp in p.temp_refs_to_declare[temp_refs_before..].iter() { + capture_refs.push(temp.r#ref); + } + p.temp_refs_to_declare.truncate(temp_refs_before); + let mut capture_decls = BumpVec::::new_in(bump); + for capture_ref in capture_refs.iter() { + let binding = p.b( + B::Identifier { + r#ref: *capture_ref, + }, + loc, + ); + capture_decls.push(G::Decl { + binding, + value: None, + }); + } + prefix_stmts.push(p.s( + S::Local { + decls: DeclList::from_bump_vec(capture_decls), + ..Default::default() + }, + loc, + )); + } + // ── Phase 8: Assemble output ───────────────────── if is_expr { let mut comma_parts = BumpVec::::new_in(bump); diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 552260cf7e93..ac1adc14e4cb 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -753,6 +753,85 @@ describe("ES Decorators", () => { }); }); + describe("private member calls in lowered classes", () => { + // When a class is lowered for standard decorators, `recv.#m(...)` becomes + // `__privateGet(recv, _m).call(recv, ...)`. The receiver must be evaluated + // exactly once: duplicating it re-runs side effects and makes the printed + // output grow exponentially for chains like `o.#m().#m().#m()`. + test("chained optional private calls do not explode the transpiled output size", () => { + const chain = "?.Foo.#m()".repeat(20); + const source = `class Foo { + static #x = -0; + static #m = function() {}; + @decorator() est() { + return [o${chain}]; + } + }`; + + const transpiler = new Bun.Transpiler({ loader: "js", target: "bun" }); + const output = transpiler.transformSync(source); + + // Exponential duplication produced ~47 MB for a 20-call chain; the + // single-evaluation lowering stays in the kilobytes. + expect(output.length).toBeLessThan(50_000); + // The lowered output must still be valid syntax. + expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(output)).not.toThrow(); + }); + + test("private method call receiver is evaluated exactly once", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(value, ctx) { return value; } + let receiverEvals = 0; + class Counter { + static #m = function (x) { return [this === Counter, x]; }; + @dec test() { + return getCounter().#m(42); + } + } + function getCounter() { receiverEvals++; return Counter; } + console.log(JSON.stringify(new Counter().test()), receiverEvals); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("[true,42] 1\n"); + expect(exitCode).toBe(0); + }); + + test("chained optional private method calls return the right value", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(value, ctx) { return value; } + class Chain { + #tag; + constructor(tag) { this.#tag = tag; } + #next() { return { Chain: new Chain(this.#tag + 1) }; } + @dec run(o) { + return o?.Chain.#next()?.Chain.#next()?.Chain.#next()?.Chain.tag(); + } + tag() { return this.#tag; } + } + console.log(new Chain(0).run({ Chain: new Chain(10) })); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("13\n"); + expect(exitCode).toBe(0); + }); + + test("private method calls through `this` and identifier receivers still work", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(value, ctx) { return value; } + class Fast { + #p(n) { return "p" + n; } + @dec viaThis() { return this.#p(1); } + @dec viaIdent(other) { return other.#p(2); } + } + const f = new Fast(); + console.log(f.viaThis(), f.viaIdent(new Fast())); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("p1 p2\n"); + expect(exitCode).toBe(0); + }); + }); + describe("accessor with TypeScript annotations", () => { test("accessor with definite assignment assertion (!)", async () => { using dir = tempDir("es-dec-accessor-bang", { From a485bf9d393b8012d9601a58f7114dd49e6d0569 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 26 May 2026 10:08:40 +0000 Subject: [PATCH 2/7] ci: retrigger From 8543d6767a4b312e2592991c36653edc3bae0cc2 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 1 Jun 2026 23:59:14 +0000 Subject: [PATCH 3/7] Add regression tests for double-call private chains in decorated static fields A fuzzer-minimized variant of the receiver-duplication blowup reaches the multi-GB range with only ~30 links: a `.#method()()` chain in a decorated static field initializer (ts loader). Cover it with a transpiled-size test (20 links must stay in the kilobytes) and a runtime test asserting each link's private method is evaluated exactly once. --- test/bundler/transpiler/es-decorators.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index ac1adc14e4cb..6516025c179e 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -778,6 +778,41 @@ describe("ES Decorators", () => { expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(output)).not.toThrow(); }); + test("double-call private chains in decorated static field initializers stay linear", () => { + // Fuzzer-minimized variant: each `.#method()()` link re-lowers the whole + // receiver, so duplicating it doubles the printed output per link + // (~30 links allocated multiple GB before aborting). + const chain = ".#method()()".repeat(20); + const source = `class C { + @decorator() static s = new C()${chain.slice(0, -2)}; + #method() { return 1e999; } + }`; + + const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun", deadCodeElimination: true }); + const output = transpiler.transformSync(source); + + // Exponential duplication produced ~64 MB for 20 links; the + // single-evaluation lowering stays in the kilobytes. + expect(output.length).toBeLessThan(50_000); + // The lowered output must still be valid syntax. + expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(output)).not.toThrow(); + }); + + test("calling the result of a private method call evaluates each link once", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(value, ctx) { return value; } + let evals = 0; + class C { + @dec static s = new C().#method()().#method()().#method()(); + #method() { evals++; const self = this; return () => self; } + } + console.log(C.s instanceof C, evals); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("true 3\n"); + expect(exitCode).toBe(0); + }); + test("private method call receiver is evaluated exactly once", async () => { const { stdout, stderr, exitCode } = await runDecorator(` function dec(value, ctx) { return value; } From 380254951b71641a89958402f2972be9ce4bb666 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:31:31 +0000 Subject: [PATCH 4/7] Preallocate the capture-temp decl vec and add a class expression test --- src/js_parser/lower/lower_decorators.rs | 19 ++++++----------- test/bundler/transpiler/es-decorators.test.ts | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 6795c5755c6f..716bbeb18e21 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -2383,24 +2383,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Declare receiver-capture temporaries generated while rewriting private // member calls (`__privateGet(_obj = recv, ...).call(_obj, ...)`). if p.temp_refs_to_declare.len() > temp_refs_before { - let mut capture_refs = BumpVec::::new_in(bump); - for temp in p.temp_refs_to_declare[temp_refs_before..].iter() { - capture_refs.push(temp.r#ref); - } - p.temp_refs_to_declare.truncate(temp_refs_before); - let mut capture_decls = BumpVec::::new_in(bump); - for capture_ref in capture_refs.iter() { - let binding = p.b( - B::Identifier { - r#ref: *capture_ref, - }, - loc, - ); + let capture_count = p.temp_refs_to_declare.len() - temp_refs_before; + let mut capture_decls = BumpVec::::with_capacity_in(capture_count, bump); + for i in temp_refs_before..p.temp_refs_to_declare.len() { + let capture_ref = p.temp_refs_to_declare[i].r#ref; + let binding = p.b(B::Identifier { r#ref: capture_ref }, loc); capture_decls.push(G::Decl { binding, value: None, }); } + p.temp_refs_to_declare.truncate(temp_refs_before); prefix_stmts.push(p.s( S::Local { decls: DeclList::from_bump_vec(capture_decls), diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 6516025c179e..ae294a47a94e 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -865,6 +865,27 @@ describe("ES Decorators", () => { expect(stdout).toBe("p1 p2\n"); expect(exitCode).toBe(0); }); + + // Decorated class expressions emit the capture temporaries through a + // different path (a `var` statement hoisted to the nearest statement + // list instead of prefix statements before the class statement). + test("decorated class expression evaluates chained private call receivers once", async () => { + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(value, ctx) { return value; } + let evals = 0; + const C = class Foo { + static #m = function (tag) { return { Foo, tag }; }; + @dec test(o) { + return o.effectful()?.Foo.#m("a")?.Foo.#m("b"); + } + }; + const o = { Foo: C, effectful() { evals++; return { Foo: C }; } }; + console.log(new C().test(o).tag, evals); + `); + expect(stderr).toBe(""); + expect(stdout).toBe("b 1\n"); + expect(exitCode).toBe(0); + }); }); describe("accessor with TypeScript annotations", () => { From ee1c2d3c9d653c364c3c23ee2e949dfff85dc517 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:56:06 +0000 Subject: [PATCH 5/7] Declare receiver-capture temps inside the enclosing function body A temp hoisted to class-statement scope is one shared binding across all invocations of a method. For getter/accessor-backed private calls, __privateGet runs the user getter between the temp write and the .call read, so a getter reentering the same call site overwrote the outer invocation's receiver. Declare temps created inside function/arrow bodies at the top of that body instead, matching where esbuild places them; sites outside function bodies run at most once per class evaluation and keep the hoisted declaration. --- src/js_parser/lower/lower_decorators.rs | 59 ++++++++++++++++++- test/bundler/transpiler/es-decorators.test.ts | 30 ++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index 716bbeb18e21..f176d000f85b 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -863,17 +863,67 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } js_ast::ExprData::EFunction(e) => { + let temps_before = self.temp_refs_to_declare.len(); let stmts = e.func.body.stmts.slice_mut(); self.rewrite_private_accesses_in_stmts(stmts, map); + e.func.body.stmts = self.declare_capture_temps_in_fn_body( + e.func.body.stmts, + temps_before, + e.func.body.loc, + ); } js_ast::ExprData::EArrow(e) => { + let temps_before = self.temp_refs_to_declare.len(); let stmts = e.body.stmts.slice_mut(); self.rewrite_private_accesses_in_stmts(stmts, map); + e.body.stmts = + self.declare_capture_temps_in_fn_body(e.body.stmts, temps_before, e.body.loc); } _ => {} } } + /// Declare receiver-capture temporaries created past `temps_before` at the + /// top of the function body they were created in, so each invocation gets + /// a fresh binding. A binding hoisted outside the function would be shared + /// across invocations, and `__privateGet(_obj = recv, _s, getter)` runs the + /// user getter between the write and the `.call(_obj)` read; re-entering + /// the same call site through that getter would clobber the shared temp. + fn declare_capture_temps_in_fn_body( + &mut self, + stmts: js_ast::StmtNodeList, + temps_before: usize, + body_loc: bun_ast::Loc, + ) -> js_ast::StmtNodeList { + let total = self.temp_refs_to_declare.len(); + if total == temps_before { + return stmts; + } + let bump = self.arena; + let mut capture_decls = BumpVec::::with_capacity_in(total - temps_before, bump); + for i in temps_before..total { + let capture_ref = self.temp_refs_to_declare[i].r#ref; + let binding = self.b(B::Identifier { r#ref: capture_ref }, body_loc); + capture_decls.push(G::Decl { + binding, + value: None, + }); + } + self.temp_refs_to_declare.truncate(temps_before); + let decl_stmt = self.s( + S::Local { + decls: DeclList::from_bump_vec(capture_decls), + ..Default::default() + }, + body_loc, + ); + let old_stmts = stmts.slice(); + let mut new_stmts = BumpVec::::with_capacity_in(old_stmts.len() + 1, bump); + new_stmts.push(decl_stmt); + new_stmts.extend_from_slice(old_stmts); + js_ast::StmtNodeList::from_bump(new_stmts) + } + fn rewrite_private_accesses_in_stmts(&mut self, stmts: &mut [Stmt], map: &PrivateLoweredMap) { for stmt_item in stmts.iter_mut() { match &mut stmt_item.data { @@ -2380,8 +2430,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O class.has_decorators = false; class.should_lower_standard_decorators = false; - // Declare receiver-capture temporaries generated while rewriting private - // member calls (`__privateGet(_obj = recv, ...).call(_obj, ...)`). + // Declare the receiver-capture temporaries that were created outside any + // function body (field initializers, static blocks, pre-eval/decorate + // expressions). Temps created inside method/function/arrow bodies were + // already declared there by `declare_capture_temps_in_fn_body`; these + // remaining sites run at most once per class evaluation, so a binding + // hoisted next to the other lowering variables is safe (and matches + // where esbuild hoists them). if p.temp_refs_to_declare.len() > temp_refs_before { let capture_count = p.temp_refs_to_declare.len() - temp_refs_before; let mut capture_decls = BumpVec::::with_capacity_in(capture_count, bump); diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index ae294a47a94e..96d63e540a8b 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -813,6 +813,36 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); + test("receiver temps are scoped per invocation, not shared across reentrant calls", async () => { + // A private getter runs user code inside __privateGet, between the + // `_obj = recv` write and the `.call(_obj)` read. If the getter reenters + // the same call site, a temp hoisted outside the method would be + // clobbered and the outer call would see the inner receiver. Declaring + // the temp inside the method body gives each invocation its own binding. + const { stdout, stderr, exitCode } = await runDecorator(` + function dec(value, ctx) { return value; } + let nextId = 0; + let depth = 0; + const order = []; + class C { + get #g() { + if (depth++ === 0) make().run(); + const self = this; + return function () { order.push(self.id + ":" + this.id); }; + } + @dec run() { make().#g(); } + } + function make() { const c = new C(); c.id = ++nextId; return c; } + make().run(); + console.log(JSON.stringify(order)); + `); + expect(stderr).toBe(""); + // Each entry pairs the receiver seen at getter time with the receiver + // the returned function was invoked on; they must always match. + expect(stdout).toBe('["4:4","2:2"]\n'); + expect(exitCode).toBe(0); + }); + test("private method call receiver is evaluated exactly once", async () => { const { stdout, stderr, exitCode } = await runDecorator(` function dec(value, ctx) { return value; } From ebefd55b37adf3e0fce24818aa38655df463cc76 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:24:59 +0000 Subject: [PATCH 6/7] Extract shared capture-temp drain helper; cover class-expression initializer temps Deduplicate the two copies of the drain-temps-into-var-declaration block into drain_capture_temp_decls, used by both the function-body and the class-prelude placement. Extend the class expression test with a decorated instance field whose initializer has a complex receiver, so the hoisted-to-nearest-statement-list placement is exercised at runtime, and reword its comment to describe both placements. --- src/js_parser/lower/lower_decorators.rs | 73 ++++++++----------- test/bundler/transpiler/es-decorators.test.ts | 17 +++-- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index f176d000f85b..baf1f49fb318 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -883,42 +883,50 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - /// Declare receiver-capture temporaries created past `temps_before` at the - /// top of the function body they were created in, so each invocation gets - /// a fresh binding. A binding hoisted outside the function would be shared - /// across invocations, and `__privateGet(_obj = recv, _s, getter)` runs the - /// user getter between the write and the `.call(_obj)` read; re-entering - /// the same call site through that getter would clobber the shared temp. - fn declare_capture_temps_in_fn_body( - &mut self, - stmts: js_ast::StmtNodeList, - temps_before: usize, - body_loc: bun_ast::Loc, - ) -> js_ast::StmtNodeList { + /// Drain receiver-capture temporaries created past `baseline` into a + /// single `var` declaration statement; `None` if none were created. + fn drain_capture_temp_decls(&mut self, baseline: usize, loc: bun_ast::Loc) -> Option { let total = self.temp_refs_to_declare.len(); - if total == temps_before { - return stmts; + if total == baseline { + return None; } let bump = self.arena; - let mut capture_decls = BumpVec::::with_capacity_in(total - temps_before, bump); - for i in temps_before..total { + let mut capture_decls = BumpVec::::with_capacity_in(total - baseline, bump); + for i in baseline..total { let capture_ref = self.temp_refs_to_declare[i].r#ref; - let binding = self.b(B::Identifier { r#ref: capture_ref }, body_loc); + let binding = self.b(B::Identifier { r#ref: capture_ref }, loc); capture_decls.push(G::Decl { binding, value: None, }); } - self.temp_refs_to_declare.truncate(temps_before); - let decl_stmt = self.s( + self.temp_refs_to_declare.truncate(baseline); + Some(self.s( S::Local { decls: DeclList::from_bump_vec(capture_decls), ..Default::default() }, - body_loc, - ); + loc, + )) + } + + /// Declare receiver-capture temporaries created past `temps_before` at the + /// top of the function body they were created in, so each invocation gets + /// a fresh binding. A binding hoisted outside the function would be shared + /// across invocations, and `__privateGet(_obj = recv, _s, getter)` runs the + /// user getter between the write and the `.call(_obj)` read; re-entering + /// the same call site through that getter would clobber the shared temp. + fn declare_capture_temps_in_fn_body( + &mut self, + stmts: js_ast::StmtNodeList, + temps_before: usize, + body_loc: bun_ast::Loc, + ) -> js_ast::StmtNodeList { + let Some(decl_stmt) = self.drain_capture_temp_decls(temps_before, body_loc) else { + return stmts; + }; let old_stmts = stmts.slice(); - let mut new_stmts = BumpVec::::with_capacity_in(old_stmts.len() + 1, bump); + let mut new_stmts = BumpVec::::with_capacity_in(old_stmts.len() + 1, self.arena); new_stmts.push(decl_stmt); new_stmts.extend_from_slice(old_stmts); js_ast::StmtNodeList::from_bump(new_stmts) @@ -2437,25 +2445,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // remaining sites run at most once per class evaluation, so a binding // hoisted next to the other lowering variables is safe (and matches // where esbuild hoists them). - if p.temp_refs_to_declare.len() > temp_refs_before { - let capture_count = p.temp_refs_to_declare.len() - temp_refs_before; - let mut capture_decls = BumpVec::::with_capacity_in(capture_count, bump); - for i in temp_refs_before..p.temp_refs_to_declare.len() { - let capture_ref = p.temp_refs_to_declare[i].r#ref; - let binding = p.b(B::Identifier { r#ref: capture_ref }, loc); - capture_decls.push(G::Decl { - binding, - value: None, - }); - } - p.temp_refs_to_declare.truncate(temp_refs_before); - prefix_stmts.push(p.s( - S::Local { - decls: DeclList::from_bump_vec(capture_decls), - ..Default::default() - }, - loc, - )); + if let Some(decl_stmt) = p.drain_capture_temp_decls(temp_refs_before, loc) { + prefix_stmts.push(decl_stmt); } // ── Phase 8: Assemble output ───────────────────── diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 96d63e540a8b..93a167df565e 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -896,24 +896,31 @@ describe("ES Decorators", () => { expect(exitCode).toBe(0); }); - // Decorated class expressions emit the capture temporaries through a - // different path (a `var` statement hoisted to the nearest statement - // list instead of prefix statements before the class statement). + // Covers both temp placements in a decorated class expression: the method + // body receiver gets a per-invocation `var` inside the method, while the + // field initializer receiver is rewritten outside any function body, so + // its temp is hoisted to the nearest statement list through the + // class-expression path. test("decorated class expression evaluates chained private call receivers once", async () => { const { stdout, stderr, exitCode } = await runDecorator(` function dec(value, ctx) { return value; } let evals = 0; + let initEvals = 0; + function pick(x) { initEvals++; return x; } const C = class Foo { static #m = function (tag) { return { Foo, tag }; }; + #p(tag) { return "i" + tag; } + @dec r = pick(this).#p("0"); @dec test(o) { return o.effectful()?.Foo.#m("a")?.Foo.#m("b"); } }; const o = { Foo: C, effectful() { evals++; return { Foo: C }; } }; - console.log(new C().test(o).tag, evals); + const inst = new C(); + console.log(inst.r, inst.test(o).tag, evals, initEvals); `); expect(stderr).toBe(""); - expect(stdout).toBe("b 1\n"); + expect(stdout).toBe("i0 b 1 1\n"); expect(exitCode).toBe(0); }); }); From 5025eaefddbff8d5a3b615544f2c2af055ba2f4e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:55:01 +0000 Subject: [PATCH 7/7] Correct the hoisted capture-temp comment for instance field initializers Instance field initializers run per construction, not once per class evaluation; they share the hoisted binding the way esbuild's lowering does. Say that instead of over-claiming once-per-class-evaluation. --- src/js_parser/lower/lower_decorators.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/js_parser/lower/lower_decorators.rs b/src/js_parser/lower/lower_decorators.rs index baf1f49fb318..d6ae84a9413f 100644 --- a/src/js_parser/lower/lower_decorators.rs +++ b/src/js_parser/lower/lower_decorators.rs @@ -2441,10 +2441,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Declare the receiver-capture temporaries that were created outside any // function body (field initializers, static blocks, pre-eval/decorate // expressions). Temps created inside method/function/arrow bodies were - // already declared there by `declare_capture_temps_in_fn_body`; these - // remaining sites run at most once per class evaluation, so a binding - // hoisted next to the other lowering variables is safe (and matches - // where esbuild hoists them). + // already declared there by `declare_capture_temps_in_fn_body`. Static + // blocks, static initializers, and decorate expressions run at most + // once per class evaluation; instance field initializers run once per + // construction and share the hoisted binding across constructions, + // matching where esbuild declares these temps. if let Some(decl_stmt) = p.drain_capture_temp_decls(temp_refs_before, loc) { prefix_stmts.push(decl_stmt); }