feat: support optimize side effects free function calls#12559
feat: support optimize side effects free function calls#12559
Conversation
✅ Deploy Preview for rspack ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Rsdoctor Bundle Diff AnalysisFound 6 projects in monorepo, 0 projects with changes. 📊 Quick Summary
Generated by Rsdoctor GitHub Action |
📦 Binary Size-limit
❌ Size increased by 74.66KB from 49.25MB to 49.32MB (⬆️0.15%) |
There was a problem hiding this comment.
Pull request overview
This PR adds support for the #__NO_SIDE_EFFECTS__ notation from the JavaScript Compiler Hints specification, enabling better tree-shaking optimization. The feature allows developers to mark function definitions as pure (having no side effects), which Rspack can use to safely eliminate unused function calls.
Key changes:
- Implements
#__NO_SIDE_EFFECTS__comment annotation parsing for various function types (declarations, expressions, arrows, exports) - Adds
pureFunctionsconfiguration option in module rules to manually flag functions as pure for third-party code - Introduces deferred purity checking system that validates imported functions across module boundaries
Reviewed changes
Copilot reviewed 29 out of 30 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| website/docs/en/guide/optimization/tree-shaking.mdx | Adds English documentation for NO_SIDE_EFFECTS notation and pureFunctions config |
| website/docs/zh/guide/optimization/tree-shaking.mdx | Adds Chinese documentation for the new feature |
| tests/rspack-test/configCases/tree-shaking/no-side-effects-notation/* | Test cases for NO_SIDE_EFFECTS annotation on various function types |
| tests/rspack-test/configCases/tree-shaking/user-mark-pure-functions/* | Test cases for pureFunctions config and warning validation |
| packages/rspack/src/config/types.ts | Adds pureFunctions array type to JavascriptParserOptions |
| packages/rspack/src/config/normalization.ts | Adds noSideEffectsNotation to ExperimentsNormalized |
| packages/rspack/src/config/defaults.ts | Sets default value (false) for noSideEffectsNotation experiment |
| packages/rspack/src/config/adapter.ts | Maps pureFunctions config to raw options |
| packages/rspack/etc/core.api.md | Updates API documentation with new config options |
| crates/rspack_plugin_javascript/src/parser_plugin/side_effects_parser_plugin.rs | Implements annotation parsing, deferred checking, and validation warnings |
| crates/rspack_plugin_javascript/src/plugin/side_effects_flag_plugin.rs | Adds finish_modules hook to validate deferred pure checks and unused ParsedPureFunction struct |
| crates/rspack_plugin_javascript/src/plugin/flag_dependency_exports_plugin.rs | Exports stage constant for plugin ordering |
| crates/rspack_plugin_javascript/src/parser_plugin/inner_graph/plugin.rs | Threads analyze_pure_annotation parameter through to purity checking functions |
| crates/rspack_plugin_javascript/src/visitors/dependency/parser/mod.rs | Changes definitions field visibility to pub(crate) for cross-module access |
| crates/rspack_core/src/module.rs | Adds pure_functions and deferred_pure_checks to BuildInfo |
| crates/rspack_core/src/options/module.rs | Adds pureFunctions option to JavascriptParserOptions |
| crates/rspack_core/src/options/experiments/mod.rs | Adds no_side_effects_notation flag to Experiments |
| crates/rspack_binding_api/src/raw_options/raw_module/mod.rs | Binds pureFunctions from JavaScript to Rust |
| crates/rspack_binding_api/src/raw_options/raw_experiments/mod.rs | Binds noSideEffectsNotation from JavaScript to Rust |
| crates/rspack/src/builder/mod.rs | Adds builder support for noSideEffectsNotation experiment |
| crates/rspack/src/builder/builder_context.rs | Minor whitespace formatting |
| crates/node_binding/napi-binding.d.ts | Updates TypeScript type definitions |
Comments suppressed due to low confidence (1)
crates/rspack_plugin_javascript/src/parser_plugin/side_effects_parser_plugin.rs:843
- The
analyze_pure_notationparameter is added to numerous functions (is_pure_call_expr,is_pure_expression,is_pure_class,is_pure_decl,is_pure_var_decl,is_pure_class_member,is_pure_pat,is_pure_function) but is never actually used to control any conditional logic within these functions. This suggests the parameter is unnecessary code that should either be removed or should actually gate some behavior related to analyzing pure notations.
fn is_pure_call_expr(
parser: &mut JavascriptParser,
analyze_pure_notation: bool,
expr: &Expr,
unresolved_ctxt: SyntaxContext,
comments: Option<&dyn Comments>,
) -> bool {
let Expr::Call(call_expr) = expr else {
unreachable!();
};
let callee = &call_expr.callee;
let pure_flag = comments
.map(|comments| comments.has_flag(callee.span().lo, "PURE"))
.unwrap_or(false);
if pure_flag {
call_expr.args.iter().all(|arg| {
if arg.spread.is_some() {
false
} else {
is_pure_expression(
parser,
analyze_pure_notation,
&arg.expr,
unresolved_ctxt,
comments,
)
}
})
} else {
if let Some(Expr::Ident(ident)) = callee.as_expr().map(|expr| expr.as_ref())
&& parser
.build_info
.pure_functions
.as_ref()
.map(|pure_functions| pure_functions.contains(&ident.sym))
.unwrap_or(false)
{
// this is a locally pure function
return true;
}
if let Some(deferred_check) = try_extract_deferred_check(parser, callee) {
parser.build_info.deferred_pure_checks.push(deferred_check);
return call_expr.args.iter().all(|arg| {
if arg.spread.is_some() {
false
} else {
is_pure_expression(
parser,
analyze_pure_notation,
&arg.expr,
unresolved_ctxt,
comments,
)
}
});
}
!expr.may_have_side_effects(ExprCtx {
unresolved_ctxt,
in_strict: false,
is_unresolved_ref_safe: false,
remaining_depth: 4,
})
}
}
fn try_extract_deferred_check(
parser: &mut JavascriptParser,
callee: &Callee,
) -> Option<DeferredPureCheck> {
let Callee::Expr(expr) = callee else {
return None;
};
let info = match &**expr {
Expr::Ident(ident) => parser.get_variable_info(&ident.sym)?,
_ => return None,
};
let tag_info_id = info.tag_info?;
let tag_info = parser.definitions_db.expect_get_tag_info(tag_info_id);
if tag_info.tag != ESM_SPECIFIER_TAG {
return None;
}
let data = ESMSpecifierData::downcast(tag_info.data.clone()?);
Some(DeferredPureCheck {
import_request: data.source.to_string(),
atom: data.name.clone(),
start: callee.span().lo.0,
end: callee.span().hi.0,
})
}
impl SideEffectsParserPlugin {
fn analyze_stmt_side_effects(&self, stmt: &Statement, parser: &mut JavascriptParser) {
if parser.side_effects_item.is_some() {
return;
}
match stmt {
Statement::If(if_stmt) => {
if !is_pure_expression(
parser,
self.analyze_pure_notation,
&if_stmt.test,
self.unresolve_ctxt,
parser.comments,
) {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
if_stmt.span(),
String::from("Statement"),
));
}
}
Statement::While(while_stmt) => {
if !is_pure_expression(
parser,
self.analyze_pure_notation,
&while_stmt.test,
self.unresolve_ctxt,
parser.comments,
) {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
while_stmt.span(),
String::from("Statement"),
));
}
}
Statement::DoWhile(do_while_stmt) => {
if !is_pure_expression(
parser,
self.analyze_pure_notation,
&do_while_stmt.test,
self.unresolve_ctxt,
parser.comments,
) {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
do_while_stmt.span(),
String::from("Statement"),
));
}
}
Statement::For(for_stmt) => {
let pure_init = match for_stmt.init {
Some(ref init) => match init {
VarDeclOrExpr::VarDecl(decl) => is_pure_var_decl(
parser,
self.analyze_pure_notation,
decl,
self.unresolve_ctxt,
parser.comments,
),
VarDeclOrExpr::Expr(expr) => is_pure_expression(
parser,
self.analyze_pure_notation,
expr,
self.unresolve_ctxt,
parser.comments,
),
},
None => true,
};
if !pure_init {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
for_stmt.span(),
String::from("Statement"),
));
return;
}
let pure_test = match &for_stmt.test {
Some(test) => is_pure_expression(
parser,
self.analyze_pure_notation,
test,
self.unresolve_ctxt,
parser.comments,
),
None => true,
};
if !pure_test {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
for_stmt.span(),
String::from("Statement"),
));
return;
}
let pure_update = match for_stmt.update {
Some(ref expr) => is_pure_expression(
parser,
self.analyze_pure_notation,
expr,
self.unresolve_ctxt,
parser.comments,
),
None => true,
};
if !pure_update {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
for_stmt.span(),
String::from("Statement"),
));
}
}
Statement::Expr(expr_stmt) => {
if !is_pure_expression(
parser,
self.analyze_pure_notation,
&expr_stmt.expr,
self.unresolve_ctxt,
parser.comments,
) {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
expr_stmt.span(),
String::from("Statement"),
));
}
}
Statement::Switch(switch_stmt) => {
if !is_pure_expression(
parser,
self.analyze_pure_notation,
&switch_stmt.discriminant,
self.unresolve_ctxt,
parser.comments,
) {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
switch_stmt.span(),
String::from("Statement"),
));
}
}
Statement::Class(class_stmt) => {
if !is_pure_class(
parser,
self.analyze_pure_notation,
class_stmt.class(),
self.unresolve_ctxt,
parser.comments,
) {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
class_stmt.span(),
String::from("Statement"),
));
}
}
Statement::Var(var_stmt) => match var_stmt {
VariableDeclaration::VarDecl(var_decl) => {
if !is_pure_var_decl(
parser,
self.analyze_pure_notation,
var_decl,
self.unresolve_ctxt,
parser.comments,
) {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
var_stmt.span(),
String::from("Statement"),
));
}
}
VariableDeclaration::UsingDecl(_) => {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
var_stmt.span(),
String::from("Statement"),
));
}
},
Statement::Empty(_) => {}
Statement::Labeled(_) => {}
Statement::Block(_) => {}
Statement::Fn(_) => {}
_ => {
parser.side_effects_item = Some(SideEffectsBailoutItemWithSpan::new(
stmt.span(),
String::from("Statement"),
))
}
};
}
}
pub fn is_pure_pat<'a>(
parser: &mut JavascriptParser,
analyze_pure_notation: bool,
pat: &'a Pat,
unresolved_ctxt: SyntaxContext,
comments: Option<&'a dyn Comments>,
) -> bool {
match pat {
Pat::Ident(_) => true,
Pat::Array(array_pat) => array_pat.elems.iter().all(|ele| {
if let Some(pat) = ele {
is_pure_pat(
parser,
analyze_pure_notation,
pat,
unresolved_ctxt,
comments,
)
} else {
true
}
}),
Pat::Rest(_) => true,
Pat::Invalid(_) | Pat::Assign(_) | Pat::Object(_) => false,
Pat::Expr(expr) => is_pure_expression(
parser,
analyze_pure_notation,
expr,
unresolved_ctxt,
comments,
),
}
}
pub fn is_pure_function<'a>(
parser: &mut JavascriptParser,
analyze_pure_notation: bool,
function: &'a Function,
unresolved_ctxt: SyntaxContext,
comments: Option<&'a dyn Comments>,
) -> bool {
if !function.params.iter().all(|param| {
is_pure_pat(
parser,
analyze_pure_notation,
¶m.pat,
unresolved_ctxt,
comments,
)
}) {
return false;
}
true
}
pub fn is_pure_expression<'a>(
parser: &mut JavascriptParser,
analyze_pure_notation: bool,
expr: &'a Expr,
unresolved_ctxt: SyntaxContext,
comments: Option<&'a dyn Comments>,
) -> bool {
pub fn _is_pure_expression<'a>(
parser: &mut JavascriptParser,
analyze_pure_notation: bool,
expr: &'a Expr,
unresolved_ctxt: SyntaxContext,
comments: Option<&'a dyn Comments>,
) -> bool {
let drive = parser.plugin_drive.clone();
if let Some(res) = drive.is_pure(parser, expr) {
return res;
}
match expr {
Expr::Call(_) => is_pure_call_expr(
parser,
analyze_pure_notation,
expr,
unresolved_ctxt,
comments,
),
Expr::Paren(_) => unreachable!(),
Expr::Seq(seq_expr) => seq_expr.exprs.iter().all(|expr| {
is_pure_expression(
parser,
analyze_pure_notation,
expr,
unresolved_ctxt,
comments,
)
}),
_ => !expr.may_have_side_effects(ExprCtx {
unresolved_ctxt,
is_unresolved_ref_safe: true,
in_strict: false,
remaining_depth: 4,
}),
}
}
_is_pure_expression(
parser,
analyze_pure_notation,
expr,
unresolved_ctxt,
comments,
)
}
pub fn is_pure_class_member<'a>(
parser: &mut JavascriptParser,
analyze_pure_notation: bool,
member: &'a ClassMember,
unresolved_ctxt: SyntaxContext,
comments: Option<&'a dyn Comments>,
) -> bool {
let is_key_pure = match member.class_key() {
Some(PropName::Ident(_ident)) => true,
Some(PropName::Str(_)) => true,
Some(PropName::Num(_)) => true,
Some(PropName::Computed(computed)) => is_pure_expression(
parser,
analyze_pure_notation,
&computed.expr,
unresolved_ctxt,
comments,
),
Some(PropName::BigInt(_)) => true,
None => true,
};
if !is_key_pure {
return false;
}
let is_static = member.is_static();
let is_value_pure = match member {
ClassMember::Constructor(_) => true,
ClassMember::Method(_) => true,
ClassMember::PrivateMethod(_) => true,
ClassMember::ClassProp(prop) => {
if let Some(ref value) = prop.value {
is_pure_expression(
parser,
analyze_pure_notation,
value,
unresolved_ctxt,
comments,
)
} else {
true
}
}
ClassMember::PrivateProp(prop) => {
if let Some(ref value) = prop.value {
is_pure_expression(
parser,
analyze_pure_notation,
value,
unresolved_ctxt,
comments,
)
} else {
true
}
}
ClassMember::TsIndexSignature(_) => unreachable!(),
ClassMember::Empty(_) => true,
ClassMember::StaticBlock(_) => false,
ClassMember::AutoAccessor(_) => false,
};
if is_static && !is_value_pure {
return false;
}
true
}
pub fn is_pure_decl(
parser: &mut JavascriptParser,
analyze_pure_notation: bool,
stmt: &Decl,
unresolved_ctxt: SyntaxContext,
comments: Option<&dyn Comments>,
) -> bool {
match stmt {
Decl::Class(class) => is_pure_class(
parser,
analyze_pure_notation,
&class.class,
unresolved_ctxt,
comments,
),
Decl::Fn(_) => true,
Decl::Var(var) => is_pure_var_decl(
parser,
analyze_pure_notation,
var,
unresolved_ctxt,
comments,
),
Decl::Using(_) => false,
Decl::TsInterface(_) => unreachable!(),
Decl::TsTypeAlias(_) => unreachable!(),
Decl::TsEnum(_) => unreachable!(),
Decl::TsModule(_) => unreachable!(),
}
}
pub fn is_pure_class(
parser: &mut JavascriptParser,
analyze_pure_notation: bool,
class: &Class,
unresolved_ctxt: SyntaxContext,
comments: Option<&dyn Comments>,
) -> bool {
if let Some(ref super_class) = class.super_class
&& !is_pure_expression(
parser,
analyze_pure_notation,
super_class,
unresolved_ctxt,
comments,
)
{
return false;
}
let is_pure_key = |parser: &mut JavascriptParser, key: &PropName| -> bool {
match key {
PropName::BigInt(_) | PropName::Ident(_) | PropName::Str(_) | PropName::Num(_) => true,
PropName::Computed(computed) => is_pure_expression(
parser,
analyze_pure_notation,
&computed.expr,
unresolved_ctxt,
comments,
),
}
};
class.body.iter().all(|item| -> bool {
match item {
ClassMember::Constructor(_) => class.super_class.is_none(),
ClassMember::Method(method) => is_pure_key(parser, &method.key),
ClassMember::PrivateMethod(method) => is_pure_expression(
parser,
analyze_pure_notation,
&Expr::PrivateName(method.key.clone()),
unresolved_ctxt,
comments,
),
ClassMember::ClassProp(prop) => {
is_pure_key(parser, &prop.key)
&& (!prop.is_static
|| if let Some(ref value) = prop.value {
is_pure_expression(
parser,
analyze_pure_notation,
value,
unresolved_ctxt,
comments,
)
} else {
true
})
}
ClassMember::PrivateProp(prop) => {
is_pure_expression(
parser,
analyze_pure_notation,
&Expr::PrivateName(prop.key.clone()),
unresolved_ctxt,
comments,
) && (!prop.is_static
|| if let Some(ref value) = prop.value {
is_pure_expression(
parser,
analyze_pure_notation,
value,
unresolved_ctxt,
comments,
)
} else {
true
})
}
ClassMember::TsIndexSignature(_) => unreachable!(),
ClassMember::Empty(_) => true,
ClassMember::StaticBlock(_) => false, // TODO: support is pure analyze for statements
ClassMember::AutoAccessor(_) => false,
}
})
}
fn is_pure_var_decl<'a>(
parser: &mut JavascriptParser,
analyze_pure_notation: bool,
var: &'a VarDecl,
unresolved_ctxt: SyntaxContext,
comments: Option<&'a dyn Comments>,
) -> bool {
var.decls.iter().all(|decl| {
if let Some(ref init) = decl.init {
is_pure_expression(
parser,
analyze_pure_notation,
init,
unresolved_ctxt,
comments,
)
} else {
true
}
})
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
aa5c920 to
ed90abe
Compare
54392cb to
0d2f8a6
Compare
Merging this PR will degrade performance by 36.56%🎉 Hooray!
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| 👁 | Simulation | js@Traverse module graph by dependencies |
530.2 µs | 835.7 µs | -36.56% |
| ❌ | Simulation | js@collect imported identifiers |
210 µs | 212.7 µs | -1.28% |
Comparing feat/pure-fn (5843920) with main (640f999)
Footnotes
-
28 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
2af18ae to
ac6765f
Compare
4eb6cfe to
9bc3ebc
Compare
5898d5c to
d48437b
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 58439205d0
ℹ️ 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".
Summary
Support
#__NO_SIDE_EFFECTS__notation and user flag side effects free function calls.What is it ?
it's a notation from https://github.com/javascript-compiler-hints/compiler-notations-spec/blob/main/no-side-effects-notation-spec.md, and it's already implemented by both esbuild and Rollup.
It can help Rspack analyze if one function is pure.
Integration
✅ Supports
#__NO_SIDE_EFFECTS__for following cases:Rspack will analyze all modules to find if the function is pure in its declaration module.
node_modulesUsed exports
Currently the pure function analyze is just work for
optimization.sideEffect, notoptimization.innerGraph, which means the call for pure function is still exist in outputRelated links
Checklist