Skip to content

fix(hir): #830 — obj.method?.(args) short-circuits on null callee, not receiver#834

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-fix-830-optional-call
May 16, 2026
Merged

fix(hir): #830 — obj.method?.(args) short-circuits on null callee, not receiver#834
proggeramlug merged 1 commit into
mainfrom
worktree-fix-830-optional-call

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Summary

Root cause

The OptChainBase::Call(call) branch in crates/perry-hir/src/lower.rs was lowering obj.method?.(args) as obj == null ? undefined : obj.method(args) — checking the receiver for null when the ?. is actually on the call, so the check must be on the function value. The leading comment // obj?.method() -> obj == null ? undefined : obj.method() had the AST shape backwards: obj?.method() is parsed as Call(OptChain(Member)) (regular Call path with an OptChain callee), not OptChain(Call). The OptChain(Call) shape is exclusively <expr>?.(args).

Fix

For the simple Member callee, set check_expr = callee_expr.clone(). callee_expr stays a PropertyGet, so the codegen still binds this = obj in the call branch. The inner-OptChain branch (foo?.bar?.()) keeps the existing check_expr = obj behavior to preserve the foo == null short-circuit — a separate foo.bar == null check on that chained form is a pre-existing gap, documented inline.

Test plan

  • Issue repro byte-identical to node --experimental-strip-types: full.greet?.('bob')hi bob, sparse.greet?.('bob')undefined.
  • Broader optional-chaining probe (obj.contact?.email, obj.tags?.[0], obj?.method() via the different AST path, ??, ??=) byte-identical to node.
  • Gap suite 34/36 — same as before (the two failures are pre-existing test_gap_console_methods and test_gap_regexp_advanced / String.prototype.replace(/.../g, fn) — replacer-fn return value is dropped (replaces with empty string) #833, both unrelated).
  • cargo test --release -p perry-hir green (10 + 6 / 0 failed).

Closes #830. Refs #793, #801.

…not receiver

The OptChain(Call) handler — which corresponds to `<expr>?.(args)` where the
`?.` is between the callee and the call parens — was lowering
`obj.method?.(args)` as `obj == null ? undefined : obj.method(args)`. That
checks the receiver, but the `?.` is on the call, so the test must be on the
function value. When `obj` was a valid object with no `method` property, the
else branch tried to invoke `undefined` and threw `TypeError: method is not a
function`.

(The misleading comment `// obj?.method() -> obj == null ? undefined :
obj.method()` had the AST shape backwards — that form parses as
`Call(OptChain(Member))` and goes through the regular Call lowering path; it
never reaches this branch.)

Fix: in the simple Member case, set `check_expr = callee_expr.clone()` so the
conditional tests the function value. callee_expr remains a `PropertyGet`, so
the codegen still binds `this = obj` in the call branch. The inner-OptChain
case (`foo?.bar?.()`) keeps the existing `check_expr = obj` behavior to
preserve the `foo == null` short-circuit; a separate `foo.bar == null` check
on that chained form is a pre-existing gap, documented inline.

Closes #830.
@proggeramlug proggeramlug merged commit 3bbe768 into main May 16, 2026
9 checks passed
@proggeramlug proggeramlug deleted the worktree-fix-830-optional-call branch May 16, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

optional call obj.fn?.(...) throws when fn is undefined (should short-circuit to undefined)

1 participant