feat(transpiler): lower auto-accessor class fields

Lower `accessor` class fields into a backing private field + getter/setter
pair, since JavaScriptCore does not support `accessor` natively.

- `accessor x = 1` → `#x = 1; get x() { return this.#x; } set x(_) { this.#x = _; }`
- `accessor #x` → `#_x; get #x() { return this.#_x; } set #x(_) { this.#_x = _; }`
- `accessor [expr]` → `#a; get [_a = expr]() { return this.#a; } set [_a](_) { this.#a = _; }`

Computed keys are cached in temp variables to avoid evaluating
side-effecting expressions twice, matching esbuild's behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jarred Sumner
2026-01-25 03:23:00 +01:00
parent 70fe76209b
commit 351a2ffa4a
8 changed files with 276 additions and 10 deletions

View File

@@ -50,7 +50,7 @@ pub const Class = struct {
return false;
}
if (property.kind == .normal) {
if (property.kind == .normal or property.kind == .auto_accessor) {
if (flags.contains(.is_static)) {
for ([2]?Expr{ property.value, property.initializer }) |val_| {
if (val_) |val| {
@@ -134,6 +134,7 @@ pub const Property = struct {
declare,
abstract,
class_static_block,
auto_accessor,
pub fn jsonStringify(self: @This(), writer: anytype) !void {
return try writer.write(@tagName(self));

View File

@@ -428,6 +428,10 @@ pub fn NewParser_(
temp_refs_to_declare: List(TempRef) = .{},
temp_ref_count: i32 = 0,
// Temp vars for auto-accessor computed keys, collected during visitClass
// and emitted as var declarations by the caller.
auto_accessor_computed_key_refs: List(LocRef) = .{},
// When bundling, hoisted top-level local variables declared with "var" in
// nested scopes are moved up to be declared in the top-level scope instead.
// The old "var" statements are turned into regular assignments instead. This
@@ -4851,6 +4855,147 @@ pub fn NewParser_(
stmts.append(closure) catch unreachable;
}
pub fn lowerAutoAccessors(p: *P, properties: []G.Property) []G.Property {
var new_props = ListManaged(G.Property).init(p.allocator);
new_props.ensureTotalCapacity(properties.len * 3) catch unreachable;
var auto_accessor_count: u32 = 0;
for (properties) |prop| {
if (prop.kind != .auto_accessor) {
new_props.append(prop) catch unreachable;
continue;
}
const loc = if (prop.key) |k| k.loc else logger.Loc.Empty;
const is_static = prop.flags.contains(.is_static);
const is_computed = prop.flags.contains(.is_computed);
// Derive backing field name from the key
const backing_name: []const u8 = blk: {
if (prop.key != null and prop.key.?.data == .e_private_identifier) {
// accessor #x -> backing field #_x
const orig_name = p.loadNameFromRef(prop.key.?.data.e_private_identifier.ref);
break :blk std.fmt.allocPrint(p.allocator, "#_{s}", .{orig_name[1..]}) catch unreachable;
} else if (!is_computed) {
if (prop.key != null and prop.key.?.data == .e_string) {
// accessor x -> backing field #x
const str = prop.key.?.data.e_string;
break :blk std.fmt.allocPrint(p.allocator, "#{s}", .{str.data}) catch unreachable;
}
}
// Computed key -> counter-based name
const offset: u8 = @intCast(@min(auto_accessor_count, 25));
const name = std.fmt.allocPrint(p.allocator, "#{c}", .{
@as(u8, 'a' + offset),
}) catch unreachable;
auto_accessor_count += 1;
break :blk name;
};
// Create backing field symbol
const backing_ref = p.newSymbol(
if (is_static) .private_static_field else .private_field,
backing_name,
) catch unreachable;
p.recordDeclaredSymbol(backing_ref) catch unreachable;
// For private auto-accessors, update the original symbol kind
// from .private_field to .private_get_set_pair
if (prop.key != null and prop.key.?.data == .e_private_identifier) {
const orig_ref = prop.key.?.data.e_private_identifier.ref;
p.symbols.items[orig_ref.innerIndex()].kind =
if (is_static) .private_static_get_set_pair else .private_get_set_pair;
}
// For computed keys, cache in a temp variable so the expression
// is only evaluated once (getter uses _a = expr, setter uses _a)
var getter_key = prop.key;
var setter_key = prop.key;
if (is_computed) {
const temp_ref = p.newSymbol(.other, "_a") catch unreachable;
p.recordDeclaredSymbol(temp_ref) catch unreachable;
p.auto_accessor_computed_key_refs.append(
p.allocator,
LocRef{ .loc = loc, .ref = temp_ref },
) catch unreachable;
// Getter key: _a = expr (evaluate and cache)
getter_key = p.newExpr(E.Binary{
.op = .bin_assign,
.left = p.newExpr(E.Identifier{ .ref = temp_ref }, loc),
.right = prop.key.?,
}, loc);
// Setter key: _a (reuse cached value)
setter_key = p.newExpr(E.Identifier{ .ref = temp_ref }, loc);
}
// 1. Backing field (kind = .normal, private symbol, with initializer)
new_props.append(.{
.kind = .normal,
.key = p.newExpr(E.PrivateIdentifier{ .ref = backing_ref }, loc),
.initializer = prop.initializer,
.flags = Flags.Property.init(.{ .is_static = is_static }),
}) catch unreachable;
// Helper: this.#backing expression
const this_dot_backing = p.newExpr(E.Index{
.target = p.newExpr(E.This{}, loc),
.index = p.newExpr(E.PrivateIdentifier{ .ref = backing_ref }, loc),
}, loc);
// 2. Getter: get x() { return this.#backing; }
new_props.append(.{
.kind = .get,
.key = getter_key,
.value = p.newExpr(E.Function{ .func = .{
.body = G.FnBody.initReturnExpr(p.allocator, this_dot_backing) catch unreachable,
.open_parens_loc = loc,
} }, loc),
.flags = Flags.Property.init(.{
.is_static = is_static,
.is_method = true,
.is_computed = is_computed,
}),
}) catch unreachable;
// 3. Setter: set x(_) { this.#backing = _; }
const setter_param_ref = p.newSymbol(.other, "_") catch unreachable;
const setter_arg = p.allocator.alloc(G.Arg, 1) catch unreachable;
setter_arg[0] = .{
.binding = Binding.alloc(p.allocator, B.Identifier{ .ref = setter_param_ref }, loc),
};
const assign_expr = p.newExpr(E.Binary{
.op = .bin_assign,
.left = p.newExpr(E.Index{
.target = p.newExpr(E.This{}, loc),
.index = p.newExpr(E.PrivateIdentifier{ .ref = backing_ref }, loc),
}, loc),
.right = p.newExpr(E.Identifier{ .ref = setter_param_ref }, loc),
}, loc);
const setter_body_stmts = p.allocator.alloc(Stmt, 1) catch unreachable;
setter_body_stmts[0] = p.s(S.SExpr{ .value = assign_expr }, loc);
new_props.append(.{
.kind = .set,
.key = setter_key,
.value = p.newExpr(E.Function{ .func = .{
.body = .{ .loc = loc, .stmts = setter_body_stmts },
.args = setter_arg,
.open_parens_loc = loc,
} }, loc),
.flags = Flags.Property.init(.{
.is_static = is_static,
.is_method = true,
.is_computed = is_computed,
}),
}) catch unreachable;
}
return new_props.items;
}
pub fn lowerClass(
noalias p: *P,
stmtorexpr: js_ast.StmtOrExpr,
@@ -4910,9 +5055,9 @@ pub fn NewParser_(
const descriptor_key = prop.key.?;
const loc = descriptor_key.loc;
// TODO: when we have the `accessor` modifier, add `and !prop.flags.contains(.has_accessor_modifier)` to
// the if statement.
const descriptor_kind: Expr = if (!prop.flags.contains(.is_method))
const descriptor_kind: Expr = if (prop.kind == .auto_accessor)
p.newExpr(E.Null{}, loc)
else if (!prop.flags.contains(.is_method))
p.newExpr(E.Undefined{}, loc)
else
p.newExpr(E.Null{}, loc);
@@ -4929,7 +5074,7 @@ pub fn NewParser_(
if (p.options.features.emit_decorator_metadata) {
switch (prop.kind) {
.normal, .abstract => {
.normal, .abstract, .auto_accessor => {
{
// design:type
var args = p.allocator.alloc(Expr, 2) catch unreachable;

View File

@@ -274,7 +274,7 @@ pub fn ParseProperty(
const scope_index = p.scopes_in_order.items.len;
if (try p.parseProperty(kind, opts, null)) |_prop| {
var prop = _prop;
if (prop.kind == .normal and prop.value == null and opts.ts_decorators.len > 0) {
if ((prop.kind == .normal or prop.kind == .auto_accessor) and prop.value == null and opts.ts_decorators.len > 0) {
prop.kind = .declare;
return prop;
}
@@ -289,7 +289,7 @@ pub fn ParseProperty(
opts.is_ts_abstract = true;
const scope_index = p.scopes_in_order.items.len;
if (try p.parseProperty(kind, opts, null)) |*prop| {
if (prop.kind == .normal and prop.value == null and opts.ts_decorators.len > 0) {
if ((prop.kind == .normal or prop.kind == .auto_accessor) and prop.value == null and opts.ts_decorators.len > 0) {
var prop_ = prop.*;
prop_.kind = .abstract;
return prop_;
@@ -299,6 +299,15 @@ pub fn ParseProperty(
return null;
}
},
.p_accessor => {
if (opts.is_class and !opts.is_async and !opts.is_generator and
(js_lexer.PropertyModifierKeyword.List.get(raw) orelse .p_static) == .p_accessor)
{
kind = .auto_accessor;
errors = null;
continue :restart;
}
},
.p_private, .p_protected, .p_public, .p_readonly, .p_override => {
// Skip over TypeScript keywords
if (opts.is_class and is_typescript_enabled and (js_lexer.PropertyModifierKeyword.List.get(raw) orelse .p_static) == keyword) {
@@ -430,7 +439,7 @@ pub fn ParseProperty(
// Parse a class field with an optional initial value
if (opts.is_class and
kind == .normal and !opts.is_async and
(kind == .normal or kind == .auto_accessor) and !opts.is_async and
!opts.is_generator and
p.lexer.token != .t_open_paren and
!has_type_parameters and

View File

@@ -713,6 +713,20 @@ pub fn Visit(
}
}
}
// Lower auto-accessors into backing field + getter + setter
{
var has_auto_accessors = false;
for (class.properties) |prop| {
if (prop.kind == .auto_accessor) {
has_auto_accessors = true;
break;
}
}
if (has_auto_accessors) {
class.properties = p.lowerAutoAccessors(class.properties);
}
}
}
if (p.symbols.items[shadow_ref.innerIndex()].use_count_estimate == 0) {
@@ -830,6 +844,17 @@ pub fn Visit(
break :list_getter &visited;
};
try p.visitAndAppendStmt(list, stmt);
// Emit var declarations for auto-accessor computed key temps
// that were generated from class expressions during this statement.
// (Class statement temps are handled in visitAndAppendStmt directly.)
// var is hoisted so placement within the statement list is fine.
for (p.auto_accessor_computed_key_refs.items) |temp| {
const decls = p.allocator.alloc(G.Decl, 1) catch unreachable;
decls[0] = .{ .binding = p.b(B.Identifier{ .ref = temp.ref.? }, temp.loc) };
try list.append(p.s(S.Local{ .decls = G.Decl.List.fromOwnedSlice(decls) }, temp.loc));
}
p.auto_accessor_computed_key_refs.clearRetainingCapacity();
}
// Transform block-level function declarations into variable declarations

View File

@@ -587,6 +587,14 @@ pub fn VisitStmt(
_ = p.visitClass(stmt.loc, &data.class, Ref.None);
// Emit var declarations for auto-accessor computed key temps
for (p.auto_accessor_computed_key_refs.items) |temp| {
const decls = p.allocator.alloc(Decl, 1) catch unreachable;
decls[0] = .{ .binding = Binding.alloc(p.allocator, B.Identifier{ .ref = temp.ref.? }, temp.loc) };
stmts.append(p.s(S.Local{ .decls = Decl.List.fromOwnedSlice(decls) }, temp.loc)) catch unreachable;
}
p.auto_accessor_computed_key_refs.clearRetainingCapacity();
// Remove the export flag inside a namespace
const was_export_inside_namespace = data.is_export and p.enclosing_namespace_arg_ref != null;
if (was_export_inside_namespace) {

View File

@@ -214,6 +214,7 @@ pub const StrictModeReservedWordsRemap = ComptimeStringMap(string, .{
pub const PropertyModifierKeyword = enum {
p_abstract,
p_accessor,
p_async,
p_declare,
p_get,
@@ -227,6 +228,7 @@ pub const PropertyModifierKeyword = enum {
pub const List = ComptimeStringMap(PropertyModifierKeyword, .{
.{ "abstract", .p_abstract },
.{ "accessor", .p_accessor },
.{ "async", .p_async },
.{ "declare", .p_declare },
.{ "get", .p_get },

View File

@@ -3292,6 +3292,13 @@ fn NewPrinter(
p.print("set");
p.printSpace();
},
.auto_accessor => {
if (comptime is_json and Environment.allow_assert)
unreachable;
p.printSpaceBeforeIdentifier();
p.print("accessor");
p.printSpace();
},
else => {},
}
@@ -3478,7 +3485,7 @@ fn NewPrinter(
},
}
if (item.kind != .normal) {
if (item.kind != .normal and item.kind != .auto_accessor) {
if (comptime is_json) {
bun.unreachablePanic("item.kind must be normal in json", .{});
}

View File

@@ -828,6 +828,68 @@ function foo() {}
exp("let x: abstract new <T>() => Foo<T>", "let x");
});
it("auto-accessor", () => {
const exp = ts.expectPrinted_;
// Basic lowering (matching esbuild TestLowerAutoAccessors)
exp(
"class Foo { accessor x }",
"class Foo {\n #x;\n get x() {\n return this.#x;\n }\n set x(_) {\n this.#x = _;\n }\n}",
);
exp(
"class Foo { accessor x = null }",
"class Foo {\n #x = null;\n get x() {\n return this.#x;\n }\n set x(_) {\n this.#x = _;\n }\n}",
);
exp(
"class Foo { static accessor x }",
"class Foo {\n static #x;\n static get x() {\n return this.#x;\n }\n static set x(_) {\n this.#x = _;\n }\n}",
);
exp(
"class Foo { static accessor x = null }",
"class Foo {\n static #x = null;\n static get x() {\n return this.#x;\n }\n static set x(_) {\n this.#x = _;\n }\n}",
);
// Private auto-accessors
exp(
"class Foo { accessor #x = 1 }",
"class Foo {\n #_x = 1;\n get #x() {\n return this.#_x;\n }\n set #x(_) {\n this.#_x = _;\n }\n}",
);
// Computed keys (expression cached in temp var to avoid double evaluation)
exp(
"class Foo { accessor [x] }",
"var _a;\n\nclass Foo {\n #a;\n get [_a = x]() {\n return this.#a;\n }\n set [_a](_) {\n this.#a = _;\n }\n}",
);
exp(
"class Foo { accessor [x] = null }",
"var _a;\n\nclass Foo {\n #a = null;\n get [_a = x]() {\n return this.#a;\n }\n set [_a](_) {\n this.#a = _;\n }\n}",
);
exp(
"class Foo { static accessor [x] }",
"var _a;\n\nclass Foo {\n static #a;\n static get [_a = x]() {\n return this.#a;\n }\n static set [_a](_) {\n this.#a = _;\n }\n}",
);
exp(
"class Foo { static accessor [x] = null }",
"var _a;\n\nclass Foo {\n static #a = null;\n static get [_a = x]() {\n return this.#a;\n }\n static set [_a](_) {\n this.#a = _;\n }\n}",
);
// TypeScript modifiers (type stripping)
exp(
"class Foo { accessor x: number = 1 }",
"class Foo {\n #x = 1;\n get x() {\n return this.#x;\n }\n set x(_) {\n this.#x = _;\n }\n}",
);
exp(
"class Foo { public accessor x = 1 }",
"class Foo {\n #x = 1;\n get x() {\n return this.#x;\n }\n set x(_) {\n this.#x = _;\n }\n}",
);
exp("class Foo { declare accessor x: number }", "class Foo {\n}");
exp("abstract class Foo { abstract accessor x: number }", "class Foo {\n}");
// Contextual keyword edge cases (not auto-accessor syntax)
exp("class Foo { accessor() {} }", "class Foo {\n accessor() {}\n}");
exp("class Foo { accessor = 1 }", "class Foo {\n accessor = 1;\n}");
});
it("as", () => {
const exp = ts.expectPrinted_;
exp("x as 1 < 1", "x < 1");
@@ -3605,7 +3667,14 @@ it("does not crash with --minify-syntax and revisiting dot expressions", () => {
env: bunEnv,
});
expect(stderr.toString()).toBe("");
expect(
stderr
.toString()
.split(/\r?\n/)
.filter(s => !s.startsWith("WARNING: ASAN interferes"))
.join("\n")
.trim(),
).toBe("");
expect(stdout.toString()).toBe("undefined\n");
expect(exitCode).toBe(0);
});