Skip to content

[JSC] (a?.b)(): a nullish a throws a TypeError, and a class field initializer makes the call - #650

Open
robobun wants to merge 1 commit into
mainfrom
robobun/bf065baf/parenthesized-optional-chain-call
Open

robobun wants to merge 1 commit into
mainfrom
robobun/bf065baf/parenthesized-optional-chain-call

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • (a?.b)() with a nullish a returns undefined. The spec, V8 and SpiderMonkey run the arguments, then throw a TypeError.
  • In a class field, x = (a?.b)() with a present a stores a.b and never calls it. TypeScript (a?.b as F)() and (a?.b)!() print as this shape.
  • Cause: ASTBuilder::makeFunctionCallNode (parser/ASTBuilder.h:1481) puts the call node back inside the OptionalChainNode of a?.b. That is the tree of a?.b(): the short-circuit jump skips the call. The node also starts at a, and a field initializer is parsed again from its node's start, so that parse stops at the ).

Fix

  • The call node stays outside the OptionalChainNode. FunctionCallDotNode and FunctionCallBracketNode get a flag, calleeIsOptionalChain.
  • With the flag, emitBytecode pushes the chain target before the base and pops it right after the callee lookup. The target loads undefined into the callee and this. The arguments run, then the call throws (o?.a) is not a function.
  • (a?.call)(), (a?.apply)() and (a?.hasOwnProperty)(k) get the generic FunctionCallDotNode (the special nodes ignore the flag).
  • Verified: JSTests/stress/optional-chaining-parenthesized-call.js (new) fails on the jsc of autobuild-cf1b36ec and passes here. Full JSTests/stress and a test262 subset: no other change.

Background

  • An optional chain a?.b.c gives undefined and skips the rest when a is nullish. Parentheses end the chain, but (a?.b) is still a property reference: the call gets this = a.
  • An OptionalChainNode pushes a jump target. Each ?. base emits jundefined_or_null to it. The target loads undefined.
  • JSC temporaries start uninitialized. (a?.b.c)() that short-circuits at a never writes the this register, so the target writes it too.
Notes
const o = undefined;
(o?.a)();        // jsc: undefined          node: TypeError
(o?.[0])(f());   // jsc: undefined, no f()  node: f() runs, then TypeError
o?.a();          // undefined in both (one chain)
(o?.a)?.();      // undefined in both (one chain)

const p = { f() { return "called"; } };
class C { x = (p?.f)(); }
new C().x;       // jsc: the function f     node: "called"

(a?.b.c)(), (a?.[k])() and ((a?.b))() behave like (a?.b)(). Nobody reported either symptom: both came up in tests of bun's decorator lowering. Upstream main has the same code (since the 2019 implementation of optional chaining), so a bugs.webkit.org report and an upstream patch are still to do. The tagged template (a?.b) ``, (a?.b).c and `(0, a?.b)()` already throw.

Bytecode of function f(o, x) { return (o?.a)(x); } with this change:

