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", + }, + }); });