feat(bundler): tree-shake Object.defineProperty(exports, ...) in cjs2esm

Recognize `Object.defineProperty(exports, 'name', { value: ... })` and
`Object.defineProperty(exports, 'name', { get: function() { return ...; } })`
patterns commonly emitted by TypeScript/Babel transpilers, and rewrite them
into `exports.name = value` assignments so the bundler can tree-shake
unused CJS exports.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sosuke Suzuki
2026-01-22 15:45:52 +09:00
parent 1da41b7f91
commit ca9c00f34e
3 changed files with 352 additions and 0 deletions

View File

@@ -3787,6 +3787,169 @@ pub fn NewParser_(
p.commonjs_named_exports_deoptimized = true;
}
/// Attempts to recognize an `Object.defineProperty(exports, 'name', descriptor)` call
/// and convert it into an equivalent `exports.name = value` assignment expression.
/// Returns the rewritten expression if successful, or null if the pattern doesn't match.
///
/// Recognized descriptor patterns:
/// - `{ value: expr }` or `{ value: expr, enumerable: true, ... }`
/// - `{ enumerable: true, get: function() { return expr; } }`
///
/// Skipped cases:
/// - Export name is `"__esModule"`
/// - Descriptor contains a `set` property
/// - Descriptor has `enumerable: false` explicitly
pub fn tryExtractDefinePropertyExport(p: *P, e_: *E.Call, call_loc: logger.Loc) ?Expr {
// Must have exactly 3 arguments
if (e_.args.len != 3) return null;
const args = e_.args.slice();
// Target must be Object.defineProperty
const dot = e_.target.data.as(.e_dot) orelse return null;
if (!strings.eqlComptime(dot.name, "defineProperty")) return null;
const dot_target_id = dot.target.data.as(.e_identifier) orelse return null;
const sym = p.symbols.items[dot_target_id.ref.innerIndex()];
if (sym.kind != .unbound or !strings.eqlComptime(sym.original_name, "Object")) return null;
// First arg must be `exports` ref or `module.exports`
// After visiting, `module.exports` may be rewritten to E.Special.module_exports
const first_arg = args[0];
const is_module_exports = brk: {
if (first_arg.data == .e_identifier) {
if (first_arg.data.e_identifier.ref.eql(p.exports_ref)) break :brk false;
}
if (first_arg.data == .e_special and first_arg.data.e_special == .module_exports) {
break :brk true;
}
if (first_arg.data.as(.e_dot)) |fa_dot| {
if (fa_dot.target.data == .e_identifier and
fa_dot.target.data.e_identifier.ref.eql(p.module_ref) and
strings.eqlComptime(fa_dot.name, "exports"))
{
break :brk true;
}
}
return null;
};
// Second arg must be a string literal (the export name)
const name_str = args[1].data.as(.e_string) orelse return null;
if (!name_str.isUTF8()) return null;
const name = name_str.data;
// Third arg must be an object literal (the descriptor)
const descriptor = args[2].data.as(.e_object) orelse return null;
// Check for `set` property - if present, skip (not safe to convert)
// Also check for explicit `enumerable: false`
var has_enumerable_false = false;
for (descriptor.properties.slice()) |prop| {
const key = prop.key orelse continue;
const key_str = key.data.as(.e_string) orelse continue;
if (key_str.eqlComptime("set")) return null;
if (key_str.eqlComptime("enumerable")) {
if (prop.value) |val| {
if (val.data == .e_boolean and !val.data.e_boolean.value) {
has_enumerable_false = true;
}
}
}
}
if (has_enumerable_false) return null;
// Try to extract the value from the descriptor
const export_value = p.extractDefinePropertyValue(descriptor) orelse return null;
// Register the named export (same logic as maybe.zig for exports.X)
const named_export_entry = p.commonjs_named_exports.getOrPut(p.allocator, name) catch unreachable;
if (!named_export_entry.found_existing) {
const new_ref = p.newSymbol(
.other,
std.fmt.allocPrint(p.allocator, "${f}", .{bun.fmt.fmtIdentifier(name)}) catch unreachable,
) catch unreachable;
bun.handleOom(p.module_scope.generated.append(p.allocator, new_ref));
named_export_entry.value_ptr.* = .{
.loc_ref = LocRef{
.loc = args[1].loc,
.ref = new_ref,
},
.needs_decl = true,
};
if (p.commonjs_named_exports_needs_conversion == std.math.maxInt(u32))
p.commonjs_named_exports_needs_conversion = @as(u32, @truncate(p.commonjs_named_exports.count() - 1));
}
const ref = named_export_entry.value_ptr.*.loc_ref.ref.?;
if (is_module_exports) {
p.recordUsage(ref);
} else {
p.ignoreUsage(p.exports_ref);
p.recordUsage(ref);
}
// Rewrite as: exports.name = value (using E.Binary with E.CommonJSExportIdentifier)
const cjs_id = p.newExpr(
E.CommonJSExportIdentifier{
.ref = ref,
.base = if (is_module_exports) .module_dot_exports else .exports,
},
args[1].loc,
);
return p.newExpr(
E.Binary{
.op = .bin_assign,
.left = cjs_id,
.right = export_value,
},
call_loc,
);
}
/// Extracts the export value from a property descriptor object.
/// Handles two patterns:
/// - `{ value: expr }` -> returns expr
/// - `{ get: function() { return expr; } }` -> returns expr
fn extractDefinePropertyValue(p: *P, descriptor: *const E.Object) ?Expr {
_ = p;
var value_expr: ?Expr = null;
var get_expr: ?Expr = null;
for (descriptor.properties.slice()) |prop| {
const key = prop.key orelse continue;
const key_str = key.data.as(.e_string) orelse continue;
if (key_str.eqlComptime("value")) {
value_expr = prop.value;
} else if (key_str.eqlComptime("get")) {
get_expr = prop.value;
}
}
// Prefer `value` pattern
if (value_expr) |val| {
return val;
}
// Try `get` pattern: get: function() { return expr; }
if (get_expr) |get_val| {
const func = switch (get_val.data) {
.e_function => |f| f,
else => return null,
};
// Must have no parameters
if (func.func.args.len != 0) return null;
// Body must be a single return statement
if (func.func.body.stmts.len != 1) return null;
const ret_stmt = func.func.body.stmts[0];
if (ret_stmt.data != .s_return) return null;
return ret_stmt.data.s_return.value;
}
return null;
}
pub fn maybeKeepExprSymbolName(p: *P, expr: Expr, original_name: string, was_anonymous_named_expr: bool) Expr {
return if (was_anonymous_named_expr) p.keepExprSymbolName(expr, original_name) else expr;
}