[   1] mov                dst:loc8, src:arg1
[   4] jundefined_or_null value:loc8, targetLabel:11(->15)
[   7] get_by_id          dst:loc5, base:loc8, property:0
[  13] jmp                targetLabel:8(->21)
[  15] mov                dst:loc5, src:Undefined(const0)
[  18] mov                dst:loc8, src:Undefined(const0)
[  21] mov                dst:loc7, src:arg2
[  24] call               dst:loc5, callee:loc5, argc:2, argv:14
[  31] ret                value:loc5
  • A present base emits the same instructions as before, plus the jmp over the two loads. a?.b(), a?.b?.() and (a?.b)?.() are each one chain and emit the same instructions as before.
  • V8 does the same in BytecodeGenerator::VisitCall for NAMED_OPTIONAL_CHAIN_PROPERTY_CALL and KEYED_OPTIONAL_CHAIN_PROPERTY_CALL: BuildOptionalChain wraps only the receiver and the property load. SpiderMonkey does it in BytecodeEmitter::emitCalleeAndThisForOptionalChain, which sets the callee and this to undefined at the jump target.
  • test262 has language/expressions/optional-chaining/optional-call-preserves-this.js for the this of (a?.b)(). It has no case for a nullish a.
  • The class field: PropertyListNode::emitBytecode records initializer->position() and parseClassFieldInitializerSourceElements parses again from there. makeFunctionCallNode returned the OptionalChainNode that parseMemberExpression made for the inside of the parentheses, which starts at a. The call node it returns now starts at the opening parenthesis.
  • Found next to it, not fixed here: class G { y = delete o?.g } gives y the value of o?.g and deletes nothing. makeDeleteNode returns the OptionalChainNode too, and that one starts after the delete keyword.
  • TaggedTemplateNode: call (a?.b)... with |this| = a, and super.tag... with the method's this #534 fixes the this of the tagged template (a?.b)`...` with the same push, pop and two loads in TaggedTemplateNode::emitBytecode. It names this call bug as out of scope. The two changes touch different functions.
  • recordCallOrApplyDepth in Parser.cpp only counts a callee that is a DotAccessorNode, so (a?.call)((b?.call)(...)) got CallFunctionCallDotNode with depth 0. With the generic node that shape has no exponential code size.
  • The new stress test covers: undefined and null bases, dot, bracket, index, symbol and private name callees (method, field, getter, static method), double parentheses, short-circuits at each link of a longer chain, the argument evaluation order and an argument that throws, call, apply and hasOwnProperty, locals that keep their value after a short-circuit, strict tail calls, arrow, generator (with a yield in the arguments), async, eval and new Function contexts, condition, ??, ||, typeof, delete and template contexts, new (a?.b)(), a super property and new.target as the base, instance, static, private and computed class fields, and a testLoopCount loop through the tiers with a present base first and a nullish base after.
  • Local Debug (no ASAN) jsc: the new test and the three optional-chaining* tests pass with the default options, --useJIT=false, --useLLInt=false, --useDFGJIT=false, --useFTLJIT=false, eager DFG and FTL thresholds with --useConcurrentJIT=false, --validateBytecode=true --validateGraph=true --validateGraphAtEachPhase=true, --forceDebuggerBytecodeGeneration=true, and --useTypeProfiler=true --useControlFlowProfiler=true.
  • The branch sits on cf1b36ec, the commit oven-sh/bun pins now, so that the preview differs from that pin by this commit only. The first two pushes sat on 28f58fb0.
  • Full JSTests/stress (5833 files, default options plus --useDollarVM=true, 120 s per file, 10 in parallel) on the release jsc of autobuild-cf1b36ec and of the first preview of this PR (29e9338a, on 28f58fb0, same Source/ change): the files that differ are the new test, optional-chaining.js, the new test of [JSC] A jettison for a dead weak reference counts toward the reoptimization back-off #634 (not in the old pin), one memoryHog! test that was killed under load and passes alone on both, and one timeout under load.
  • test262 language/expressions/{optional-chaining,call,tagged-template,coalesce,delete,new,super,property-accessors,grouping,typeof} and language/{statements,expressions}/class/elements, sloppy and strict (6733 runs): the same 16 known failures before and after.

@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Preview Builds

Commit Release Date
85a710c2 autobuild-preview-pr-650-85a710c2 2026-09-14 07:32:24 UTC
13bd3c9b autobuild-preview-pr-650-13bd3c9b 2026-09-14 06:38:40 UTC
29e9338a autobuild-preview-pr-650-29e9338a 2026-09-13 20:46:23 UTC

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes bytecode-generation control flow for optional-chain callees in ASTBuilder/NodesCodegen — a language-semantics change on paths owned by JSC reviewers — a human look is still worthwhile.

What was reviewed:

  • Traced the new pushOptionalChainTarget/emitOptionalChainCalleeEnd flow in both FunctionCallDotNode and FunctionCallBracketNode for single-link ((a?.b)()) and multi-link ((a?.b.c)(), (a?.b[k])()) short-circuits — the landing pad writes both function and thisRegister before the call, so no uninitialized temporaries reach emitCallInTailPosition.
  • Checked makeFunctionCallNode: the unwrap only fires when expr()->isLocation() and !isOptionalCall, so (a?.b)?.() and (a?.())() keep their existing shapes; the .call/.apply/hasOwnProperty special nodes are correctly bypassed when the flag is set.
  • Confirmed the change is bytecode-level (tier-agnostic) and that the new stress test throws on failure and uses testLoopCount per JSTests conventions.
Extended reasoning...

Overview

