Skip to content

Commit 65c00a3

Browse files
lahmaclaude
andauthored
Distinguish an optional-chain short circuit from a genuine undefined (#3040)
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>
1 parent 96db032 commit 65c00a3

5 files changed

Lines changed: 333 additions & 27 deletions

File tree

Jint.Tests.Test262/Test262Harness.settings.json

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,15 +172,12 @@
172172
"staging/sm/Function/arguments-parameter-shadowing.js",
173173
"staging/sm/regress/regress-552432.js",
174174

175-
// Argument validation raising no error, or the wrong one, on built-ins called with a bad this
176-
// or a bad index. Includes SpiderMonkey's own toSource extension, which Jint does not implement
177-
// at all -- that half is removed by the test moving, not by Jint changing.
175+
// Argument validation raising no error, or the wrong one: a bad this or a bad index reaching a
176+
// built-in, a NUL-bearing string reaching one, and the order in which a super reference is
177+
// evaluated against its side effects.
178178
"staging/sm/TypedArray/constructor-byteoffsets-bounds.js",
179179
"staging/sm/TypedArray/iterator-next-with-detached.js",
180-
"staging/sm/class/newTargetDVG.js",
181180
"staging/sm/class/superPropOrdering.js",
182-
"staging/sm/expressions/optional-chain.js",
183-
"staging/sm/extensions/extension-methods-reject-null-undefined-this.js",
184181
"staging/sm/extensions/quote-string-for-nul-character.js",
185182
"staging/sm/misc/builtin-methods-reject-null-undefined-this.js",
186183

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
using Jint.Native;
2+
3+
namespace Jint.Tests.Runtime;
4+
5+
/// <summary>
6+
/// Pins ECMA-262 §13.3.9.1 (https://tc39.es/ecma262/#sec-optional-chaining-evaluation): an optional
7+
/// chain short-circuits at the <c>?.</c> whose <em>own</em> base is nullish, and nowhere else. A link
8+
/// that merely produces <c>undefined</c> — because the property is absent, because the callee returned
9+
/// it, or because the value simply is <c>undefined</c> — continues the chain, and the next link throws.
10+
/// <para>
11+
/// The interpreter signals the short circuit by returning a marker up the chain. These tests exist
12+
/// because that marker used to be <see cref="JsValue.Undefined"/> itself, compared with
13+
/// <c>ReferenceEquals</c> — indistinguishable from a genuine <c>undefined</c>, so every expression that
14+
/// happened to evaluate to the singleton was treated as a short circuit and swallowed the
15+
/// <c>TypeError</c> it owed.
16+
/// </para>
17+
/// </summary>
18+
public class OptionalChainShortCircuitTests
19+
{
20+
/// <summary>
21+
/// Runs <paramref name="source"/> and reports the constructor name of whatever it threw, so a test
22+
/// pins the error <em>type</em> rather than a message that is free to change.
23+
/// </summary>
24+
private static string Throws(string source)
25+
{
26+
var engine = new Engine();
27+
return engine.Evaluate($$"""
28+
(function () {
29+
try {
30+
{{source}};
31+
} catch (e) {
32+
return e.constructor.name;
33+
}
34+
return 'did not throw';
35+
})()
36+
""").AsString();
37+
}
38+
39+
private static JsValue Evaluate(string source) => new Engine().Evaluate(source);
40+
41+
[Fact]
42+
public void CallingAValueThatIsUndefinedThrowsRatherThanShortCircuiting()
43+
{
44+
// The sequence expression yields the JsValue.Undefined singleton; nothing here is an optional
45+
// chain, so the call must throw.
46+
// (staging/sm/extensions/extension-methods-reject-null-undefined-this.js)
47+
Throws("(0, undefined)()").Should().Be("TypeError");
48+
}
49+
50+
[Fact]
51+
public void CallingAnUndefinedNewTargetThrows()
52+
{
53+
// Engine.GetNewTarget hands back the JsValue.Undefined singleton for a [[Call]]ed function.
54+
// (staging/sm/class/newTargetDVG.js)
55+
Throws("(function () { new.target(); })()").Should().Be("TypeError");
56+
}
57+
58+
[Fact]
59+
public void ANonOptionalLinkAfterAnOptionalOneStillThrows()
60+
{
61+
// `({})?.a` does not short-circuit — {} is not nullish — so it is a legitimate undefined and
62+
// the non-optional `['b']` that follows must throw.
63+
Throws("({})?.a['b']").Should().Be("TypeError");
64+
Throws("({})?.['a'].b").Should().Be("TypeError");
65+
Throws("({ a: { b: undefined } }).a?.b.b.c").Should().Be("TypeError");
66+
}
67+
68+
[Fact]
69+
public void CallingTheUndefinedResultOfACompletedOptionalCallThrows()
70+
{
71+
// `b?.()` does not short-circuit: b is callable. It simply returns undefined, and calling that
72+
// undefined must throw.
73+
Throws("(({ a: { b: () => undefined } }).a.b?.())()").Should().Be("TypeError");
74+
}
75+
76+
[Fact]
77+
public void ParenthesesEndTheChainSoTheNextLinkThrows()
78+
{
79+
// `(a?.b)` is a complete ChainExpression: its short circuit produces a real undefined, and the
80+
// member access outside the parentheses is not part of that chain.
81+
Throws("var a = null; (a?.b).c").Should().Be("TypeError");
82+
Throws("var a = null; (a?.b)()").Should().Be("TypeError");
83+
}
84+
85+
[Fact]
86+
public void ShortCircuitStopsTheWholeChain()
87+
{
88+
Evaluate("undefined?.a").Should().BeUndefined();
89+
Evaluate("null?.a?.b").Should().BeUndefined();
90+
Evaluate("var a; a?.()").Should().BeUndefined();
91+
Evaluate("null?.a['b']().c").Should().BeUndefined();
92+
Evaluate("null?.['a'].b()['c']").Should().BeUndefined();
93+
Evaluate("null?.()().a['b']").Should().BeUndefined();
94+
Evaluate("({ a: { b: undefined } }).a.b?.()()()").Should().BeUndefined();
95+
}
96+
97+
[Fact]
98+
public void OrdinaryChainsKeepResolving()
99+
{
100+
Evaluate("({ a: 1 })?.a").Should().Be(1);
101+
Evaluate("({ a: { b: [10, 20] } })?.a.b[1]").Should().Be(20);
102+
103+
// A deep chain mixing optional and non-optional links, calls and computed reads.
104+
var deep = Evaluate("""
105+
(function () {
106+
var o = {
107+
a: {
108+
b: function () { return this._b.bind(this); },
109+
_b: function () { return this.__b; },
110+
__b: { c: 42 }
111+
}
112+
};
113+
return [
114+
o?.a?.['b']?.()?.()?.c,
115+
o.a.b()().c,
116+
o?.a.b?.()().c,
117+
o?.missing?.['x']?.()?.()?.y
118+
].join(',');
119+
})()
120+
""");
121+
122+
deep.AsString().Should().Be("42,42,42,");
123+
}
124+
125+
[Fact]
126+
public void ShortCircuitSkipsTheRestOfTheChainWithoutEvaluatingIt()
127+
{
128+
// `y` is not declared: reaching the computed property expression at all would raise a
129+
// ReferenceError, so `true` proves the whole tail was skipped.
130+
Evaluate("delete undefined?.x[y + 1]").Should().Be(true);
131+
132+
// Every base is evaluated exactly once, and only up to the short circuit.
133+
var counts = Evaluate("""
134+
(function () {
135+
var calls = 0;
136+
var a = { b: { c: { d: function () { calls++; return a; } } } };
137+
a.b.c.d?.()?.b?.c?.d;
138+
139+
var reads = 0;
140+
var g = { get b() { reads++; return { c: {} }; } };
141+
g.b?.c?.d;
142+
g.b?.c?.d;
143+
144+
return calls + ',' + reads;
145+
})()
146+
""");
147+
148+
counts.AsString().Should().Be("1,2");
149+
}
150+
151+
[Fact]
152+
public void DeleteOfAShortCircuitedChainIsTrue()
153+
{
154+
Evaluate("delete undefined?.foo").Should().Be(true);
155+
Evaluate("delete null?.['foo']").Should().Be(true);
156+
Evaluate("delete null?.()").Should().Be(true);
157+
Evaluate("delete ({ a: { b: undefined } }).a?.b?.b").Should().Be(true);
158+
}
159+
160+
[Fact]
161+
public void TheVerdictSurvivesHandlerTreeReuseAndPreparedScripts()
162+
{
163+
// Whether a link propagates its short circuit is decided once, when the handler tree is built, so
164+
// it has to hold for a re-run on a warmed engine and for a Prepared<Script> shared across engines.
165+
var prepared = Engine.PrepareScript("""
166+
(function () {
167+
var results = [];
168+
results.push(String(null?.a['b']().c));
169+
try { ({})?.a['b']; results.push('did not throw'); }
170+
catch (e) { results.push(e.constructor.name); }
171+
return results.join(',');
172+
})()
173+
""");
174+
175+
var engine = new Engine();
176+
for (var i = 0; i < 3; i++)
177+
{
178+
engine.Evaluate(prepared).AsString().Should().Be("undefined,TypeError");
179+
}
180+
181+
new Engine().Evaluate(prepared).AsString().Should().Be("undefined,TypeError");
182+
}
183+
184+
[Fact]
185+
public void ShortCircuitedChainStaysAnOrdinaryUndefinedForItsConsumers()
186+
{
187+
// The short-circuit marker must never reach a consumer of the chain's value.
188+
Evaluate("typeof null?.a").Should().Be("undefined");
189+
Evaluate("null?.a === undefined").Should().Be(true);
190+
Evaluate("String(null?.a)").Should().Be("undefined");
191+
Evaluate("null?.a ?? 'fallback'").Should().Be("fallback");
192+
Evaluate("JSON.stringify({ x: null?.a })").Should().Be("{}");
193+
Evaluate("[null?.a].length").Should().Be(1);
194+
Evaluate("({ undefined: 3 })?.[null?.a]").Should().Be(3);
195+
Evaluate("((x) => typeof x)(null?.a)").Should().Be("undefined");
196+
}
197+
}

Jint/Runtime/Interpreter/Expressions/JintCallExpression.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ internal sealed class JintCallExpression : JintExpression
8282
/// </summary>
8383
private const int MaxRegisterArguments = 4;
8484

85+
// Set by the next link of the same optional chain (see JintExpression.EnableShortCircuitPropagation):
86+
// true means this node's short circuit is consumed by its parent link, false means this node is the
87+
// outermost link and its short circuit is the chain's value, i.e. a real undefined.
88+
private bool _propagatesShortCircuit;
89+
8590
public JintCallExpression(CallExpression expression) : base(expression)
8691
{
8792
_arguments.Initialize(expression.Arguments.AsSpan());
@@ -90,11 +95,27 @@ public JintCallExpression(CallExpression expression) : base(expression)
9095
_calleeMember = _calleeExpression as JintMemberExpression;
9196
_isTailPosition = ReferenceEquals(expression.UserData, TailCallMarker.Instance);
9297

98+
// A ChainExpression callee is a chain of its own whose value has already been taken
99+
// (`(a?.b)()`), so its short circuit is a real undefined and this call must run against it.
100+
// Anything else is the same chain continuing.
101+
if (expression.Callee.Type != NodeType.ChainExpression && CanShortCircuit(expression.Callee))
102+
{
103+
_calleeExpression.EnableShortCircuitPropagation();
104+
}
105+
93106
_argCount = expression.Arguments.Count;
94107
_fastArgsEligible = !_arguments.HasSpreads && _argCount <= 2;
95108
_regLaneEligible = !_arguments.HasSpreads && _argCount is >= 1 and <= MaxRegisterArguments;
96109
}
97110

111+
internal override void EnableShortCircuitPropagation() => _propagatesShortCircuit = true;
112+
113+
/// <summary>
114+
/// What this link returns when the chain short-circuits here or below: the signal when the next link
115+
/// is waiting for it, and the chain's actual value — <c>undefined</c> — when this is the outermost link.
116+
/// </summary>
117+
private JsValue ShortCircuit() => _propagatesShortCircuit ? ShortCircuited : JsValue.Undefined;
118+
98119
protected override object EvaluateInternal(EvaluationContext context)
99120
{
100121

@@ -176,17 +197,21 @@ protected override object EvaluateInternal(EvaluationContext context)
176197
return calleeReference as JsValue ?? JsValue.Undefined;
177198
}
178199

179-
if (ReferenceEquals(calleeReference, JsValue.Undefined))
200+
if (ReferenceEquals(calleeReference, ShortCircuited))
180201
{
181-
return JsValue.Undefined;
202+
// The callee link short-circuited, so this call is skipped along with the rest of the
203+
// chain — its arguments are never evaluated. A callee that merely evaluated to undefined
204+
// is *not* this case and falls through to the "not a function" throw below.
205+
return ShortCircuit();
182206
}
183207

184208
func = engine.GetValue(calleeReference, false);
185209

210+
// This link's own `?.(`: the value being called is nullish, so the chain short-circuits here.
186211
if (func.IsNullOrUndefined() && _expression.IsOptional())
187212
{
188213
engine._referencePool.Return(referenceRecord);
189-
return JsValue.Undefined;
214+
return ShortCircuit();
190215
}
191216

192217
if (ReferenceEquals(func, engine.Realm.Intrinsics.Eval)

Jint/Runtime/Interpreter/Expressions/JintExpression.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,62 @@ internal abstract class JintExpression
1010
{
1111
protected internal readonly Expression _expression;
1212

13+
/// <summary>
14+
/// The signal one optional-chain link hands the next when the chain short-circuits — that is, when a
15+
/// <c>?.</c> found its <em>own</em> base nullish (https://tc39.es/ecma262/#sec-optional-chaining-evaluation).
16+
/// It exists so that a short circuit is distinguishable from a link that legitimately produced
17+
/// <c>undefined</c>: <c>({})?.a['b']</c> must throw because <c>({})?.a</c> did not short-circuit, and
18+
/// <c>(0, undefined)()</c> must throw because nothing in it is a chain at all.
19+
/// <para>
20+
/// It cannot reach a script. A link produces it only when the node that evaluates it asked to receive
21+
/// it — <see cref="EnableShortCircuitPropagation"/>, called at build time by the member/call node that
22+
/// owns it as its object/callee — and every such node tests for it in the statement immediately
23+
/// following that evaluation and returns instead of using it. A link nobody asked (the outermost link
24+
/// of the chain, i.e. whatever a <c>ChainExpression</c> wraps) returns a real
25+
/// <see cref="JsValue.Undefined"/>, which is precisely what the chain's value is.
26+
/// </para>
27+
/// </summary>
28+
private protected static readonly JsValue ShortCircuited = OptionalChainShortCircuit.Instance;
29+
1330
protected JintExpression(Expression expression)
1431
{
1532
_expression = expression;
1633
}
1734

35+
/// <summary>
36+
/// Tells this node that whoever built it will evaluate it as the base of the next link in the same
37+
/// optional chain, and so will consume <see cref="ShortCircuited"/> rather than mistake it for a value.
38+
/// Only the two link types — <see cref="JintMemberExpression"/> and <see cref="JintCallExpression"/> —
39+
/// can short-circuit at all, so every other node ignores the request. Build time only; nothing calls
40+
/// this during evaluation.
41+
/// </summary>
42+
internal virtual void EnableShortCircuitPropagation()
43+
{
44+
}
45+
46+
/// <summary>
47+
/// Whether evaluating <paramref name="expression"/> can end in an optional chain's short circuit: it is
48+
/// itself a <c>?.</c> link, or it is a link whose own base can. A <c>ChainExpression</c> is deliberately
49+
/// transparent here — the predicate gates fast paths, where over-approximating costs a fast lane and
50+
/// never correctness; the chain <em>boundary</em> a <c>ChainExpression</c> marks is honoured where
51+
/// propagation is enabled instead.
52+
/// </summary>
53+
private protected static bool CanShortCircuit(Expression expression)
54+
{
55+
if (expression.IsOptional())
56+
{
57+
return true;
58+
}
59+
60+
return expression switch
61+
{
62+
ChainExpression chainExpression => CanShortCircuit(chainExpression.Expression),
63+
CallExpression callExpression => CanShortCircuit(callExpression.Callee),
64+
MemberExpression memberExpression => CanShortCircuit(memberExpression.Object),
65+
_ => false
66+
};
67+
}
68+
1869
/// <summary>
1970
/// Resolves the underlying value for this expression.
2071
/// By default uses the Engine for resolving.
@@ -574,3 +625,27 @@ protected static bool AreIntegerOperands(JsValue left, JsValue right)
574625
return left._type == right._type && left._type == InternalTypes.Integer;
575626
}
576627
}
628+
629+
/// <summary>
630+
/// The single instance behind <see cref="JintExpression.ShortCircuited"/>. A dedicated type rather than a
631+
/// reused <see cref="JsValue.Undefined"/> (which is exactly what made the short circuit indistinguishable
632+
/// from a value) or a marker string (which would masquerade as one if it ever escaped): it claims no
633+
/// JavaScript type at all, so nothing coerces or compares it into looking like a value, and the one member
634+
/// through which a host could observe it throws instead of answering.
635+
/// </summary>
636+
internal sealed class OptionalChainShortCircuit : JsValue
637+
{
638+
internal static readonly OptionalChainShortCircuit Instance = new();
639+
640+
private OptionalChainShortCircuit() : base(Types.Empty)
641+
{
642+
}
643+
644+
public override object? ToObject()
645+
{
646+
Throw.InvalidOperationException("The optional chain short-circuit signal escaped the chain that produced it.");
647+
return null;
648+
}
649+
650+
public override string ToString() => "[[optional chain short-circuit]]";
651+
}

0 commit comments

Comments
 (0)