Skip to content

Commit bdf3a98

Browse files
authored
feat(es/minifier): Implement partial evaluation of array join (#10758)
1 parent 4304f91 commit bdf3a98

File tree

14 files changed

+343
-176
lines changed

14 files changed

+343
-176
lines changed

crates/swc_ecma_minifier/src/compress/pure/misc.rs

Lines changed: 300 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ fn collect_exprs_from_object(obj: &mut ObjectLit) -> Vec<Box<Expr>> {
9595
exprs
9696
}
9797

98+
#[derive(Debug)]
99+
enum GroupType<'a> {
100+
Literals(Vec<&'a ExprOrSpread>),
101+
Expression(&'a ExprOrSpread),
102+
}
103+
98104
impl Pure<'_> {
99105
/// `a = a + 1` => `a += 1`.
100106
pub(super) fn compress_bin_assignment_to_left(&mut self, e: &mut AssignExpr) {
@@ -501,6 +507,19 @@ impl Pure<'_> {
501507
return;
502508
}
503509

510+
// Handle empty array case first
511+
if arr.elems.is_empty() {
512+
report_change!("Compressing empty array.join()");
513+
self.changed = true;
514+
*e = Lit::Str(Str {
515+
span: call.span,
516+
raw: None,
517+
value: atom!(""),
518+
})
519+
.into();
520+
return;
521+
}
522+
504523
let cannot_join_as_str_lit = arr
505524
.elems
506525
.iter()
@@ -520,11 +539,19 @@ impl Pure<'_> {
520539
return;
521540
}
522541

523-
if !self.options.unsafe_passes {
542+
// Try partial optimization (grouping consecutive literals)
543+
if let Some(new_expr) =
544+
self.compress_array_join_partial(arr.span, &mut arr.elems, &separator)
545+
{
546+
self.changed = true;
547+
report_change!("Compressing array.join() with partial optimization");
548+
*e = new_expr;
524549
return;
525550
}
526551

527-
// TODO: Partial join
552+
if !self.options.unsafe_passes {
553+
return;
554+
}
528555

529556
if arr
530557
.elems
@@ -625,6 +652,277 @@ impl Pure<'_> {
625652
.into()
626653
}
627654

655+
/// Performs partial optimization on array.join() when there are mixed
656+
/// literals and expressions. Groups consecutive literals into string
657+
/// concatenations.
658+
fn compress_array_join_partial(
659+
&mut self,
660+
_span: Span,
661+
elems: &mut Vec<Option<ExprOrSpread>>,
662+
separator: &str,
663+
) -> Option<Expr> {
664+
if !self.options.evaluate {
665+
return None;
666+
}
667+
668+
// Check if we have any non-literal elements
669+
let has_non_literals = elems.iter().flatten().any(|elem| match &*elem.expr {
670+
Expr::Lit(Lit::Str(..) | Lit::Num(..) | Lit::Null(..)) => false,
671+
e if is_pure_undefined(self.expr_ctx, e) => false,
672+
_ => true,
673+
});
674+
675+
if !has_non_literals {
676+
return None; // Pure literal case will be handled elsewhere
677+
}
678+
679+
// For non-empty separators, only optimize if we have at least 2 consecutive
680+
// literals This prevents infinite loop and ensures meaningful
681+
// optimization
682+
if !separator.is_empty() {
683+
let mut consecutive_literals = 0;
684+
let mut max_consecutive = 0;
685+
686+
for elem in elems.iter().flatten() {
687+
let is_literal = match &*elem.expr {
688+
Expr::Lit(Lit::Str(..) | Lit::Num(..) | Lit::Null(..)) => true,
689+
e if is_pure_undefined(self.expr_ctx, e) => true,
690+
_ => false,
691+
};
692+
693+
if is_literal {
694+
consecutive_literals += 1;
695+
max_consecutive = max_consecutive.max(consecutive_literals);
696+
} else {
697+
consecutive_literals = 0;
698+
}
699+
}
700+
701+
if max_consecutive < 2 {
702+
return None;
703+
}
704+
705+
// Only optimize for single-character separators to avoid bloating the code
706+
// Long separators like "really-long-separator" should not be optimized
707+
if separator.len() > 1 {
708+
return None;
709+
}
710+
711+
// For comma separator, require a higher threshold to avoid infinite loops
712+
if separator == "," && max_consecutive < 6 {
713+
return None;
714+
}
715+
} else {
716+
// For empty string joins, optimize more aggressively since we're
717+
// doing string concatenation We can always optimize
718+
// these as long as there are mixed expressions and literals
719+
}
720+
721+
// Group consecutive literals and create a string concatenation expression
722+
let mut groups = Vec::new();
723+
let mut current_group = Vec::new();
724+
725+
for elem in elems.iter().flatten() {
726+
let is_literal = match &*elem.expr {
727+
Expr::Lit(Lit::Str(..) | Lit::Num(..) | Lit::Null(..)) => true,
728+
e if is_pure_undefined(self.expr_ctx, e) => true,
729+
_ => false,
730+
};
731+
732+
if is_literal {
733+
current_group.push(elem);
734+
} else {
735+
if !current_group.is_empty() {
736+
groups.push(GroupType::Literals(current_group));
737+
current_group = Vec::new();
738+
}
739+
groups.push(GroupType::Expression(elem));
740+
}
741+
}
742+
743+
if !current_group.is_empty() {
744+
groups.push(GroupType::Literals(current_group));
745+
}
746+
747+
// If we don't have any grouped literals, no optimization possible
748+
if groups.iter().all(|g| matches!(g, GroupType::Expression(_))) {
749+
return None;
750+
}
751+
752+
// Handle different separators
753+
let is_string_concat = separator.is_empty();
754+
755+
if is_string_concat {
756+
// Convert to string concatenation
757+
let mut result_parts = Vec::new();
758+
759+
// Only add empty string prefix when the first element is a non-string
760+
// expression that needs coercion to string AND there's no string
761+
// literal early enough to provide coercion
762+
let needs_empty_string_prefix = match groups.first() {
763+
Some(GroupType::Expression(first_expr)) => {
764+
// Check if the first expression is already a string concatenation
765+
let first_needs_coercion = match &*first_expr.expr {
766+
Expr::Bin(BinExpr {
767+
op: op!(bin, "+"), ..
768+
}) => false, // Already string concat
769+
Expr::Lit(Lit::Str(..)) => false, // Already a string literal
770+
Expr::Call(_call) => {
771+
// Function calls may return any type and need string coercion
772+
true
773+
}
774+
_ => true, // Other expressions need string coercion
775+
};
776+
777+
// If the first element needs coercion, check if the second element is a string
778+
// literal that can provide the coercion
779+
if first_needs_coercion {
780+
match groups.get(1) {
781+
Some(GroupType::Literals(_)) => false, /* String literals will */
782+
// provide coercion
783+
_ => true, // No string literal to provide coercion
784+
}
785+
} else {
786+
false
787+
}
788+
}
789+
_ => false,
790+
};
791+
792+
if needs_empty_string_prefix {
793+
result_parts.push(Box::new(Expr::Lit(Lit::Str(Str {
794+
span: DUMMY_SP,
795+
raw: None,
796+
value: atom!(""),
797+
}))));
798+
}
799+
800+
for group in groups {
801+
match group {
802+
GroupType::Literals(literals) => {
803+
let mut joined = String::new();
804+
for literal in literals.iter() {
805+
match &*literal.expr {
806+
Expr::Lit(Lit::Str(s)) => joined.push_str(&s.value),
807+
Expr::Lit(Lit::Num(n)) => write!(joined, "{}", n.value).unwrap(),
808+
Expr::Lit(Lit::Null(..)) => {
809+
// For string concatenation, null becomes
810+
// empty string
811+
}
812+
e if is_pure_undefined(self.expr_ctx, e) => {
813+
// undefined becomes empty string in string
814+
// context
815+
}
816+
_ => unreachable!(),
817+
}
818+
}
819+
820+
result_parts.push(Box::new(Expr::Lit(Lit::Str(Str {
821+
span: DUMMY_SP,
822+
raw: None,
823+
value: joined.into(),
824+
}))));
825+
}
826+
GroupType::Expression(expr) => {
827+
result_parts.push(expr.expr.clone());
828+
}
829+
}
830+
}
831+
832+
// Create string concatenation expression
833+
if result_parts.len() == 1 {
834+
return Some(*result_parts.into_iter().next().unwrap());
835+
}
836+
837+
let mut result = *result_parts.remove(0);
838+
for part in result_parts {
839+
result = Expr::Bin(BinExpr {
840+
span: DUMMY_SP,
841+
left: Box::new(result),
842+
op: op!(bin, "+"),
843+
right: part,
844+
});
845+
}
846+
847+
Some(result)
848+
} else {
849+
// For non-empty separator, create a more compact array
850+
let mut new_elems = Vec::new();
851+
852+
for group in groups {
853+
match group {
854+
GroupType::Literals(literals) => {
855+
let mut joined = String::new();
856+
for (idx, literal) in literals.iter().enumerate() {
857+
if idx > 0 {
858+
joined.push_str(separator);
859+
}
860+
861+
match &*literal.expr {
862+
Expr::Lit(Lit::Str(s)) => joined.push_str(&s.value),
863+
Expr::Lit(Lit::Num(n)) => write!(joined, "{}", n.value).unwrap(),
864+
Expr::Lit(Lit::Null(..)) => {
865+
// null becomes empty string
866+
}
867+
e if is_pure_undefined(self.expr_ctx, e) => {
868+
// undefined becomes empty string
869+
}
870+
_ => unreachable!(),
871+
}
872+
}
873+
874+
new_elems.push(Some(ExprOrSpread {
875+
spread: None,
876+
expr: Box::new(Expr::Lit(Lit::Str(Str {
877+
span: DUMMY_SP,
878+
raw: None,
879+
value: joined.into(),
880+
}))),
881+
}));
882+
}
883+
GroupType::Expression(expr) => {
884+
new_elems.push(Some(ExprOrSpread {
885+
spread: None,
886+
expr: expr.expr.clone(),
887+
}));
888+
}
889+
}
890+
}
891+
892+
// Create a new array.join() call with the original separator
893+
let new_array = Expr::Array(ArrayLit {
894+
span: _span,
895+
elems: new_elems,
896+
});
897+
898+
// For comma separator, use .join() without arguments (shorter)
899+
let args = if separator == "," {
900+
vec![]
901+
} else {
902+
vec![ExprOrSpread {
903+
spread: None,
904+
expr: Box::new(Expr::Lit(Lit::Str(Str {
905+
span: DUMMY_SP,
906+
raw: None,
907+
value: separator.into(),
908+
}))),
909+
}]
910+
};
911+
912+
Some(Expr::Call(CallExpr {
913+
span: _span,
914+
ctxt: Default::default(),
915+
callee: Callee::Expr(Box::new(Expr::Member(MemberExpr {
916+
span: _span,
917+
obj: Box::new(new_array),
918+
prop: MemberProp::Ident(IdentName::new(atom!("join"), _span)),
919+
}))),
920+
args,
921+
..Default::default()
922+
}))
923+
}
924+
}
925+
628926
pub(super) fn drop_undefined_from_return_arg(&mut self, s: &mut ReturnStmt) {
629927
if let Some(e) = s.arg.as_deref() {
630928
if is_pure_undefined(self.expr_ctx, e) {

crates/swc_ecma_minifier/tests/TODO.txt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
arguments/modified/input.js
2-
arrays/constant_join_2/input.js
32
arrays/constant_join_3/input.js
4-
arrays/constant_join/input.js
53
arrays/for_loop/input.js
64
arrays/index_length/input.js
75
arrow/issue_2084/input.js
@@ -122,7 +120,6 @@ drop_unused/reassign_const/input.js
122120
drop_unused/unused_circular_references_2/input.js
123121
drop_unused/unused_circular_references_3/input.js
124122
drop_unused/var_catch_toplevel/input.js
125-
evaluate/issue_2207_3/input.js
126123
evaluate/issue_2535_1/input.js
127124
evaluate/issue_399/input.js
128125
evaluate/prototype_function/input.js

crates/swc_ecma_minifier/tests/benches-full/echarts.js

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20006,13 +20006,11 @@
2000620006
} else {
2000720007
this._width = this._getSize(0), this._height = this._getSize(1);
2000820008
var width1, height1, domRoot, domRoot1 = this._domRoot = (width1 = this._width, height1 = this._height, (domRoot = document.createElement('div')).style.cssText = [
20009-
'position:relative',
20009+
"position:relative",
2001020010
'width:' + width1 + 'px',
2001120011
'height:' + height1 + 'px',
20012-
'padding:0',
20013-
'margin:0',
20014-
'border-width:0'
20015-
].join(';') + ';', domRoot);
20012+
"padding:0;margin:0;border-width:0"
20013+
].join(";") + ';', domRoot);
2001620014
root.appendChild(domRoot1);
2001720015
}
2001820016
}
@@ -41758,14 +41756,7 @@
4175841756
'right'
4175941757
], arrowPos) > -1 ? (positionStyle += 'top:50%', transformStyle += "translateY(-50%) rotate(" + ('left' === arrowPos ? -225 : -45) + "deg)") : (positionStyle += 'left:50%', transformStyle += "translateX(-50%) rotate(" + ('top' === arrowPos ? 225 : 45) + "deg)");
4176041758
var borderStyle = borderColor + " solid 1px;";
41761-
return "<div style=\"" + [
41762-
'position:absolute;width:10px;height:10px;',
41763-
positionStyle + ";" + transformStyle + ";",
41764-
"border-bottom:" + borderStyle,
41765-
"border-right:" + borderStyle,
41766-
"background-color:" + backgroundColor + ";",
41767-
'box-shadow:8px 8px 16px -3px #000;'
41768-
].join('') + "\"></div>";
41759+
return "<div style=\"" + ("position:absolute;width:10px;height:10px;" + (positionStyle + ";") + transformStyle + ";border-bottom:" + borderStyle + "border-right:" + borderStyle + "background-color:" + backgroundColor) + ';box-shadow:8px 8px 16px -3px #000;"></div>';
4176941760
}(tooltipModel.get('backgroundColor'), borderColor, arrowPosition)), isString(content)) el.innerHTML = content;
4177041761
else if (content) {
4177141762
// Clear previous

crates/swc_ecma_minifier/tests/benches-full/lodash.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,9 @@
7070
].join('|') + ')',
7171
rsUpper + '?' + rsMiscLower + '+' + rsOptContrLower,
7272
rsUpper + '+' + rsOptContrUpper,
73-
'\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])',
74-
'\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])',
75-
'\\d+',
73+
"\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])|\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])|\\d+",
7674
rsEmoji
77-
].join('|'), 'g'), reHasUnicode = RegExp('[' + rsZWJ + rsAstralRange + rsComboRange + rsVarRange + ']'), reHasUnicodeWord = /[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/, contextProps = [
75+
].join("|"), 'g'), reHasUnicode = RegExp('[' + rsZWJ + rsAstralRange + rsComboRange + rsVarRange + ']'), reHasUnicodeWord = /[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/, contextProps = [
7876
'Array',
7977
'Buffer',
8078
'DataView',

0 commit comments

Comments
 (0)