This PR fixes a spec-compliance bug in JavaScriptCore's handling of (a?.b)() when a is nullish: parentheses terminate the optional chain, so the call must still evaluate its arguments and throw a TypeError rather than short-circuiting to undefined. The fix touches parser/ASTBuilder.h (no longer wraps the call inside the callee's OptionalChainNode; instead unwraps to the inner Dot/Bracket accessor and sets a new m_calleeIsOptionalChain flag), parser/Nodes.h (adds the flag and setter to FunctionCallDotNode and FunctionCallBracketNode), and bytecompiler/NodesCodegen.cpp (both emitBytecode methods push an optional-chain target before evaluating the base and, via a new emitOptionalChainCalleeEnd helper, emit a jump over a landing pad that loads undefined into the function and this registers). A 469-line stress test and three assertions in the existing optional-chaining.js cover dot/bracket/private-name callees, multi-link chains, .call/.apply/.hasOwnProperty, argument evaluation order, tail calls, generators/async, and a testLoopCount tier-up loop.

Security risks

No direct security surface (auth, crypto, permissions) is touched. The relevant risk class for bytecode-compiler changes is uninitialized-register reads or unbalanced control flow. I traced the short-circuit paths: for (a?.b.c)() and (a?.b[k])() the jump lands at popOptionalChainTarget() where both function and callArguments.thisRegister() are explicitly loaded with undefined before the call, so the previously-unwritten this temporary is never read uninitialized. The push/pop of the optional-chain target is balanced within each emitBytecode (the flag is only set when !isOptionalCall, asserted at the pop site). The OptionalChainNode wrapper is discarded rather than reused, so there's no double-emission of its target.

Level of scrutiny

High. This is a semantics change in the JavaScript engine's AST→bytecode pipeline that affects every execution tier and is on a CODEOWNERS-covered path (/Source/JavaScriptCore and /JSTests → @ WebKit/jsc-reviewers). Per the approval guidelines, CODEOWNER-covered paths and language-semantics changes should not be auto-approved. The PR also intentionally disables the specialized CallFunctionCallDotNode/ApplyFunctionCallDotNode/HasOwnPropertyFunctionCallDotNode fast paths for the parenthesized-chain shape — a deliberate perf/correctness trade-off a JSC reviewer should sign off on.

Other factors

The change is well-scoped, thoroughly commented, and mirrors the approach the PR describes for V8 and SpiderMonkey. Test coverage is extensive and follows JSTests conventions (throws on failure, uses testLoopCount, no logging). The bug hunter ran to a dry streak with no findings. I found no correctness issues myself, but the combination of CODEOWNERS coverage and the nature of the change (bytecode control-flow edit affecting all four tiers) means a human JSC reviewer should confirm.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 9dc1aefe-6125-424e-b1d0-d537c678d620

📥 Commits

Reviewing files that changed from the base of the PR and between 28f58fb and 29e9338.

📒 Files selected for processing (5)
  • JSTests/stress/optional-chaining-parenthesized-call.js
  • JSTests/stress/optional-chaining.js
  • Source/JavaScriptCore/bytecompiler/NodesCodegen.cpp
  • Source/JavaScriptCore/parser/ASTBuilder.h
  • Source/JavaScriptCore/parser/Nodes.h

Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review.


Walkthrough

The parser now represents optional-chain callees explicitly. Bytecode generation handles short-circuited parenthesized calls. Stress tests cover synchronous, asynchronous, private-name, context-sensitive, and optimized execution paths.

Changes

Optional-chain call compilation

Layer / File(s) Summary
AST optional-callee representation
Source/JavaScriptCore/parser/ASTBuilder.h, Source/JavaScriptCore/parser/Nodes.h
Call nodes now record optional-chain callees. Non-optional calls unwrap optional-chain locations during AST construction.
Optional-callee bytecode generation
Source/JavaScriptCore/bytecompiler/NodesCodegen.cpp
Bracket and dot calls preserve optional-chain targets. Short-circuited calls materialize undefined for the function and receiver.
Optional-chain call stress coverage
JSTests/stress/optional-chaining-parenthesized-call.js, JSTests/stress/optional-chaining.js
Tests cover nullish short-circuiting, evaluation order, receivers, private names, language contexts, asynchronous execution, optimized paths, and non-optional call errors.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 29e93

No actionable regression is currently established, so the change is mergeable after normal checks.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description provides a detailed problem statement, technical fix, behavior details, affected scenarios, and test results. However, it omits required template information: a Bugzilla bug title and … Add the associated Bugzilla URL and bug title, include the required review line, and list the changed files with the relevant functions or classes in the template format.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main fix: parenthesized optional-chain calls with a nullish base must throw a TypeError. It is specific and relevant to the changeset.
Full details: Description check

Explanation

The description provides a detailed problem statement, technical fix, behavior details, affected scenarios, and test results. However, it omits required template information: a Bugzilla bug title and URL, a “Reviewed by NOBODY (OOPS!).” line, and a clear path/function change list.

  • Fix all pre-merge checks with AI

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


