feat(minify): convert () => obj.method() to obj.method.bind(obj)

When --minify-syntax is enabled, transform arrow functions of the form
`() => obj.method()` into `obj.method.bind(obj)`. This reduces closure
allocation overhead by using the more optimized `.bind()` mechanism.

The optimization uses a two-phase approach:
1. During visiting, mark eligible arrows with the receiver's symbol ref
2. During printing, check if the symbol was ever assigned to and only
   apply the transformation if not

This allows the optimization to work with:
- const bindings (never reassigned by definition)
- let/var bindings that are never reassigned in practice
- Function parameters that are never reassigned

The optimization is NOT applied when:
- The arrow has parameters
- The arrow is async
- The call has arguments
- Optional chaining is used anywhere
- The receiver is an unbound global (could be reassigned externally)
- The receiver symbol is assigned to anywhere in the code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-02-01 14:18:58 +00:00
parent ddefa11070
commit 0b13ba1b7e
6 changed files with 340 additions and 0 deletions

View File

@@ -269,6 +269,12 @@ pub const Arrow = struct {
has_rest_arg: bool = false,
prefer_expr: bool = false, // Use shorthand if true and "Body" is a single return statement
/// When minify_syntax is enabled and this arrow is a candidate for the
/// `() => obj.method()` -> `obj.method.bind(obj)` transformation, this
/// stores the ref of the receiver identifier (obj). The printer will check
/// if this symbol was_assigned_to and only apply the transformation if not.
bind_call_target_ref: ?Ref = null,
pub const noop_return_undefined: Arrow = .{
.args = &.{},
.body = .{

View File

@@ -122,6 +122,7 @@ pub fn NewParser_(
pub const visitClass = astVisit.visitClass;
pub const visitStmts = astVisit.visitStmts;
pub const visitAndAppendStmt = astVisit.visitAndAppendStmt;
pub const tryMarkArrowForBindCallTransform = astVisit.tryMarkArrowForBindCallTransform;
pub const BinaryExpressionVisitor = @import("./visitBinaryExpression.zig").CreateBinaryExpressionVisitor(parser_feature__typescript, parser_feature__jsx, parser_feature__scan_only).BinaryExpressionVisitor;

View File

@@ -154,6 +154,53 @@ pub fn Visit(
return decs;
}
/// Check if this arrow is a candidate for the `() => obj.method()` to
/// `obj.method.bind(obj)` transformation. If so, store the receiver ref
/// in the arrow so the printer can check if the symbol was assigned to.
pub fn tryMarkArrowForBindCallTransform(p: *P, arrow: *E.Arrow) void {
// Must have exactly one statement
if (arrow.body.stmts.len != 1) return;
const stmt = arrow.body.stmts[0];
// Must be a return statement
const return_value = switch (stmt.data) {
.s_return => |ret| ret.value orelse return,
else => return,
};
// The return value must be a call expression with no arguments
const call = switch (return_value.data) {
.e_call => |c| c,
else => return,
};
// Call must have no arguments and no optional chaining
if (call.args.len != 0 or call.optional_chain != null) return;
// Call target must be a property access (dot or index) without optional chaining
const receiver_ref: Ref = switch (call.target.data) {
.e_dot => |dot| if (dot.optional_chain != null) return else switch (dot.target.data) {
.e_identifier => |ident| ident.ref,
else => return,
},
.e_index => |idx| if (idx.optional_chain != null) return else switch (idx.target.data) {
.e_identifier => |ident| ident.ref,
else => return,
},
else => return,
};
// Don't transform unbound globals (like console, Math, etc.)
// They could be reassigned by other code we can't see.
const symbol = p.symbols.items[receiver_ref.innerIndex()];
if (symbol.kind == .unbound) return;
// Mark this arrow as a candidate for bind transformation.
// The printer will check if the symbol was assigned to before applying.
arrow.bind_call_target_ref = receiver_ref;
}
pub fn visitDecls(noalias p: *P, decls: []G.Decl, was_const: bool, comptime is_possibly_decl_to_remove: bool) usize {
var j: usize = 0;
var out_decls = decls;

View File

@@ -1571,6 +1571,18 @@ pub fn VisitExpr(
p.fn_only_data_visit.is_inside_async_arrow_fn = old_inside_async_arrow_fn;
p.fn_or_arrow_data_visit = std.mem.bytesToValue(@TypeOf(p.fn_or_arrow_data_visit), &old_fn_or_arrow_data);
// Mark arrows that are candidates for the `() => obj.method()` to
// `obj.method.bind(obj)` transformation. The actual transformation is
// deferred to the printer, which can check if the captured symbol was
// assigned to anywhere in the code.
if (p.options.features.minify_syntax and
e_.args.len == 0 and
!e_.is_async and
e_.body.stmts.len == 1)
{
p.tryMarkArrowForBindCallTransform(e_);
}
if (react_hook_data) |*hook| try_mark_hook: {
const stmts = p.nearest_stmt_list orelse break :try_mark_hook;
bun.handleOom(stmts.append(p.getReactRefreshHookSignalDecl(hook.signature_cb)));

View File

@@ -2565,6 +2565,26 @@ fn NewPrinter(
}
},
.e_arrow => |e| {
// Optimization: Convert `() => obj.method()` to `obj.method.bind(obj)`
// when the receiver symbol was never assigned to.
if (e.bind_call_target_ref) |target_ref| {
if (p.symbols().get(target_ref)) |symbol| {
// Only transform if the symbol was never assigned to.
// For const/unbound, has_been_assigned_to is always false.
// For hoisted (function params, var), it's true if assigned anywhere.
if (!symbol.has_been_assigned_to) {
// Get the call expression from the body
const call = e.body.stmts[0].data.s_return.value.?.data.e_call;
// Print: target.method.bind(target)
p.printExpr(call.target, .postfix, ExprFlag.Set{});
p.print(".bind(");
p.printSymbol(target_ref);
p.print(")");
return;
}
}
}
const wrap = level.gte(.assign);
if (wrap) {

View File

@@ -1192,4 +1192,258 @@ describe("bundler", () => {
stdout: "object\nobject\nobject",
},
});
// Arrow to bind optimization tests
itBundled("minify/ArrowToBindConstIdentifier", {
files: {
"/entry.js": /* js */ `
const obj = { value: 42, method() { return this.value; } };
const fn = () => obj.method();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should transform () => obj.method() to obj.method.bind(obj)
expect(code).toContain(".bind(");
expect(code).not.toMatch(/\(\)\s*=>\s*\w+\.\w+\(\)/);
},
run: {
stdout: "42",
},
});
itBundled("minify/ArrowToBindNoTransformUnboundGlobal", {
files: {
"/entry.js": /* js */ `
const fn = () => console.log();
fn();
console.log("done");
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform unbound globals - they could be reassigned externally
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\(\)\s*=>/);
},
run: {
stdout: "\ndone",
},
});
itBundled("minify/ArrowToBindComputedProperty", {
files: {
"/entry.js": /* js */ `
const obj = { myMethod() { return this.value; }, value: 99 };
const key = "myMethod";
const fn = () => obj[key]();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should transform () => obj[key]() to obj[key].bind(obj)
expect(code).toContain(".bind(");
},
run: {
stdout: "99",
},
});
itBundled("minify/ArrowToBindNoTransformLetReassigned", {
files: {
"/entry.js": /* js */ `
let obj = { method() { return "first"; } };
const fn = () => obj.method();
obj = { method() { return "second"; } };
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because obj is reassigned
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\(\)\s*=>/);
},
run: {
stdout: "second",
},
});
itBundled("minify/ArrowToBindLetNotReassigned", {
files: {
"/entry.js": /* js */ `
let obj = { value: 42, method() { return this.value; } };
const fn = () => obj.method();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should transform because obj is not reassigned
expect(code).toContain(".bind(");
},
run: {
stdout: "42",
},
});
itBundled("minify/ArrowToBindFunctionParam", {
files: {
"/entry.js": /* js */ `
function fetch(init) {
return () => init.signal();
}
console.log(fetch({ signal: () => 555 })());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should transform because init is not reassigned
expect(code).toContain(".bind(");
},
run: {
stdout: "555",
},
});
itBundled("minify/ArrowToBindFunctionParamReassigned", {
files: {
"/entry.js": /* js */ `
function fetch(init) {
const cb = () => init.signal();
init = { signal: () => 666 };
return cb;
}
console.log(fetch({ signal: () => 555 })());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because init is reassigned
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\(\)\s*=>/);
},
run: {
stdout: "666",
},
});
itBundled("minify/ArrowToBindNoTransformWithArgs", {
files: {
"/entry.js": /* js */ `
const obj = { method(x) { return x * 2; } };
const fn = () => obj.method(21);
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because the call has arguments
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\(\)\s*=>/);
},
run: {
stdout: "42",
},
});
itBundled("minify/ArrowToBindNoTransformArrowWithParams", {
files: {
"/entry.js": /* js */ `
const obj = { method() { return 123; } };
const fn = (x) => obj.method();
console.log(fn(1));
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because the arrow has parameters
expect(code).not.toContain(".bind(");
},
run: {
stdout: "123",
},
});
itBundled("minify/ArrowToBindNoTransformAsync", {
files: {
"/entry.js": /* js */ `
const obj = { async method() { return 456; } };
const fn = async () => obj.method();
fn().then(console.log);
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because the arrow is async
expect(code).not.toContain(".bind(");
expect(code).toMatch(/async\s*\(\)\s*=>/);
},
run: {
stdout: "456",
},
});
itBundled("minify/ArrowToBindNoTransformOptionalChain", {
files: {
"/entry.js": /* js */ `
const obj = { method() { return 789; } };
const fn = () => obj?.method();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because of optional chaining
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\?\.\w+\(\)/);
},
run: {
stdout: "789",
},
});
itBundled("minify/ArrowToBindNoTransformOptionalCall", {
files: {
"/entry.js": /* js */ `
const obj = { method() { return 321; } };
const fn = () => obj.method?.();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because of optional call
expect(code).not.toContain(".bind(");
},
run: {
stdout: "321",
},
});
});