fix(hir): a?.b?.method(args) must short-circuit when a?.b is undefined (threw on undefined receiver) - #5570
Conversation
…tream receiver `a?.b?.method(args)` and `a?.b?.method?.(args)` threw `TypeError: Cannot read properties of <nullish> (reading 'method')` when the upstream `a?.b` was null/undefined, instead of short-circuiting to undefined. In the OptChain(Call) lowering, when the optional method member's receiver is itself produced by an upstream optional chain it lowers to a Conditional; the inner-Conditional nesting branch reused the un-short-circuited receiver (`a.b`) as the call object without a per-receiver null guard. For the optional-CALL variant the function-value nullish guard additionally read `(a.b).method` off the nullish receiver and threw during guard evaluation. Re-add a receiver-nullish short-circuit (`a.b == null ? undefined : ...`) for the optional-member case in both the plain-call and optional-call branches, gated on a side-effect-free receiver so it never double-evaluates side effects. Fixes the `_?.allowModels?.some(...)` startup wall. Adds a runnable regression test.
📝 WalkthroughWalkthroughIn the HIR optional-call lowering ( ChangesOptional chain double member call lowering fix
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/perry-hir/src/lower/lower_expr.rs (1)
2139-2141: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winString-builtin optional calls can still short-circuit incorrectly in nested chains.
On Line 2139,
guard_condusesopt_call_member_receiver, which is often aConditionalina?.b?.method?.(...)shapes. That bypasses the string-safe branch inopt_call_func_nullish_guard, so nested cases likeobj?.s?.split?.("/")can incorrectly returnundefinedinstead of callingsplit.💡 Proposed fix
- let guard_cond = match &opt_call_member_receiver { + let guard_receiver = receiver_for_member_guard + .as_ref() + .or(opt_call_member_receiver.as_ref()); + let guard_cond = match guard_receiver { Some(recv) => opt_call_func_nullish_guard(recv, fixed_callee), None => Expr::Compare { op: CompareOp::LooseEq, left: Box::new(fixed_callee), right: Box::new(Expr::Null), }, };Also applies to: 2152-2170
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-hir/src/lower/lower_expr.rs` around lines 2139 - 2141, The guard condition logic around line 2139 using `opt_call_member_receiver` bypasses the string-safe branch in `opt_call_func_nullish_guard` when the receiver is a Conditional expression from nested optional member accesses like `obj?.s?.split?.`, causing it to incorrectly return undefined instead of calling the builtin function. To fix this, extract or unwrap the innermost actual receiver from `opt_call_member_receiver` even when it is a Conditional, then pass that unwrapped receiver to `opt_call_func_nullish_guard` so the string-safe logic is properly applied. Apply the same fix to the corresponding guard condition construction code in the second location around lines 2152-2170 that handles similar optional call patterns.
🧹 Nitpick comments (1)
tests/test_optional_chain_double_member_call.sh (1)
54-75: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a regression for nested optional-call on string receivers.
This script should also assert a nested-string case (e.g.,
obj?.s?.split?.("/")) so the string-safe guard path is protected in the same double-member chain shape.🧪 Suggested test addition
const o2: any = { fn: () => 99 }; const r6 = o2?.fn?.(); console.log("r6=" + r6); // 99 + +// Nested optional-chain + optional-call on string builtin: should call split. +const sObj: any = { s: "a/b" }; +const r7 = sObj?.s?.split?.("/").join("|"); +console.log("r7=" + r7); // "a|b" EOF @@ EXPECTED='r1=undefined r2=undefined r3=true r4=2,4,6 r5=undefined -r6=99' +r6=99 +r7=a|b'🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_optional_chain_double_member_call.sh` around lines 54 - 75, Add a regression test case for nested optional-call on string receivers to protect the string-safe guard path. Before the EOF marker in the TypeScript test code block, add a test case that declares an object with a string property and calls an optional method on that string (e.g., const r7 = obj?.stringProp?.split?.("/"); with appropriate expected output). Then append the corresponding expected result to the EXPECTED variable to match the actual output from running this new test case through the PERRY compiler.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@crates/perry-hir/src/lower/lower_expr.rs`:
- Around line 2139-2141: The guard condition logic around line 2139 using
`opt_call_member_receiver` bypasses the string-safe branch in
`opt_call_func_nullish_guard` when the receiver is a Conditional expression from
nested optional member accesses like `obj?.s?.split?.`, causing it to
incorrectly return undefined instead of calling the builtin function. To fix
this, extract or unwrap the innermost actual receiver from
`opt_call_member_receiver` even when it is a Conditional, then pass that
unwrapped receiver to `opt_call_func_nullish_guard` so the string-safe logic is
properly applied. Apply the same fix to the corresponding guard condition
construction code in the second location around lines 2152-2170 that handles
similar optional call patterns.
---
Nitpick comments:
In `@tests/test_optional_chain_double_member_call.sh`:
- Around line 54-75: Add a regression test case for nested optional-call on
string receivers to protect the string-safe guard path. Before the EOF marker in
the TypeScript test code block, add a test case that declares an object with a
string property and calls an optional method on that string (e.g., const r7 =
obj?.stringProp?.split?.("/"); with appropriate expected output). Then append
the corresponding expected result to the EXPECTED variable to match the actual
output from running this new test case through the PERRY compiler.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: fb1a2658-b6c7-4de9-aacd-28d2953dfa15
📒 Files selected for processing (2)
crates/perry-hir/src/lower/lower_expr.rstests/test_optional_chain_double_member_call.sh
Problem
A double optional-member method call threw on an undefined intermediate receiver instead of short-circuiting to
undefinedper spec.Root cause
crates/perry-hir/src/lower/lower_expr.rs, OptChain(Call) lowering. When the upstreama?.bis undefined, the receiver lowers to a Conditional; the inner-Conditional nesting branch reused the un-short-circuited receiver (a.b) as the call object without re-applying the per-receiver nullish guard the non-Conditional path emits. So(undefined).some(args)was evaluated. The optional-CALL varianta?.b?.method?.(args)had the same gap: the function-value guard read(a.b).methodoff the nullish receiver and threw during guard evaluation.Fix
Re-apply a receiver-nullish short-circuit (
a.b == null ? undefined : (a.b).method(args)) for the optional-member case in both the plain-call and optional-call branches, gated on a side-effect-free receiver (no double evaluation).Tests
tests/test_optional_chain_double_member_call.sh(runnable:o?.missing?.some(...)→ undefined, not a throw; chained and optional-call variants).cargo test -p perry-hir: 196 + new pass;cargo test -p perry-runtime --lib: 1074 passed. Verified end-to-end: a real bundle's_?.allowModels?.some(...)config check no longer throws.Summary by CodeRabbit
Bug Fixes
undefinedis returned instead of throwing when receivers in the chain are nullish.Tests