Comment @coderabbitai help to get the list of available commands.

@robobun
robobun force-pushed the robobun/bf065baf/parenthesized-optional-chain-call branch from 29e9338 to 13bd3c9 Compare September 14, 2026 06:09
@robobun robobun changed the title [JSC] (a?.b)() with a nullish a throws a TypeError instead of skipping the call [JSC] (a?.b)(): a nullish a throws a TypeError, and a class field initializer makes the call Sep 14, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

oven-sh/bun#42684 pins the preview autobuild-preview-pr-650-13bd3c9b and runs JSTests/stress/optional-chaining-parenthesized-call.js under bun as a jsc-stress fixture. It also covers the TypeScript spellings (a?.b as F)() and (a?.b)!() through the transpiler.

The full JSTests/stress comparison in the description ran on the release jsc of the first preview (29e9338a). The second push (13bd3c9b) changed only the test file and the commit message. Source/ is the same.

… initializer makes the call

The parentheses in `(a?.b)()` end the optional chain, so the call is not part
of it. When `a` is nullish, `(a?.b)` is the value undefined and EvaluateCall
throws a TypeError after it evaluated the arguments
(https://tc39.es/ecma262/#sec-evaluatecall). V8 and SpiderMonkey do that. JSC
returned undefined and did not evaluate the arguments:

    const o = undefined;
    (o?.a)();       // JSC: undefined. Expected: TypeError
    (o?.a.b)();     // same
    (o?.[0])(f());  // same, and f is not called
    ((o?.a))();     // same

The cause is in ASTBuilder::makeFunctionCallNode. `(a?.b)()` has to call `b`
with |this| = `a`, so the function looks through the OptionalChainNode of the
callee to make a FunctionCallDotNode or FunctionCallBracketNode. It then put
that call node inside the OptionalChainNode. That is the tree of `a?.b()`, so
the short-circuit jump also skipped the call.

The node that came back was also the OptionalChainNode of `a?.b`, which starts
at `a` and not at the opening parenthesis. A class field initializer is parsed
a second time from the position of its node, and that parse stopped at the
closing parenthesis. So `class C { x = (o?.f)(); }` with a present `o` stored
`o.f` in `x` and never called it.

The call node is no longer put inside the OptionalChainNode. The two generic
call nodes get a flag, calleeIsOptionalChain. With the flag, emitBytecode
pushes the optional chain target before it evaluates the base and pops it
right after the lookup of the callee. At the target the callee and |this| are
loaded with undefined. The arguments are evaluated after that point and the
call throws, with the message of any other call of undefined:
"(o?.a) is not a function. (In '(o?.a)()', '(o?.a)' is undefined)".
|this| has to be written at the target because a chain that short-circuits
before its last link, as in `(a?.b.c)()`, never wrote that register.

The nodes for `.call()`, `.apply()` and `.hasOwnProperty()` do not know the
flag, so `(a?.call)()` and the like now get the generic FunctionCallDotNode.

A present base emits the same instructions as before plus one jump over the
two loads. `a?.b()`, `a?.b?.()` and `(a?.b)?.()` are each one chain and emit
the same instructions as before.

* JSTests/stress/optional-chaining-parenthesized-call.js: Added.
* JSTests/stress/optional-chaining.js:
* Source/JavaScriptCore/bytecompiler/NodesCodegen.cpp:
(JSC::emitOptionalChainCalleeEnd):
(JSC::FunctionCallBracketNode::emitBytecode):
(JSC::FunctionCallDotNode::emitBytecode):
* Source/JavaScriptCore/parser/ASTBuilder.h:
(JSC::ASTBuilder::makeFunctionCallNode):
* Source/JavaScriptCore/parser/Nodes.h:
(JSC::FunctionCallBracketNode::setCalleeIsOptionalChain):
(JSC::FunctionCallDotNode::setCalleeIsOptionalChain):
@robobun
robobun force-pushed the robobun/bf065baf/parenthesized-optional-chain-call branch from 13bd3c9 to 85a710c Compare September 14, 2026 07:03

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

robobun added a commit to oven-sh/bun that referenced this pull request Sep 14, 2026
The WebKit branch now sits on cf1b36ec, the commit this repo pinned
before. The preview differs from that pin by the one commit of
oven-sh/WebKit#650 and no longer carries oven-sh/WebKit#634 and
oven-sh/WebKit#636.
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.

1 participant