From 0b13ba1b7e954f1464214e4a55f5d5cb72c2a64c Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 1 Feb 2026 14:18:58 +0000 Subject: [PATCH] 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 --- src/ast/E.zig | 6 + src/ast/P.zig | 1 + src/ast/visit.zig | 47 +++++ src/ast/visitExpr.zig | 12 ++ src/js_printer.zig | 20 +++ test/bundler/bundler_minify.test.ts | 254 ++++++++++++++++++++++++++++ 6 files changed, 340 insertions(+) diff --git a/src/ast/E.zig b/src/ast/E.zig index 02223acc9c..3b90ec5f15 100644 --- a/src/ast/E.zig +++ b/src/ast/E.zig @@ -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 = .{ diff --git a/src/ast/P.zig b/src/ast/P.zig index 9d2b4e126e..9de6ec32a3 100644 --- a/src/ast/P.zig +++ b/src/ast/P.zig @@ -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; diff --git a/src/ast/visit.zig b/src/ast/visit.zig index e3e79a0b9c..05ec3dcd31 100644 --- a/src/ast/visit.zig +++ b/src/ast/visit.zig @@ -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; diff --git a/src/ast/visitExpr.zig b/src/ast/visitExpr.zig index cc8bcb1b49..d9f9f0a2d3 100644 --- a/src/ast/visitExpr.zig +++ b/src/ast/visitExpr.zig @@ -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))); diff --git a/src/js_printer.zig b/src/js_printer.zig index 77f839e2f3..dc208ca990 100644 --- a/src/js_printer.zig +++ b/src/js_printer.zig @@ -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) { diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 66fa487207..00d3c4b327 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -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", + }, + }); });