mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
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:
@@ -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 = .{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user