Skip to content

Distinguish an optional-chain short circuit from a genuine undefined - #3040

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:staging/optional-chain-sentinel
Aug 19, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:staging/optional-chain-sentinel

Conversation

@lahma

@lahma lahma commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Jint signalled "this optional chain short-circuited" by returning the JsValue.Undefined singleton and then testing for it with ReferenceEquals. That is indistinguishable from a link that legitimately produced undefined, so several expressions silently returned undefined where they must throw:

(0, undefined)()          // returned undefined; must throw TypeError
new.target()              // returned undefined; must throw TypeError
({})?.a['b']              // returned undefined; must throw TypeError
(a?.b).c                  // returned undefined; must throw TypeError  (a === null)

Per 13.3.9.1 a chain short-circuits only at the ?. whose own base is nullish. new.target() was caught up in it because Engine.GetNewTarget() returns thisEnvironment.NewTarget ?? JsValue.Undefined, which then tripped the sentinel test in JintCallExpression.

There was a second, independent bug on the same code path: JintMemberExpression short-circuited whenever

baseValue.IsNullOrUndefined() && (_memberExpression.Optional || _objectExpression._expression.IsOptional())

i.e. because the object expression merely contained a ?. somewhere, not because this link had one. That disjunct is what made ({})?.a['b'] and (a?.b).c return undefined.

The fix

A dedicated internal sentinel, OptionalChainShortCircuit — a JsValue with Types.Empty whose ToObject() throws, so it claims no JavaScript type and cannot masquerade as a value if it ever escaped.

Whether a link emits the sentinel or a real undefined is decided at build time: a member/call node calls EnableShortCircuitPropagation() on its object/callee only when that child is part of the same chain (a ChainExpression child ends the chain, which is exactly what parentheses mean). A link nobody asked returns JsValue.Undefined, so there is no conversion step at the boundary to get wrong.

Containment is structural rather than by convention: every parent that asks consumes the sentinel in the statement immediately following the child's Evaluate, so it never travels more than one edge, is never stored, never becomes a Reference base, and never reaches engine.GetValue. The four fast lanes that would reach a child by some route other than Evaluate are all gated on !_objectExpressionCanShortCircuit, which is exactly complementary to the condition that enables propagation.

The IsOptional() disjunct is removed.

Performance

Nothing added on the non-optional path, and the member link's own test got cheaper: an IChainElement type test became a bool field read, tested first. The additions are one bool field on each of two node types and one internal virtual slot called only during handler-tree construction.

Tests

Jint.Tests/Runtime/OptionalChainShortCircuitTests.cs — 10 tests, 5 failing against unfixed code, plus positive cases (undefined?.a, null?.a?.b, deep mixed chains) and a test that a short-circuited chain's result is an ordinary undefined for typeof, ===, String(), ??, JSON.stringify, spread and computed keys.

language/expressions/optional-chaining/** (76 generated cases) unaffected.

Frees three staging/ exclusions: class/newTargetDVG.js, expressions/optional-chain.js, extensions/extension-methods-reject-null-undefined-this.js. The exclusion comment claimed the last of these needed SpiderMonkey's toSource extension and was unfixable — that was wrong, and instructively so: toSource being absent is precisely what makes Object.prototype.toSource undefined, which drove (0, Class.prototype[method])() into the buggy call path. The missing extension was the trigger, not the blocker.

Refs #3021.

…not at every undefined

Jint signalled "this optional chain short-circuited" by returning the
`JsValue.Undefined` singleton and testing for it with `ReferenceEquals`, which
is indistinguishable from a value that simply is `undefined`. Three consumers
read that signal, and each of them swallowed a `TypeError` the spec owes:

    (0, undefined)()                       // returned undefined
    new.target()                           // returned undefined
    ({})?.a['b']                           // returned undefined
    (({a:{b:()=>undefined}}).a.b?.())()    // returned undefined

`new.target()` is the same bug seen from `Engine.GetNewTarget`, which hands
back the singleton for a [[Call]]ed function; `({})?.a['b']` is the member
path's second half of it, where a base that merely *contains* a `?.`
(`_objectExpression._expression.IsOptional()`) short-circuited the link after
it. Per ECMA-262 13.3.9.1 the short circuit is decided only at the `?.` whose
own base is nullish; a link that produces `undefined` continues normally and
the next link throws.

The signal is now a dedicated sentinel, and it is opt-in per parent/child edge:
a member or call node calls `EnableShortCircuitPropagation()` on its
object/callee at build time, and only a link whose parent asked ever produces
the sentinel. Every such parent tests for it in the statement that follows the
evaluation and returns instead of using it, so the sentinel never travels
farther than one edge and can never become a value. A link nobody asked — the
outermost one, i.e. whatever a `ChainExpression` wraps, which is also what
parentheses create — returns a real `undefined`, which is exactly the chain's
value. A `ChainExpression` object/callee is deliberately excluded from the
opt-in: `(a?.b).c` must throw.

The sentinel is an internal JsValue with no JavaScript type whose `ToObject`
throws, so a future change that broke the invariant would fail loudly rather
than hand script a stray value.

No cost on the non-optional path: the two consumption sites are the same single
reference compare they were, the fast lanes are unchanged, and the member link's
own test got cheaper (a readonly bool where an `IChainElement` type test used to
be). The new field and the build-time virtual are paid once per node, never per
evaluation.

Frees three test262 staging files, which now pass in every mode they declare:
`staging/sm/class/newTargetDVG.js`, `staging/sm/expressions/optional-chain.js`
and `staging/sm/extensions/extension-methods-reject-null-undefined-this.js` —
the last of which the exclusion banner claimed needed SpiderMonkey's `toSource`
extension and could not be fixed. It never did: `toSource` being absent is what
makes `(0, Object.prototype.toSource)()` reach the call in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lahma
lahma force-pushed the staging/optional-chain-sentinel branch from bb4a383 to c4f45ed Compare August 18, 2026 17:12
@lahma
lahma enabled auto-merge (squash) August 19, 2026 10:27
@lahma
lahma merged commit 65c00a3 into sebastienros:main Aug 19, 2026
9 of 10 checks passed
@lahma
lahma deleted the staging/optional-chain-sentinel branch August 20, 2026 19:41
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