View File

@@ -1509,6 +1509,19 @@ pub fn VisitExpr(
}
};
// Recognize Object.defineProperty(exports, 'name', { value: ... }) pattern
// generated by TypeScript/Babel transpilers and convert to exports.name = value
// to enable tree-shaking.
if (p.shouldUnwrapCommonJSToESM() and
!p.commonjs_named_exports_deoptimized and
!p.is_control_flow_dead and
p.current_scope == p.module_scope)
{
if (p.tryExtractDefinePropertyExport(e_, expr.loc)) |rewritten| {
return rewritten;
}
}
return expr;
}
pub fn e_new(p: *P, expr: Expr, _: ExprIn) Expr {

View File

@@ -392,4 +392,180 @@ describe("bundler", () => {
stdout: '[[{"xyz":456},456],[{"xyz":123},123],[{"xyz":456},456],[{"xyz":123},123]]',
},
});
// Object.defineProperty(exports, ...) pattern tests
itBundled("cjs2esm/DefinePropertyValuePattern", {
files: {
"/entry.js": /* js */ `
import { foo } from 'lib';
console.log(foo);
`,
"/node_modules/lib/index.js": /* js */ `
Object.defineProperty(exports, "__esModule", { value: true });
Object.defineProperty(exports, "foo", { value: "hello" });
`,
},
cjs2esm: true,
run: {
stdout: "hello",
},
});
itBundled("cjs2esm/DefinePropertyValueTreeShaking", {
files: {
"/entry.js": /* js */ `
import { foo } from 'lib';
console.log(foo);
`,
"/node_modules/lib/index.js": /* js */ `
Object.defineProperty(exports, "__esModule", { value: true });
Object.defineProperty(exports, "foo", { value: "kept" });
Object.defineProperty(exports, "bar", { value: "remove_me" });
`,
},
cjs2esm: true,
dce: true,
treeShaking: true,
run: {
stdout: "kept",
},
});
itBundled("cjs2esm/DefinePropertyGetterPattern", {
files: {
"/entry.js": /* js */ `
import { pi } from 'lib';
console.log(pi);
`,
"/node_modules/lib/index.js": /* js */ `
Object.defineProperty(exports, "__esModule", { value: true });
var _pi = 3.14;
Object.defineProperty(exports, "pi", {
enumerable: true,
get: function() { return _pi; }
});
`,
},
cjs2esm: true,
run: {
stdout: "3.14",
},
});
itBundled("cjs2esm/DefinePropertyGetterTreeShaking", {
files: {
"/entry.js": /* js */ `
import { used } from 'lib';
console.log(used);
`,
"/node_modules/lib/index.js": /* js */ `
Object.defineProperty(exports, "__esModule", { value: true });
var _used = "yes";
var _unused = "remove_me";
Object.defineProperty(exports, "used", {
enumerable: true,
get: function() { return _used; }
});
Object.defineProperty(exports, "unused", {
enumerable: true,
get: function() { return _unused; }
});
`,
},
cjs2esm: true,
dce: true,
treeShaking: true,
run: {
stdout: "yes",
},
});
itBundled("cjs2esm/DefinePropertyMixedWithDirectExports", {
files: {
"/entry.js": /* js */ `
import { foo, bar } from 'lib';
console.log(foo, bar);
`,
"/node_modules/lib/index.js": /* js */ `
Object.defineProperty(exports, "__esModule", { value: true });
Object.defineProperty(exports, "foo", { value: "from_define" });
exports.bar = "from_direct";
`,
},
cjs2esm: true,
run: {
stdout: "from_define from_direct",
},
});
itBundled("cjs2esm/DefinePropertyModuleExports", {
files: {
"/entry.js": /* js */ `
import { foo } from 'lib';
console.log(foo);
`,
"/node_modules/lib/index.js": /* js */ `
Object.defineProperty(module.exports, "__esModule", { value: true });
Object.defineProperty(module.exports, "foo", { value: "via_module" });
`,
},
cjs2esm: true,
run: {
stdout: "via_module",
},
});
itBundled("cjs2esm/DefinePropertyEnumerableFalseNotConverted", {
files: {
"/entry.js": /* js */ `
const lib = require('lib');
console.log(lib.foo);
`,
"/node_modules/lib/index.js": /* js */ `
Object.defineProperty(exports, "foo", { enumerable: false, value: "hidden" });
`,
},
run: {
stdout: "hidden",
},
});
itBundled("cjs2esm/DefinePropertyWithSetterNotConverted", {
files: {
"/entry.js": /* js */ `
const lib = require('lib');
console.log(lib.foo);
`,
"/node_modules/lib/index.js": /* js */ `
var _foo = "with_setter";
Object.defineProperty(exports, "foo", {
get: function() { return _foo; },
set: function(v) { _foo = v; }
});
`,
},
run: {
stdout: "with_setter",
},
});
itBundled("cjs2esm/DefinePropertyMultipleExports", {
files: {
"/entry.js": /* js */ `
import { a, b, c } from 'lib';
console.log(a, b, c);
`,
"/node_modules/lib/index.js": /* js */ `
Object.defineProperty(exports, "__esModule", { value: true });
Object.defineProperty(exports, "a", { value: 1 });
Object.defineProperty(exports, "b", { enumerable: true, value: 2 });
Object.defineProperty(exports, "c", { enumerable: true, get: function() { return 3; } });
`,
},
cjs2esm: true,
run: {
stdout: "1 2 3",
},
});
});