-
Notifications
You must be signed in to change notification settings - Fork 29.9k
[turbopack] Analyze null safe access operators to trim effects #85840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -90,7 +90,12 @@ | |
| match self { | ||
| ConditionalKind::If { then: block } | ||
| | ConditionalKind::Else { r#else: block } | ||
| | ConditionalKind::Labeled { body: block } => block.normalize(), | ||
| | ConditionalKind::And { expr: block, .. } | ||
|
Check failure on line 93 in turbopack/crates/turbopack-ecmascript/src/analyzer/graph.rs
|
||
| | ConditionalKind::Or { expr: block, .. } | ||
|
Check failure on line 94 in turbopack/crates/turbopack-ecmascript/src/analyzer/graph.rs
|
||
| | ConditionalKind::NullishCoalescing { expr: block, .. } | ||
|
Check failure on line 95 in turbopack/crates/turbopack-ecmascript/src/analyzer/graph.rs
|
||
| | ConditionalKind::Labeled { body: block } => { | ||
| block.normalize(); | ||
| } | ||
| ConditionalKind::IfElse { then, r#else, .. } | ||
| | ConditionalKind::Ternary { then, r#else, .. } => { | ||
| then.normalize(); | ||
|
|
@@ -105,7 +110,7 @@ | |
| } | ||
| ConditionalKind::IfElseMultiple { then, r#else, .. } => { | ||
| for block in then.iter_mut().chain(r#else.iter_mut()) { | ||
| block.normalize(); | ||
| block.normalize() | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -171,6 +176,12 @@ | |
| ast_path: Vec<AstParentKind>, | ||
| span: Span, | ||
| }, | ||
| /// The receiver of a null safe expression | ||
| NullSafeAccessReceiver { | ||
| obj: Box<JsValue>, | ||
| ast_path: Vec<AstParentKind>, | ||
| effects: Box<EffectsBlock>, | ||
| }, | ||
| /// A reference to an imported binding. | ||
| ImportedBinding { | ||
| esm_reference_index: usize, | ||
|
|
@@ -229,6 +240,10 @@ | |
| obj.normalize(); | ||
| prop.normalize(); | ||
| } | ||
| Effect::NullSafeAccessReceiver { obj, effects, .. } => { | ||
| obj.normalize(); | ||
| effects.normalize(); | ||
| } | ||
| Effect::ImportedBinding { .. } => {} | ||
| Effect::TypeOf { arg, .. } => { | ||
| arg.normalize(); | ||
|
|
@@ -800,6 +815,87 @@ | |
| _ => JsValue::unknown_empty(true, "compound assignment expression"), | ||
| }, | ||
|
|
||
| Expr::OptChain(OptChainExpr { base, optional, .. }) => { | ||
| // Optional chaining: obj?.prop or obj?.method() | ||
| // If optional is true and receiver is null/undefined, return undefined | ||
| // Otherwise, evaluate normally | ||
| match &**base { | ||
| OptChainBase::Member(MemberExpr { obj, prop, .. }) => { | ||
| let obj_value = self.eval(obj); | ||
|
|
||
| // If this is an optional access and obj is null/undefined, return undefined | ||
| if *optional { | ||
| if let JsValue::Constant(ConstantValue::Null) = obj_value { | ||
| return JsValue::Constant(ConstantValue::Undefined); | ||
| } | ||
| // Check if it's undefined | ||
| if matches!(obj_value, JsValue::Constant(ConstantValue::Undefined)) { | ||
| return JsValue::Constant(ConstantValue::Undefined); | ||
| } | ||
| } | ||
|
|
||
| // Otherwise, evaluate the member access | ||
| let prop_value = match prop { | ||
| MemberProp::Ident(i) => i.sym.clone().into(), | ||
| MemberProp::PrivateName(_) => { | ||
| return JsValue::unknown_empty( | ||
| false, | ||
| "private names in optional chaining are not supported", | ||
| ); | ||
| } | ||
| MemberProp::Computed(ComputedPropName { expr, .. }) => self.eval(expr), | ||
| }; | ||
|
|
||
| JsValue::member(Box::new(obj_value), Box::new(prop_value)) | ||
| } | ||
| OptChainBase::Call(OptCall { callee, args, .. }) => { | ||
| let callee_value = self.eval(callee); | ||
|
|
||
| // If this is an optional call and callee is null/undefined, return | ||
| // undefined | ||
| if *optional { | ||
| if let JsValue::Constant(ConstantValue::Null) = callee_value { | ||
| return JsValue::Constant(ConstantValue::Undefined); | ||
| } | ||
| if matches!(callee_value, JsValue::Constant(ConstantValue::Undefined)) { | ||
| return JsValue::Constant(ConstantValue::Undefined); | ||
| } | ||
| } | ||
|
|
||
| // We currently do not handle spreads. | ||
| if args.iter().any(|arg| arg.spread.is_some()) { | ||
| return JsValue::unknown_empty( | ||
| true, | ||
| "spread in optional chaining calls is not supported", | ||
| ); | ||
| } | ||
|
|
||
| // Evaluate the call | ||
| let args_values = args.iter().map(|arg| self.eval(&arg.expr)).collect(); | ||
|
|
||
| if let Expr::Member(MemberExpr { obj, prop, .. }) = unparen(callee) { | ||
| let obj = Box::new(self.eval(obj)); | ||
| let prop = Box::new(match prop { | ||
| MemberProp::Ident(i) => i.sym.clone().into(), | ||
| MemberProp::PrivateName(_) => { | ||
| return JsValue::unknown_empty( | ||
| false, | ||
| "private names in optional chaining are not supported", | ||
| ); | ||
| } | ||
| MemberProp::Computed(ComputedPropName { expr, .. }) => { | ||
| self.eval(expr) | ||
| } | ||
| }); | ||
| JsValue::member_call(obj, prop, args_values) | ||
| } else { | ||
| let func = Box::new(callee_value); | ||
| JsValue::call(func, args_values) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| _ => JsValue::unknown_empty(true, "unsupported expression"), | ||
| } | ||
| } | ||
|
|
@@ -1700,6 +1796,142 @@ | |
| member_expr.visit_children_with_ast_path(self, ast_path); | ||
| } | ||
|
|
||
| fn visit_opt_chain_expr<'ast: 'r, 'r>( | ||
| &mut self, | ||
| node: &'ast OptChainExpr, | ||
| ast_path: &mut swc_core::ecma::visit::AstNodePath<'r>, | ||
| ) { | ||
| // Optional chaining has control flow semantics: | ||
| // a?.b means: if a is null/undefined, return undefined, else access a.b | ||
| // | ||
| // The key insight from the AST structure: | ||
| // - OptChainExpr.optional indicates if THIS level has ?. | ||
| // - OptChainExpr.base contains either a MemberExpr or OptCall | ||
| // - The object/callee of that base may itself be an OptChainExpr (for nested ?.) | ||
| // | ||
| // We handle this by: | ||
| // 1. Visit the receiver (object/callee) normally - this may recursively hit another | ||
| // OptChainExpr | ||
| // 2. If this level is optional, collect effects from the access/call and wrap in | ||
| // NullSafeAccessReceiver | ||
| // 3. Otherwise, just visit normally | ||
|
|
||
| if !node.optional { | ||
| // No optional operator at this level, just visit children normally | ||
| // The visitor will handle member accesses and calls appropriately | ||
| node.visit_children_with_ast_path(self, ast_path); | ||
| return; | ||
| } | ||
|
|
||
| // This level has an optional operator (?.) | ||
| match &*node.base { | ||
| OptChainBase::Member(member) => { | ||
| // Visit the object first (may be another opt chain) | ||
| member.obj.visit_with_ast_path(self, ast_path); | ||
|
|
||
| // Get the receiver value for the null check | ||
| let receiver_value = Box::new(self.eval_context.eval(&member.obj)); | ||
|
|
||
| // Collect effects from the member access | ||
| let prev_effects = take(&mut self.effects); | ||
|
|
||
| // Use the existing helper to add member effect | ||
| self.check_member_expr_for_effects(member, ast_path); | ||
|
|
||
| let optional_effects = Box::new(EffectsBlock { | ||
| effects: take(&mut self.effects), | ||
| range: AstPathRange::Exact(as_parent_path(ast_path)), | ||
| }); | ||
|
|
||
| self.effects = prev_effects; | ||
|
|
||
| // Add the NullSafeAccessReceiver effect | ||
| self.add_effect(Effect::NullSafeAccessReceiver { | ||
| obj: receiver_value, | ||
| ast_path: as_parent_path(ast_path), | ||
| effects: optional_effects, | ||
| }); | ||
|
Comment on lines
+1828
to
+1853
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Computed property expressions in optional member chains are not being visited, which means side effects from computed properties (like function calls) would be lost during analysis. View Details📝 Patch Detailsdiff --git a/turbopack/crates/turbopack-ecmascript/src/analyzer/graph.rs b/turbopack/crates/turbopack-ecmascript/src/analyzer/graph.rs
index 57cc0de40d..cbbb3fb515 100644
--- a/turbopack/crates/turbopack-ecmascript/src/analyzer/graph.rs
+++ b/turbopack/crates/turbopack-ecmascript/src/analyzer/graph.rs
@@ -1822,6 +1822,11 @@ impl VisitAstPath for Analyzer<'_> {
// Visit the object first (may be another opt chain)
member.obj.visit_with_ast_path(self, ast_path);
+ // Visit computed property expressions to capture any side effects
+ if let MemberProp::Computed(ComputedPropName { expr, .. }) = &member.prop {
+ expr.visit_with_ast_path(self, ast_path);
+ }
+
// Get the receiver value for the null check
let receiver_value = Box::new(self.eval_context.eval(&member.obj));
AnalysisComputed property expressions in optional member chains are not being visited for side effectsWhat fails: Side effects from computed property expressions in optional member chains (e.g., How to reproduce: Create an optional chain with a computed property containing a function call: let result = obj?.[getKey()];The analysis should collect an effect for the Expected behavior: The computed property expression should be visited to capture any side effects (like function calls), similar to how the Root cause: In Fix: Added code to explicitly visit computed property expressions in the Member case, ensuring side effects are captured before effects collection: if let MemberProp::Computed(ComputedPropName { expr, .. }) = &member.prop {
expr.visit_with_ast_path(self, ast_path);
}This aligns the Member case behavior with the Call case and ensures consistency with the previous default visitor behavior before the recent |
||
| } | ||
| OptChainBase::Call(call) => { | ||
| // Visit the callee first (may be another opt chain) | ||
| call.callee.visit_with_ast_path(self, ast_path); | ||
|
|
||
| // Visit arguments | ||
| for arg in call.args.iter() { | ||
| arg.visit_with_ast_path(self, ast_path); | ||
| } | ||
|
|
||
| // Get the receiver value for the null check | ||
| let receiver_value = Box::new(self.eval_context.eval(&call.callee)); | ||
|
|
||
| // Collect effects from the call | ||
| let prev_effects = take(&mut self.effects); | ||
|
|
||
| // Add call effect (reuse logic from handle_call) | ||
| let args: Vec<EffectArg> = call | ||
| .args | ||
| .iter() | ||
| .map(|arg| { | ||
| if arg.spread.is_some() { | ||
| EffectArg::Spread | ||
| } else { | ||
| EffectArg::Value(self.eval_context.eval(&arg.expr)) | ||
| } | ||
| }) | ||
| .collect(); | ||
|
|
||
| if let Expr::Member(member) = unparen(&call.callee) { | ||
| let obj = Box::new(self.eval_context.eval(&member.obj)); | ||
| let prop = Box::new(match &member.prop { | ||
| MemberProp::Ident(i) => i.sym.clone().into(), | ||
| MemberProp::PrivateName(_) => JsValue::unknown_empty( | ||
| false, | ||
| "private names in member expressions are not supported", | ||
| ), | ||
| MemberProp::Computed(ComputedPropName { expr, .. }) => { | ||
| self.eval_context.eval(expr) | ||
| } | ||
| }); | ||
|
|
||
| self.add_effect(Effect::MemberCall { | ||
| obj, | ||
| prop, | ||
| args, | ||
| ast_path: as_parent_path(ast_path), | ||
| span: call.span, | ||
| in_try: is_in_try(ast_path), | ||
| new: false, | ||
| }); | ||
| } else { | ||
| let func = Box::new(self.eval_context.eval(&call.callee)); | ||
|
|
||
| self.add_effect(Effect::Call { | ||
| func, | ||
| args, | ||
| ast_path: as_parent_path(ast_path), | ||
| span: call.span, | ||
| in_try: is_in_try(ast_path), | ||
| new: false, | ||
| }); | ||
| } | ||
|
|
||
| let optional_effects = Box::new(EffectsBlock { | ||
| effects: take(&mut self.effects), | ||
| range: AstPathRange::Exact(as_parent_path(ast_path)), | ||
| }); | ||
|
|
||
| self.effects = prev_effects; | ||
|
|
||
| // Add the NullSafeAccessReceiver effect | ||
| self.add_effect(Effect::NullSafeAccessReceiver { | ||
| obj: receiver_value, | ||
| ast_path: as_parent_path(ast_path), | ||
| effects: optional_effects, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn visit_expr<'ast: 'r, 'r>( | ||
| &mut self, | ||
| n: &'ast Expr, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code attempts to destructure non-existent fields in enum variants, which will cause a compilation error. Lines 93-95 try to access an
exprfield that doesn't exist onConditionalKind::And,ConditionalKind::Or, andConditionalKind::NullishCoalescing.View Details
Analysis
Incorrect enum field destructuring in ConditionalKind::normalize()
What fails: The
normalize()method inConditionalKindimpl (lines 90-117) attempts to destructure anexprfield that doesn't exist onAnd,Or, andNullishCoalescingenum variants, causing a compilation error.How to reproduce:
# From turbopack root cargo check -p turbopack-ecmascriptExpected to fail with error:
What was wrong vs expected:
The first match arm (lines 93-95 in original code) tried to match:
But the enum variants are defined with
rhs_effectsfield only (lines 78-82):Additionally, these three variants were already correctly handled in the second match arm (lines 104-106) with the correct field name
rhs_effects.Fix: Removed the three incorrect pattern arms from the first match arm, allowing them to be handled exclusively by the second match arm with the correct field destructuring.
Verification: After the fix, all 9 enum variants are correctly matched:
If,Else,Labeled(usingthen/r#else/bodyfields)IfElse,Ternary(usingthen/r#elsefields)And,Or,NullishCoalescing(usingrhs_effectsfield)IfElseMultiple(usingthen/r#elsefields)Reference: Rust pattern matching on struct fields