Skip to content

fix: optional chaining short-circuits only one link, and pollutes indexed objects - #3688

Open
Jaybhade wants to merge 1 commit into
josdejong:developfrom
Jaybhade:fix/optional-chaining-short-circuit
Open

fix: optional chaining short-circuits only one link, and pollutes indexed objects#3688
Jaybhade wants to merge 1 commit into
josdejong:developfrom
Jaybhade:fix/optional-chaining-short-circuit

Conversation

@Jaybhade

@Jaybhade Jaybhade commented Aug 8, 2026

Copy link
Copy Markdown

Optional chaining keeps its short-circuit state in the evaluation context, and only the accessor directly after the optional one looks at it. Two things follow from that.

1. The chain stops short-circuiting after one link

math.evaluate('obj?.foo.bar', { obj: null })      // undefined
math.evaluate('obj?.foo.bar.baz', { obj: null })  // TypeError: Cannot access property "baz": object is undefined

bar is the only link that consults the state ?. set (prevOptionalChaining was isAccessorNode(this.object) && this.object.optionalChaining, so it looked exactly one accessor back), so baz is read off the undefined that bar returned. The same applies to a call at the end of a chain (obj?.foo.fn() threw "No access to method") and to a property of a call result (obj?.fn().foo).

2. The state is written onto the object of an index expression

The context of an index expression is the object being indexed, so the flag ends up on the user's own array or matrix, and is still there for the next evaluation:

const A = [10, 20, 30]
const scope = { A, first: () => 1, obj: { foo: { bar: 1 } }, nothing: null }

math.evaluate('A[first(nothing?.foo)]', scope)  // 10
Object.keys(A)                                  // ['0', '1', '2', 'optionalShortCircuit']  <-- A was mutated

math.evaluate('A[obj?.foo.bar]', scope)         // TypeError: Dimension must be an Array, Matrix, number, bigint, string, or Range

That last expression evaluates to 10 on its own; it only fails because it read the stale flag left on A. A math.matrix is affected the same way.

The fix

The short-circuit state is now an OptionalChain object that travels along the chain instead of living in the evaluation context, so it can never be confused with — or written onto — the object of an index expression. Every link of a chain honours it, not just the first one after ?., which also covers calls in a chain and accesses of a call result. Arguments of a call are evaluated outside the chain (x?.f(y?.z).g keeps working when y is nullish), matching how a nested chain behaves in JavaScript.

Behaviour that was already correct is unchanged: a chain still throws when nothing short-circuited (obj?.foo.bar with obj.foo === undefined throws, as its existing test requires), and (obj?.foo).bar still ends the chain at the parentheses.

Tests

Six cases added to test/unit-tests/expression/parse.test.js, next to the existing optional chaining tests: chains longer than two links in dot and bracket notation, calls in a chain, accesses of a call result, an argument that short-circuits inside a chain, and the index-expression case above (asserting the value and that A is untouched). Five of them fail on develop; the sixth guards against short-circuiting a chain that should not short-circuit.

npm run test:all (6658 unit + 36 generated + 282 node + types) and npm run lint pass.

…exed objects

An optional chain kept its short-circuit state in the evaluation context and
only honoured it in the accessor directly following the optional one, so:

- `obj?.foo.bar.baz` threw "Cannot access property" instead of returning
  undefined, since only `bar` looked at the state that `?.` had set;
- the state was written onto the context of an index expression, which is the
  object being indexed, so `A[first(nothing?.foo)]` left an
  `optionalShortCircuit` property on `A` and any later chain indexing `A` read
  that stale flag and short-circuited when it should not have.

The state now travels along the chain itself, and every link of the chain
honours it, including calls (`obj?.foo.fn()`) and accesses of a call result
(`obj?.fn().foo`). Arguments of a call are evaluated outside the chain, so a
short-circuit in an argument does not short-circuit the chain around the call.
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