feat(es/minifier): Remove useless arguments for non inlined callee#11645
feat(es/minifier): Remove useless arguments for non inlined callee#11645kdy1 merged 6 commits intoswc-project:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 2a062bf The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fd21aab84a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| let data = self.data.var_or_default(id.id.to_id()); | ||
|
|
||
| if known { | ||
| data.mark_param_count(Value::Known(n.function.params.len() as u8)); |
There was a problem hiding this comment.
Avoid truncating inferred arity to 8 bits
Parameter counts are recorded via len() as u8, which silently wraps for functions with more than 255 parameters; those wrapped values are later used by ignore_unused_args_of_call to delete trailing arguments. In such cases, real positional arguments can be removed incorrectly (e.g. 300 params inferred as 44), producing wrong runtime inputs. Please store arity in a wider type or mark it unknown when it exceeds u8::MAX.
Useful? React with 👍 / 👎.
Binary Sizes
Commit: d1360dd |
There was a problem hiding this comment.
Pull request overview
This PR extends SWC’s minifier to drop unused call arguments even when the callee is not inlined, by tracking a function’s parameter count in usage analysis and using that during compression (fixing #11089).
Changes:
- Extend
swc_ecma_usage_analyzerto record whether a function’s parameter count is “known” and store that count on variable usage info. - Add a new minifier optimization to remove trailing call arguments when the callee’s parameter count is known (direct fn/arrow callees and identifiers with recorded
param_count). - Update fixture/terser/project snapshot outputs to reflect the new argument-dropping behavior (including a new regression fixture for #11089).
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/swc_ecma_usage_analyzer/src/analyzer/storage.rs | Extends analyzer storage traits to expose scope flags and store parameter-count metadata. |
| crates/swc_ecma_usage_analyzer/src/analyzer/mod.rs | Records param_count for functions/arrow expressions assigned to identifiers and for function declarations. |
| crates/swc_ecma_minifier/src/program_data.rs | Adds param_count to VarUsageInfo and wires it through VarDataLike. |
| crates/swc_ecma_minifier/src/compress/optimize/unused.rs | Implements ignore_unused_args_of_call to drop trailing call args based on callee parameter count. |
| crates/swc_ecma_minifier/src/compress/optimize/mod.rs | Invokes the new call-argument dropping pass during call-expression optimization. |
| crates/swc_ecma_minifier/src/compress/optimize/iife.rs | Refactors IIFE arg handling to rely on scope flags and removes duplicated trailing-arg removal logic. |
| crates/swc_ecma_minifier/tests/terser/compress/export/issue_2131/output.js | Updates expected output with removed unused argument. |
| crates/swc_ecma_minifier/tests/terser/compress/collapse_vars/issue_2436_12/output.js | Updates expected output with removed unused argument. |
| crates/swc_ecma_minifier/tests/projects/output/react-dom-17.0.2.js | Updates expected output with removed unused arguments in wrapper calls. |
| crates/swc_ecma_minifier/tests/libs-size.snapshot.md | Updates size snapshot to reflect small output differences. |
| crates/swc_ecma_minifier/tests/fixture/next/wrap-contracts/output.js | Updates expected output with removed unused arguments in bundled code. |
| crates/swc_ecma_minifier/tests/fixture/next/regression-1/framework-798bab57daac3897/output.js | Updates expected output with removed unused arguments across multiple call sites. |
| crates/swc_ecma_minifier/tests/fixture/next/react-pdf-renderer/output.js | Updates expected output with removed unused arguments and resulting renames. |
| crates/swc_ecma_minifier/tests/fixture/next/47005/output.js | Updates expected output with removed unused arguments in app bundle. |
| crates/swc_ecma_minifier/tests/fixture/next/33265/static/chunks/d6e1aeb5-38a8d7ae57119c23/output.js | Updates expected output with removed unused argument(s) in helper calls. |
| crates/swc_ecma_minifier/tests/fixture/issues/firebase-core/1/output.js | Updates expected output with removed unused arguments in generated code. |
| crates/swc_ecma_minifier/tests/fixture/issues/emotion/react/1/output.js | Updates expected output with removed unused arguments in wrapper call. |
| crates/swc_ecma_minifier/tests/fixture/issues/7784/3/output.js | Updates expected output with removed unused argument. |
| crates/swc_ecma_minifier/tests/fixture/issues/2257/full/output.js | Updates expected output with removed unused arguments in bundled runtime. |
| crates/swc_ecma_minifier/tests/fixture/issues/11089/input.js | Adds new regression input for #11089. |
| crates/swc_ecma_minifier/tests/fixture/issues/11089/output.js | Adds expected output showing dropped unused arguments for #11089. |
| crates/swc_ecma_minifier/tests/benches-full/victory.js | Updates expected bench output with removed unused arguments. |
| crates/swc_ecma_minifier/tests/benches-full/d3.js | Updates expected bench output with removed unused arguments. |
| crates/swc/tests/tsc-references/taggedTemplateStringsWithOverloadResolution3.2.minified.js | Updates expected minified output with removed unused arguments. |
| crates/swc/tests/tsc-references/callbackTagVariadicType.2.minified.js | Updates expected minified output with removed unused arguments. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| fn mark_used_recursively(&mut self); | ||
|
|
||
| fn mark_param_count(&mut self, count: Value<u8>); |
There was a problem hiding this comment.
mark_param_count uses Value<u8>, which will force call sites to truncate params.len() for functions with >255 parameters. That can lead to incorrect param_count and unsafe argument dropping. Consider using a larger integer type here (e.g. u16/usize) or specifying that overflow must map to Value::Unknown and enforcing it at the API boundary.
| fn mark_param_count(&mut self, count: Value<u8>); | |
| fn mark_param_count(&mut self, count: Value<usize>); |
Merging this PR will degrade performance by 2.58%
Performance Changes
Comparing |
kdy1
left a comment
There was a problem hiding this comment.
Thanks for the PR. I left three inline findings.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 25 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
crates/swc_ecma_minifier/src/program_data.rs:132
param_countis added toVarUsageInfo, butProgramData::mergedoes not merge this new field when combiningchild.varsinto the parent. This can leave a staleparam_countfrom an outer scope (e.g. variable initialized to a 1-arg fn, then reassigned in a block to a 2-arg fn) and makeignore_unused_args_of_calldrop arguments incorrectly. Please mergeparam_countinmerge(e.g. by applying the same conflict/unknown logic asmark_param_countwhen the child provides a value).
pub(crate) merged_var_type: Option<Value<Type>>,
pub(crate) callee_count: u32,
pub(crate) param_count: Option<Value<u8>>,
/// `infects_to`. This should be renamed, but it will be done with another
/// PR. (because it's hard to review)
infects_to: Vec<Access>,
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| let scope = self.data.scope(n.function.ctxt); | ||
| let known = !scope.used_arguments() | ||
| && !scope.used_eval() | ||
| && !n.function.params.iter().any(|p| p.pat.is_rest()); | ||
| let data = self.data.var_or_default(id.id.to_id()); | ||
|
|
||
| if known { | ||
| data.mark_param_count(Value::Known(n.function.params.len() as u8)); | ||
| } else { | ||
| data.mark_param_count(Value::Unknown); | ||
| } |
There was a problem hiding this comment.
💡 Codex Review
&&=, ||=, and ??= assignments
visit_assign_expr only updates param_count inside the n.op == op!("=") branch, so logical/nullish assignments leave the previous arity attached to the binding. That makes ignore_unused_args_of_call unsound for patterns like let f = a => a; if (cond) f &&= ((a, b) => b); f(1, 2): when the branch runs, f now observes the second argument, but this pass still thinks f takes one parameter and can trim the 2.
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| pub(crate) merged_var_type: Option<Value<Type>>, | ||
|
|
||
| pub(crate) callee_count: u32, | ||
| pub(crate) param_count: Option<Value<u8>>, |
There was a problem hiding this comment.
Propagate
param_count when merging child-scope data
This new param_count field is never merged in ProgramData::merge, so bindings keep a stale known arity when a nested block/function assigns a different callee. For example, let f = a => a; if (cond) { f = (a, b) => b } f(1, 2) records the reassignment in the child scope, but the parent VarUsageInfo still looks like arity 1, so ignore_unused_args_of_call can delete the second argument even when the branch executes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 25 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| let scope = self.data.scope(n.function.ctxt); | ||
| let known = !scope.used_arguments() | ||
| && !scope.used_eval() | ||
| && !n.function.params.iter().any(|p| p.pat.is_rest()); | ||
| let data = self.data.var_or_default(id.id.to_id()); | ||
|
|
||
| if known { | ||
| data.mark_param_count(Value::Known(n.function.params.len() as u8)); | ||
| } else { | ||
| data.mark_param_count(Value::Unknown); |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4a85801181
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if let Some(Value::Known(count)) = self | ||
| .data | ||
| .get_var_data(i.to_id()) | ||
| .and_then(|d| d.param_count) | ||
| { |
There was a problem hiding this comment.
Require definite function initialization before trimming identifier calls
This treats any recorded param_count as proof that the callee is already a function at this call site, but the analyzer records arity from later initializers and assignments too. That makes calls like function g(){ f(0, sideEffect()); var f = a => a } unsafe: at runtime f is still undefined, so sideEffect() runs before the TypeError, while this path can drop the second argument entirely. Please gate this on the binding being definitely initialized to that function before the call, rather than only on a known arity.
Useful? React with 👍 / 👎.
| if let Some(Value::Known(count)) = self | ||
| .data | ||
| .get_var_data(i.to_id()) | ||
| .and_then(|d| d.param_count) | ||
| { |
There was a problem hiding this comment.
Invalidate stored arity on update expressions
This also trusts param_count after writes like f++, but the new arity tracking is only cleared from visit_assign_expr; visit_update_expr still just reports an assignment and leaves the old count intact. In let f = a => a; f++; f(0, sideEffect()), f becomes NaN, the original program evaluates sideEffect() before throwing, and this optimization can incorrectly remove that trailing argument because the stale arity is still recorded as 1.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: de7f217c0f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if let Some(Value::Known(count)) = self | ||
| .data | ||
| .get_var_data(i.to_id()) | ||
| .and_then(|d| d.param_count) |
There was a problem hiding this comment.
Invalidate cached arity on
for…in/of loop rebindings
This path trusts param_count even after the callee variable has been rebound by a loop head. In crates/swc_ecma_usage_analyzer/src/analyzer/mod.rs, visit_for_in_stmt / visit_for_of_stmt assign through report_assign_pat (lines 167-172, 919-960), but that helper never calls mark_param_count(Value::Unknown), and ForHead::VarDecl heads also skip any arity invalidation. A case like let f = a => a; for (f in {x:1}) break; f(0, sideEffect()) therefore keeps the old arity of f, so this optimization can drop sideEffect() even though the runtime call now throws only after evaluating it.
Useful? React with 👍 / 👎.
|
@Austaras I added some edge cases that are related to this PR |
|
I'm fine with most but I believe 256 is a reasonable number for the upper limit of param count in real life code. |
|
Yeah I agree. You can ignore it. Sorry for that. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 36 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub(crate) merged_var_type: Option<Value<Type>>, | ||
|
|
||
| pub(crate) callee_count: u32, | ||
| pub(crate) param_count: Option<Value<u8>>, |
There was a problem hiding this comment.
param_count is stored as Value<u8>, but the analyzer writes params.len() as u8. If a function has >255 parameters, this will truncate and can make ignore_unused_args_of_call drop arguments incorrectly. Consider using a wider integer type (e.g. u16/usize) or at minimum treat params.len() > u8::MAX as Unknown when recording the count.
| pub(crate) param_count: Option<Value<u8>>, | |
| pub(crate) param_count: Option<Value<u16>>, |
| if e.args.len() > params_len { | ||
| for i in (params_len..e.args.len()).rev() { | ||
| if let Some(arg) = e.args.get_mut(i) { | ||
| let new = self.ignore_return_value(&mut arg.expr); | ||
|
|
||
| if let Some(new) = new { | ||
| arg.expr = Box::new(new); | ||
| } else { | ||
| e.args.remove(i); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
ignore_unused_args_of_call mutates/removes call arguments but never sets self.changed = true (and doesn’t emit a report_change!) when it actually drops/replaces args. This can prevent follow-up optimizations in later passes that depend on the changed flag. Please mark the optimizer as changed whenever an argument is removed or rewritten here.
| Expr::Ident(i) => { | ||
| if let Some(scope) = self.data.get_scope(self.ctx.scope) { | ||
| if scope.intersects(ScopeData::HAS_EVAL_CALL.union(ScopeData::HAS_WITH_STMT)) { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| if let Some(Value::Known(count)) = self | ||
| .data | ||
| .get_var_data(i.to_id()) | ||
| .and_then(|d| d.param_count) | ||
| { | ||
| count as usize | ||
| } else { | ||
| return; | ||
| } |
There was a problem hiding this comment.
For Expr::Ident callees, the safety check only looks at self.ctx.scope for eval/with, but the callee binding may come from an outer scope that contains eval/with (which can dynamically rebind the function and invalidate the recorded param_count). This can make argument-dropping unsound for captured variables. Consider also bailing out when the program/top scope has HAS_EVAL_CALL/HAS_WITH_STMT, or using VarUsageInfoFlags (e.g. USED_IN_NON_CHILD_FN) to conservatively avoid using param_count across scope boundaries when eval/with exists.
| Expr::Ident(i) => { | |
| if let Some(scope) = self.data.get_scope(self.ctx.scope) { | |
| if scope.intersects(ScopeData::HAS_EVAL_CALL.union(ScopeData::HAS_WITH_STMT)) { | |
| return; | |
| } | |
| } | |
| if let Some(Value::Known(count)) = self | |
| .data | |
| .get_var_data(i.to_id()) | |
| .and_then(|d| d.param_count) | |
| { | |
| count as usize | |
| } else { | |
| return; | |
| } | |
| Expr::Ident(_) => { | |
| // Be conservative for identifier callees: their binding may come from | |
| // an outer scope that uses `eval` or `with`, which can dynamically | |
| // rebind the function and invalidate any recorded `param_count`. | |
| // To avoid unsound argument dropping in such cases, bail out. | |
| return; |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 954c01cfd6
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if let Some(scope) = self.data.get_scope(self.ctx.scope) { | ||
| if scope.intersects(ScopeData::HAS_EVAL_CALL.union(ScopeData::HAS_WITH_STMT)) { | ||
| return; |
There was a problem hiding this comment.
Honor enclosing eval/with scopes before trimming identifier calls
This guard only inspects self.ctx.scope, but visit_mut_block_stmt switches ctx.scope to each nested block and ScopeData::merge only propagates HAS_EVAL_CALL/HAS_WITH_STMT upward. As a result, f(1, 2) inside an inner block can still be rewritten to f(1) even when an enclosing function or with statement has already made f dynamically rebindable, e.g. function outer(){ let f = a => a; eval("f = (_, b) => b"); { return f(1, 2); } }. Please walk enclosing scopes (or use the existing BitCtx::InWithStmt) before trusting param_count here.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 729ff09595
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if let Some(Value::Known(count)) = self | ||
| .data | ||
| .get_var_data(i.to_id()) | ||
| .and_then(|d| d.param_count) | ||
| { |
There was a problem hiding this comment.
Restrict arity trimming to declared local callees
This branch trusts any stored param_count for identifier callees, but param_count is also populated for unresolved assignments in visit_assign_expr (e.g. f = (a)=>a). In non-strict/script code those are global-property bindings, so a later write like globalThis.f = (_, b) => b can change the runtime callee without invalidating this cached arity; rewriting f(1, sideEffect()) to f(1) then drops observable argument evaluation. Please require a definitely declared local binding (or otherwise exclude unresolved/global bindings) before using param_count here.
Useful? React with 👍 / 👎.
|
I added more tests as this PR is about adding more minification rules |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d19a4d8166
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for id in find_pat_ids(&n.left) { | ||
| self.data | ||
| .var_or_default(id) | ||
| .mark_param_count(Value::Unknown) |
There was a problem hiding this comment.
Invalidate arity for wrapped assignment targets
When the left-hand side is wrapped (e.g. (f as any) = ..., f! = ..., or parenthesized forms), n.left.as_ident() is None, so this falls back to find_pat_ids(&n.left). find_pat_ids intentionally ignores expressions, so no identifier is found and param_count is not invalidated. That leaves stale known arity for f, and ignore_unused_args_of_call can later drop side-effectful trailing arguments from f(...) after such reassignments.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a156dcce5c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| let new = self.ignore_return_value(&mut arg.expr); | ||
|
|
||
| if let Some(new) = new { | ||
| arg.expr = Box::new(new); | ||
| } else { | ||
| e.args.remove(i); |
There was a problem hiding this comment.
Preserve potentially throwing trailing arguments
This new call-arity trimming path removes an argument whenever ignore_return_value returns None, but that helper can drop expressions that are side-effect-free yet still throw when evaluated (for example, 1n + 1). In a case like function f(a){}; f(0, 1n + 1), this optimization now rewrites to f(0) and suppresses the runtime TypeError, which is a behavior change. The trailing argument should only be removed when evaluation is guaranteed not to throw.
Useful? React with 👍 / 👎.
|
I'll apply the review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2a062bff9e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| (Some(Value::Known(v)), None) | (None, Some(Value::Known(v))) => { | ||
| Some(Value::Known(v)) |
There was a problem hiding this comment.
Treat one-sided child arity merges as unknown
ProgramData::merge preserves a known param_count when only one side has data ((Known, None) or (None, Known)), but this merge runs for child scopes that may not execute (e.g. if branches and nested function bodies). That makes arity look definite when it is path-dependent: in let f; if (cond) { f = a => a } f(0, sideEffect()), the branch contributes Known(1) and this arm keeps it, so ignore_unused_args_of_call can drop sideEffect() even though cond = false leaves f non-callable and the original program still evaluates that argument before throwing.
Useful? React with 👍 / 👎.
Description:
BREAKING CHANGE:
Related issue (if exists):
Closes #11089