Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions src/js_parser/lower/lower_decorators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
robobun marked this conversation as resolved.
};
let private_access = self.private_get_expr(get_obj, &info, expr_loc);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let call_target = self.new_expr(
E::Dot {
target: private_access,
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2353,6 +2380,29 @@ 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 capture_count = p.temp_refs_to_declare.len() - temp_refs_before;
let mut capture_decls = BumpVec::<G::Decl>::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,
));
}
Comment thread
robobun marked this conversation as resolved.
Outdated

// ── Phase 8: Assemble output ─────────────────────
if is_expr {
let mut comma_parts = BumpVec::<Expr>::new_in(bump);
Expand Down
135 changes: 135 additions & 0 deletions test/bundler/transpiler/es-decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,141 @@ 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("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; }
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);
});

// 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 () => {
Comment thread
robobun marked this conversation as resolved.
Outdated
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", () => {
test("accessor with definite assignment assertion (!)", async () => {
using dir = tempDir("es-dec-accessor-bang", {
Expand Down
Loading