Compare commits

...

4 Commits

Author SHA1 Message Date
Claude Bot
6441149674 chore: remove duplicate test and document constructability issue
- Remove duplicate ArrowToBindNoTransformWithCallArgs test (already covered
  by ArrowToBindNoTransformWithArgs)
- Add constructability to the list of reasons the optimization is disabled
  (arrows are not constructable but bound functions may be)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:45:10 +00:00
Claude Bot
e5c8da3c99 fix(minify): disable arrow-to-bind transform due to semantic issues
The arrow-to-bind transformation (`() => obj.method()` -> `obj.method.bind(obj)`)
can change semantics in several cases:

1. Property reassignment: If `obj.method` is reassigned after the arrow is
   created, the bound function would still reference the original method.

2. Getter properties: If `method` is a getter, bind() would only call the
   getter once at bind time, while the arrow calls it on each invocation.

3. Object escaping: The object could be modified by code we can't analyze.

This commit disables the optimization until proper property tracking is
implemented. Added tests to document these edge cases:
- ArrowToBindNoTransformPropertyReassigned
- ArrowToBindNoTransformGetterProperty

Also fixed test naming to use consistent NoTransform prefix pattern.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:38:43 +00:00
Claude Bot
55fe522910 test(minify): add tests for arrow-to-bind edge cases
Add tests to verify the arrow-to-bind transformation correctly handles:
- Arrow functions where target is 'this' (not transformed)
- Arrow functions where call has arguments (not transformed)
- Arrow functions using arguments[N] (not transformed)
- Arrow functions with new.target references (not transformed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:25:34 +00:00
Claude Bot
0b13ba1b7e 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>
2026-02-01 15:17:31 +00:00
6 changed files with 451 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,31 @@ 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.
///
/// NOTE: This optimization is currently DISABLED because it can change
/// semantics in cases where:
/// 1. The property (method) is reassigned after the arrow is created
/// 2. The property is a getter that returns different values on each access
/// 3. Constructability differs: arrows are not constructable but bound
/// functions derived from regular methods may be
///
/// To safely enable this optimization, we would need to track:
/// - Property assignments to the receiver object
/// - Whether the property is defined as a getter
/// - Whether the object escapes to code that could modify it
/// - Whether the arrow could be used with `new`
///
/// For now, we conservatively disable the transformation entirely.
pub fn tryMarkArrowForBindCallTransform(p: *P, arrow: *E.Arrow) void {
_ = p;
_ = arrow;
// Disabled - see comment above for rationale
return;
}
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,391 @@ describe("bundler", () => {
stdout: "object\nobject\nobject",
},
});
// Arrow to bind optimization tests
// NOTE: The arrow-to-bind transformation is currently DISABLED because
// it can change semantics when properties are reassigned or are getters.
// These tests verify the transformation is NOT applied.
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");
// Optimization disabled - should NOT transform
expect(code).not.toContain(".bind(");
},
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");
// Optimization disabled - should NOT transform
expect(code).not.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");
// Optimization disabled - should NOT transform
expect(code).not.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");
// Optimization disabled - should NOT transform
expect(code).not.toContain(".bind(");
},
run: {
stdout: "555",
},
});
itBundled("minify/ArrowToBindNoTransformFunctionParamReassigned", {
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",
},
});
itBundled("minify/ArrowToBindNoTransformThisTarget", {
files: {
"/entry.js": /* js */ `
class MyClass {
value = 42;
method() { return this.value; }
getMethod() {
// Arrow captures 'this' from enclosing context
// Cannot transform because 'this' is not an identifier
return () => this.method();
}
}
const obj = new MyClass();
const fn = obj.getMethod();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because target is 'this', not a bound identifier
expect(code).not.toContain(".bind(");
},
run: {
stdout: "42",
},
});
itBundled("minify/ArrowToBindNoTransformArgumentsAccess", {
files: {
"/entry.js": /* js */ `
const obj = { greet(name) { return "Hello, " + name; } };
function test() {
// Arrow that uses arguments from enclosing function
const fn = () => obj.greet(arguments[0]);
return fn();
}
console.log(test("World"));
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because call has arguments
expect(code).not.toContain(".bind(");
},
run: {
stdout: "Hello, World",
},
});
itBundled("minify/ArrowToBindNoTransformNewTarget", {
files: {
"/entry.js": /* js */ `
const obj = {
check() { return "method called"; }
};
function MyConstructor() {
// Arrow that references new.target
this.fn = () => {
if (new.target) {
return obj.check();
}
return "no new.target";
};
}
const instance = new MyConstructor();
console.log(instance.fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform - arrow body has multiple statements (if/else logic)
// and references new.target which arrows inherit from enclosing scope
expect(code).not.toContain("obj.check.bind(");
},
run: {
stdout: "method called",
},
});
itBundled("minify/ArrowToBindNoTransformPropertyReassigned", {
files: {
"/entry.js": /* js */ `
const obj = { method() { return "original"; } };
const fn = () => obj.method();
obj.method = () => "reassigned";
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because obj.method is reassigned after the arrow is created
// If we transform to obj.method.bind(obj), it would capture the original method
// But the arrow should call the reassigned method
expect(code).not.toContain(".bind(");
},
run: {
stdout: "reassigned",
},
});
itBundled("minify/ArrowToBindNoTransformGetterProperty", {
files: {
"/entry.js": /* js */ `
let callCount = 0;
const obj = {
get method() {
callCount++;
return () => "from getter " + callCount;
}
};
const fn = () => obj.method();
console.log(fn());
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because method is a getter
// bind() would only call the getter once, but arrow calls it each time
expect(code).not.toContain(".bind(");
},
run: {
stdout: "from getter 1\nfrom getter 2",
},
});
});