diff --git a/crates/oxc_minifier/src/compressor.rs b/crates/oxc_minifier/src/compressor.rs index 2f2cc96f83cf5..8f198d95ea32d 100644 --- a/crates/oxc_minifier/src/compressor.rs +++ b/crates/oxc_minifier/src/compressor.rs @@ -31,7 +31,8 @@ impl<'a> Compressor<'a> { let normalize_options = NormalizeOptions { convert_while_to_fors: true, convert_const_to_let: true }; Normalize::new(normalize_options, self.options).build(program, &mut ctx); - PeepholeOptimizations::new(self.options.target).run_in_loop(program, &mut ctx); + PeepholeOptimizations::new(self.options.target, self.options.keep_names) + .run_in_loop(program, &mut ctx); LatePeepholeOptimizations::new(self.options.target).build(program, &mut ctx); } diff --git a/crates/oxc_minifier/src/lib.rs b/crates/oxc_minifier/src/lib.rs index d18b94bf28a8a..0837a7d908cdf 100644 --- a/crates/oxc_minifier/src/lib.rs +++ b/crates/oxc_minifier/src/lib.rs @@ -18,7 +18,9 @@ use oxc_semantic::{Scoping, SemanticBuilder, Stats}; pub use oxc_mangler::MangleOptions; -pub use crate::{compressor::Compressor, options::CompressOptions}; +pub use crate::{ + compressor::Compressor, options::CompressOptions, options::CompressOptionsKeepNames, +}; #[derive(Debug, Clone, Copy)] pub struct MinifierOptions { diff --git a/crates/oxc_minifier/src/options.rs b/crates/oxc_minifier/src/options.rs index 79feec1d22369..abdb5aa80d2c4 100644 --- a/crates/oxc_minifier/src/options.rs +++ b/crates/oxc_minifier/src/options.rs @@ -12,6 +12,9 @@ pub struct CompressOptions { /// Default `ESTarget::ESNext` pub target: ESTarget, + /// Keep function / class names. + pub keep_names: CompressOptionsKeepNames, + /// Remove `debugger;` statements. /// /// Default `true` @@ -32,10 +35,55 @@ impl Default for CompressOptions { impl CompressOptions { pub fn smallest() -> Self { - Self { target: ESTarget::ESNext, drop_debugger: true, drop_console: true } + Self { + target: ESTarget::ESNext, + keep_names: CompressOptionsKeepNames::all_false(), + drop_debugger: true, + drop_console: true, + } } pub fn safest() -> Self { - Self { target: ESTarget::ESNext, drop_debugger: false, drop_console: false } + Self { + target: ESTarget::ESNext, + keep_names: CompressOptionsKeepNames::all_true(), + drop_debugger: false, + drop_console: false, + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct CompressOptionsKeepNames { + /// Keep function names so that `Function.prototype.name` is preserved. + /// + /// This does not guarantee that the `undefined` name is preserved. + /// + /// Default `false` + pub function: bool, + + /// Keep class names so that `Class.prototype.name` is preserved. + /// + /// This does not guarantee that the `undefined` name is preserved. + /// + /// Default `false` + pub class: bool, +} + +impl CompressOptionsKeepNames { + pub fn all_false() -> Self { + Self { function: false, class: false } + } + + pub fn all_true() -> Self { + Self { function: true, class: true } + } + + pub fn function_only() -> Self { + Self { function: true, class: false } + } + + pub fn class_only() -> Self { + Self { function: false, class: true } } } diff --git a/crates/oxc_minifier/src/peephole/minimize_conditional_expression.rs b/crates/oxc_minifier/src/peephole/minimize_conditional_expression.rs index 33af746a3e8e6..b96cf4b15e1ad 100644 --- a/crates/oxc_minifier/src/peephole/minimize_conditional_expression.rs +++ b/crates/oxc_minifier/src/peephole/minimize_conditional_expression.rs @@ -423,10 +423,11 @@ impl<'a> PeepholeOptimizations { if !matches!(consequent.left, AssignmentTarget::AssignmentTargetIdentifier(_)) { return None; } - // TODO: need this condition when `keep_fnames` is introduced - // if consequent.right.is_anonymous_function_definition() { - // return None; - // } + // NOTE: if the right hand side is an anonymous function, applying this compression will + // set the `name` property of that function. + // Since codes relying on the fact that function's name is undefined should be rare, + // we do this compression even if `keep_names` is enabled. + if consequent.operator != AssignmentOperator::Assign || consequent.operator != alternate.operator || consequent.left.content_ne(&alternate.left) diff --git a/crates/oxc_minifier/src/peephole/minimize_conditions.rs b/crates/oxc_minifier/src/peephole/minimize_conditions.rs index d4a373a155c37..b67b344d100ff 100644 --- a/crates/oxc_minifier/src/peephole/minimize_conditions.rs +++ b/crates/oxc_minifier/src/peephole/minimize_conditions.rs @@ -187,7 +187,10 @@ impl<'a> PeepholeOptimizations { } let Expression::LogicalExpression(logical_expr) = &mut expr.right else { return false }; - let new_op = logical_expr.operator.to_assignment_operator(); + // NOTE: if the right hand side is an anonymous function, applying this compression will + // set the `name` property of that function. + // Since codes relying on the fact that function's name is undefined should be rare, + // we do this compression even if `keep_names` is enabled. let ( AssignmentTarget::AssignmentTargetIdentifier(write_id_ref), @@ -202,6 +205,7 @@ impl<'a> PeepholeOptimizations { return false; } + let new_op = logical_expr.operator.to_assignment_operator(); expr.operator = new_op; expr.right = ctx.ast.move_expression(&mut logical_expr.right); true @@ -1317,11 +1321,8 @@ mod test { // a.b might have a side effect test_same("x ? a.b = 0 : a.b = 1"); - // `a = x ? () => 'a' : () => 'b'` does not set the name property of the function - // TODO: need to pass these tests when `keep_fnames` are introduced - // test_same("x ? a = () => 'a' : a = () => 'b'"); - // test_same("x ? a = function () { return 'a' } : a = function () { return 'b' }"); - // test_same("x ? a = class { foo = 'a' } : a = class { foo = 'b' }"); + // `a = x ? () => 'a' : () => 'b'` does not set the name property of the function, but we ignore that difference + test("x ? a = () => 'a' : a = () => 'b'", "a = x ? () => 'a' : () => 'b'"); // for non `=` operators, `GetValue(lref)` is called before `Evaluation of AssignmentExpression` // so cannot be fold to `a += x ? 0 : 1` @@ -1376,7 +1377,7 @@ mod test { test("x && (x = g())", "x &&= g()"); test("x ?? (x = g())", "x ??= g()"); - // `||=`, `&&=`, `??=` sets the name property of the function + // `||=`, `&&=`, `??=` sets the name property of the function, but we ignore that difference // Example case: `let f = false; f || (f = () => {}); console.log(f.name)` test("x || (x = () => 'a')", "x ||= () => 'a'"); @@ -1424,6 +1425,11 @@ mod test { // This case is not supported, since the minifier does not support with statements // test_same("var x; with (z) { x = x || 1 }"); + // `||=`, `&&=`, `??=` sets the name property of the function, while `= x || y` does not + // but we ignore that difference + // Example case: `let f = false; f = f || (() => {}); console.log(f.name)` + test("var x; x = x || (() => 'a')", "var x; x ||= (() => 'a')"); + let target = ESTarget::ES2019; let code = "var x; x = x || 1"; assert_eq!( diff --git a/crates/oxc_minifier/src/peephole/mod.rs b/crates/oxc_minifier/src/peephole/mod.rs index 37f77aa604ea8..ddfe59a8fc82b 100644 --- a/crates/oxc_minifier/src/peephole/mod.rs +++ b/crates/oxc_minifier/src/peephole/mod.rs @@ -25,12 +25,13 @@ use oxc_data_structures::stack::NonEmptyStack; use oxc_syntax::{es_target::ESTarget, scope::ScopeId}; use oxc_traverse::{ReusableTraverseCtx, Traverse, TraverseCtx, traverse_mut_with_ctx}; -use crate::ctx::Ctx; +use crate::{ctx::Ctx, options::CompressOptionsKeepNames}; pub use self::normalize::{Normalize, NormalizeOptions}; pub struct PeepholeOptimizations { target: ESTarget, + keep_names: CompressOptionsKeepNames, /// Walk the ast in a fixed point loop until no changes are made. /// `prev_function_changed`, `functions_changed` and `current_function` track changes @@ -45,9 +46,10 @@ pub struct PeepholeOptimizations { } impl<'a> PeepholeOptimizations { - pub fn new(target: ESTarget) -> Self { + pub fn new(target: ESTarget, keep_names: CompressOptionsKeepNames) -> Self { Self { target, + keep_names, iteration: 0, prev_functions_changed: FxHashSet::default(), functions_changed: FxHashSet::default(), @@ -363,7 +365,12 @@ pub struct DeadCodeElimination { impl<'a> DeadCodeElimination { pub fn new() -> Self { - Self { inner: PeepholeOptimizations::new(ESTarget::ESNext) } + Self { + inner: PeepholeOptimizations::new( + ESTarget::ESNext, + CompressOptionsKeepNames::all_true(), + ), + } } pub fn build(&mut self, program: &mut Program<'a>, ctx: &mut ReusableTraverseCtx<'a>) { diff --git a/crates/oxc_minifier/src/peephole/substitute_alternate_syntax.rs b/crates/oxc_minifier/src/peephole/substitute_alternate_syntax.rs index 9231172f61f21..95eef6eb0349f 100644 --- a/crates/oxc_minifier/src/peephole/substitute_alternate_syntax.rs +++ b/crates/oxc_minifier/src/peephole/substitute_alternate_syntax.rs @@ -971,6 +971,10 @@ impl<'a> PeepholeOptimizations { /// /// This compression is not safe if the code relies on `Function::name`. fn try_remove_name_from_functions(&mut self, func: &mut Function<'a>, ctx: Ctx<'a, '_>) { + if self.keep_names.function { + return; + } + if func.id.as_ref().is_some_and(|id| !ctx.scoping().symbol_is_used(id.symbol_id())) { func.id = None; self.mark_current_function_as_changed(); @@ -981,8 +985,12 @@ impl<'a> PeepholeOptimizations { /// /// e.g. `var a = class C {}` -> `var a = class {}` /// - /// This compression is not safe if the code relies on `Function::name`. + /// This compression is not safe if the code relies on `Class::name`. fn try_remove_name_from_classes(&mut self, class: &mut Class<'a>, ctx: Ctx<'a, '_>) { + if self.keep_names.class { + return; + } + if class.id.as_ref().is_some_and(|id| !ctx.scoping().symbol_is_used(id.symbol_id())) { class.id = None; self.mark_current_function_as_changed(); @@ -1206,9 +1214,16 @@ mod test { use crate::{ CompressOptions, + options::CompressOptionsKeepNames, tester::{run, test, test_same}, }; + fn test_same_keep_names(keep_names: CompressOptionsKeepNames, code: &str) { + let result = run(code, Some(CompressOptions { keep_names, ..CompressOptions::smallest() })); + let expected = run(code, None); + assert_eq!(result, expected, "\nfor source\n{code}\ngot\n{result}"); + } + #[test] fn test_fold_return_result() { test("function f(){return !1;}", "function f(){return !1}"); @@ -1858,8 +1873,10 @@ mod test { fn test_remove_name_from_expressions() { test("var a = function f() {}", "var a = function () {}"); test_same("var a = function f() { return f; }"); + test_same_keep_names(CompressOptionsKeepNames::function_only(), "var a = function f() {}"); test("var a = class C {}", "var a = class {}"); test_same("var a = class C { foo() { return C } }"); + test_same_keep_names(CompressOptionsKeepNames::class_only(), "var a = class C {}"); } #[test] diff --git a/crates/oxc_minifier/tests/peephole/mod.rs b/crates/oxc_minifier/tests/peephole/mod.rs index 8c5383affced3..21edb9b416949 100644 --- a/crates/oxc_minifier/tests/peephole/mod.rs +++ b/crates/oxc_minifier/tests/peephole/mod.rs @@ -4,7 +4,7 @@ mod esbuild; use oxc_minifier::CompressOptions; fn test(source_text: &str, expected: &str) { - let options = CompressOptions::safest(); + let options = CompressOptions { drop_debugger: false, ..CompressOptions::default() }; crate::test(source_text, expected, options); } diff --git a/napi/minify/index.d.ts b/napi/minify/index.d.ts index 16b90c18cb776..8daf831a49737 100644 --- a/napi/minify/index.d.ts +++ b/napi/minify/index.d.ts @@ -23,6 +23,8 @@ export interface CompressOptions { * @default 'esnext' */ target?: 'esnext' | 'es2015' | 'es2016' | 'es2017' | 'es2018' | 'es2019' | 'es2020' | 'es2021' | 'es2022' | 'es2023' | 'es2024' + /** Keep function / class names. */ + keepNames?: CompressOptionsKeepNames /** * Pass true to discard calls to `console.*`. * @@ -37,6 +39,25 @@ export interface CompressOptions { dropDebugger?: boolean } +export interface CompressOptionsKeepNames { + /** + * Keep function names so that `Function.prototype.name` is preserved. + * + * This does not guarantee that the `undefined` name is preserved. + * + * @default false + */ + function: boolean + /** + * Keep class names so that `Class.prototype.name` is preserved. + * + * This does not guarantee that the `undefined` name is preserved. + * + * @default false + */ + class: boolean +} + export interface MangleOptions { /** * Pass `true` to mangle names declared in the top level scope. diff --git a/napi/minify/src/options.rs b/napi/minify/src/options.rs index e26b4b0c6965d..ff2ddb3fe753f 100644 --- a/napi/minify/src/options.rs +++ b/napi/minify/src/options.rs @@ -23,6 +23,9 @@ pub struct CompressOptions { )] pub target: Option, + /// Keep function / class names. + pub keep_names: Option, + /// Pass true to discard calls to `console.*`. /// /// @default false @@ -36,7 +39,7 @@ pub struct CompressOptions { impl Default for CompressOptions { fn default() -> Self { - Self { target: None, drop_console: None, drop_debugger: Some(true) } + Self { target: None, keep_names: None, drop_console: None, drop_debugger: Some(true) } } } @@ -51,12 +54,36 @@ impl TryFrom<&CompressOptions> for oxc_minifier::CompressOptions { .map(|s| ESTarget::from_str(s)) .transpose()? .unwrap_or(default.target), + keep_names: o.keep_names.as_ref().map(Into::into).unwrap_or_default(), drop_console: o.drop_console.unwrap_or(default.drop_console), drop_debugger: o.drop_debugger.unwrap_or(default.drop_debugger), }) } } +#[napi(object)] +pub struct CompressOptionsKeepNames { + /// Keep function names so that `Function.prototype.name` is preserved. + /// + /// This does not guarantee that the `undefined` name is preserved. + /// + /// @default false + pub function: bool, + + /// Keep class names so that `Class.prototype.name` is preserved. + /// + /// This does not guarantee that the `undefined` name is preserved. + /// + /// @default false + pub class: bool, +} + +impl From<&CompressOptionsKeepNames> for oxc_minifier::CompressOptionsKeepNames { + fn from(o: &CompressOptionsKeepNames) -> Self { + oxc_minifier::CompressOptionsKeepNames { function: o.function, class: o.class } + } +} + #[napi(object)] #[derive(Default)] pub struct MangleOptions { diff --git a/napi/playground/src/lib.rs b/napi/playground/src/lib.rs index b4c6e785cc33f..a8d0c020da754 100644 --- a/napi/playground/src/lib.rs +++ b/napi/playground/src/lib.rs @@ -266,10 +266,10 @@ impl Oxc { CompressOptions { drop_console: compress_options.drop_console, drop_debugger: compress_options.drop_debugger, - ..CompressOptions::safest() + ..CompressOptions::default() } } else { - CompressOptions::safest() + CompressOptions::default() }), }; Minifier::new(options).build(&allocator, &mut program).scoping