From ffe1b133348484ad22ba13d756326c49e205255f Mon Sep 17 00:00:00 2001 From: Matthew Gaudet Date: Thu, 20 Aug 2026 10:45:43 -0600 Subject: [PATCH 1/3] Add thenable-curtailment to features.txt --- features.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/features.txt b/features.txt index ac9cec96a5c..0411212c006 100644 --- a/features.txt +++ b/features.txt @@ -77,6 +77,10 @@ error-stack-accessor # https://github.com/tc39/proposal-iterator-join Iterator.prototype.join +# Thenable Curtailment +# https://github.com/tc39/proposal-thenable-curtailment +thenable-curtailment + ## Standard language features # # Language features that have been included in a published version of the From 9689d204273d892b0a289c3ea8c952b20afcb56e Mon Sep 17 00:00:00 2001 From: Matthew Gaudet Date: Thu, 20 Aug 2026 10:52:15 -0600 Subject: [PATCH 2/3] Add the safePromiseResolve host-defined function to docuemntation and features.txt --- INTERPRETING.md | 1 + features.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/INTERPRETING.md b/INTERPRETING.md index 9526b20f889..63ae7921464 100644 --- a/INTERPRETING.md +++ b/INTERPRETING.md @@ -81,6 +81,7 @@ properties of the global scope prior to test execution. Use this property to test that ECMAScript algorithms aren't mis-implemented to treat `document.all` as being `undefined` or of type Undefined (instead of Object). **Tests using this function must be tagged with the `IsHTMLDDA` feature so that only hosts supporting this property will run them.** + - **`safeResolvePromise`** - (present only in implementations that can provide it) a function which takes as its first argument a promise and as its second argument a value, and uses the SafeResolve semantics provided by the [Thenable Curtailment](https://github.com/tc39/proposal-thenable-curtailment) proposal to resolve the provided promise with the value. - **`agent`** - an ordinary object with the following properties: - **`start`** - a function that takes a script source string and runs the script in a concurrent agent. Will block until that agent is diff --git a/features.txt b/features.txt index 0411212c006..952a5851bdc 100644 --- a/features.txt +++ b/features.txt @@ -277,3 +277,4 @@ __setter__ IsHTMLDDA host-gc-required +safeResolvePromise From 99c60bedd206bb00b91638421eaa8a2eb0e51105 Mon Sep 17 00:00:00 2001 From: Matthew Gaudet Date: Mon, 31 Aug 2026 13:08:30 -0600 Subject: [PATCH 3/3] Add tests for Thenable-Curtailment proposal These tests were authored with the assistance of an LLM, however they were targeted and reviewed by myself. The tests were validated on the prototype implemetnation of SafePromiseResolve in the Firefox tree; the tests highlight a current divergence between the SpiderMonkey implementation and the spec-text as written (specifically SpiderMonkey will currently defer on a `then` setter even with no getter, which is not correct by specification). --- .../deferred-module-namespace.js | 63 +++++++++++ .../deferred-module-namespace_FIXTURE.js | 4 + .../deferred-native-promise-pending-ticks.js | 72 ++++++++++++ .../deferred-native-promise-ticks.js | 79 +++++++++++++ .../deferred-object-prototype-then.js | 86 +++++++++++++++ .../deferred-proxy-empty-handler.js | 74 +++++++++++++ .../deferred-proxy-get-trap.js | 81 ++++++++++++++ .../deferred-proxy-on-prototype-chain.js | 93 ++++++++++++++++ .../deferred-proxy-revoked.js | 70 ++++++++++++ .../deferred-self-resolution.js | 74 +++++++++++++ .../deferred-then-call-throws.js | 72 ++++++++++++ .../deferred-then-callable-own.js | 62 +++++++++++ .../deferred-then-callable-proto.js | 90 +++++++++++++++ .../deferred-then-deleted-before-job.js | 80 ++++++++++++++ .../deferred-then-getter-own.js | 69 ++++++++++++ .../deferred-then-getter-proto.js | 84 ++++++++++++++ .../deferred-then-getter-throws.js | 68 ++++++++++++ .../deferred-then-reject.js | 72 ++++++++++++ .../deferred-then-this-binding.js | 56 ++++++++++ .../latch-after-deferred.js | 80 ++++++++++++++ .../safe-resolve-promise/latch-after-sync.js | 41 +++++++ .../latch-already-settled.js | 67 +++++++++++ .../latch-double-safe-resolve.js | 63 +++++++++++ .../latch-thenable-resolving-functions.js | 104 ++++++++++++++++++ .../safe-resolve-promise/sync-no-then.js | 88 +++++++++++++++ .../safe-resolve-promise/sync-non-object.js | 83 ++++++++++++++ ...ull-proto-ignores-object-prototype-then.js | 81 ++++++++++++++ ...-own-non-callable-then-shadows-callable.js | 71 ++++++++++++ .../sync-self-resolution.js | 86 +++++++++++++++ .../sync-then-not-callable-proto.js | 83 ++++++++++++++ .../sync-then-not-callable.js | 77 +++++++++++++ .../sync-then-setter-only-proto.js | 78 +++++++++++++ .../sync-then-setter-only.js | 69 ++++++++++++ 33 files changed, 2420 insertions(+) create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace_FIXTURE.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-pending-ticks.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-ticks.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-object-prototype-then.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-proxy-empty-handler.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-proxy-get-trap.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-proxy-on-prototype-chain.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-proxy-revoked.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-self-resolution.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-then-call-throws.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-own.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-proto.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-then-deleted-before-job.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-own.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-proto.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-throws.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-then-reject.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/deferred-then-this-binding.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/latch-after-deferred.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/latch-after-sync.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/latch-already-settled.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/latch-double-safe-resolve.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/latch-thenable-resolving-functions.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/sync-no-then.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/sync-non-object.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/sync-null-proto-ignores-object-prototype-then.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/sync-own-non-callable-then-shadows-callable.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/sync-self-resolution.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable-proto.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only-proto.js create mode 100644 test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only.js diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace.js b/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace.js new file mode 100644 index 00000000000..65f8fb32034 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace.js @@ -0,0 +1,63 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when the resolution is a module + namespace exotic object. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 3. If _o_ has the [[GetPrototypeOf]] and [[GetOwnProperty]] internal methods + as defined in Module Namespace Exotic Objects, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [module, async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +import * as ns from "./deferred-module-namespace_FIXTURE.js"; + +var expected = [ + // Being a module namespace object forces deferral without any lookup. + "start", + + "tick 1", + "tick 2", + + // No callable "then" is found, so the namespace object fulfills the promise + // from the deferred job, one microtask behind a synchronous resolution. + "settled", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a module namespace object" + ); + }); + + assert.sameValue(ns.then, undefined, "the fixture module does not export \"then\""); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, ns); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + ns, + "promise is fulfilled with the module namespace object itself" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace_FIXTURE.js b/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace_FIXTURE.js new file mode 100644 index 00000000000..41334dc4bae --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace_FIXTURE.js @@ -0,0 +1,4 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +export var x = 1; diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-pending-ticks.js b/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-pending-ticks.js new file mode 100644 index 00000000000..44fc75bf980 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-pending-ticks.js @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Resolving with a still-pending native promise via SafePromiseResolve takes + the same number of microtasks as an ordinary resolution. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 9. If _thenCallTiming_ is ~deferred~, then + a. Perform ! PerformPromiseResolveThenable(_promise_, _resolution_, + _thenAction_). + b. Return ~unused~. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +// As for an already-fulfilled inner promise, the two paths settle in the same +// microtask, shown by the two settlements being adjacent and in the order their +// reactions were attached. +var expected = [ + "start", + + "tick 1", + "tick 2", + "tick 3", + + // Were the safe path to take an extra microtask, these two would swap. + "settled safe", + "settled ordinary", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => actual.push("tick 3")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for resolving with a pending promise" + ); + }); + + var safeInner = Promise.withResolvers(); + var safe = Promise.withResolvers(); + $262.safeResolvePromise(safe.promise, safeInner.promise); + var safeSettled = safe.promise.then(function(settledValue) { + actual.push("settled safe"); + assert.sameValue(settledValue, "inner", "fulfilled with the inner promise's value"); + }); + safeInner.resolve("inner"); + + var ordinaryInner = Promise.withResolvers(); + var ordinary = Promise.withResolvers(); + ordinary.resolve(ordinaryInner.promise); + var ordinarySettled = ordinary.promise.then(function(settledValue) { + actual.push("settled ordinary"); + assert.sameValue(settledValue, "inner", "fulfilled with the inner promise's value"); + }); + ordinaryInner.resolve("inner"); + + actual.push("start"); + + return Promise.all([ruler, safeSettled, ordinarySettled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-ticks.js b/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-ticks.js new file mode 100644 index 00000000000..9425541b6e6 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-ticks.js @@ -0,0 +1,79 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Resolving with an already-fulfilled native promise via SafePromiseResolve + takes the same number of microtasks as an ordinary resolution. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 9. If _thenCallTiming_ is ~deferred~, then + a. Perform ! PerformPromiseResolveThenable(_promise_, _resolution_, + _thenAction_). + b. Return ~unused~. + 10. Let _thenJobCallback_ be HostMakeJobCallback(_thenAction_). + 11. Let _job_ be NewPromiseResolveThenableJob(_promise_, _resolution_, + _thenJobCallback_). + 12. Perform HostEnqueuePromiseJob(_job_.[[Job]], _job_.[[Realm]]). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +// A native promise has a callable "then" reachable without running user code, so +// an ordinary resolution already enqueues a job for it. In the deferred job +// _thenCallTiming_ is ~deferred~, so the "then" call is performed inline rather +// than in a further job, and the two paths settle in the same microtask. +// +// The tick each settlement lands on is fully determined by the spec given this +// test's construction, but it is not a count of the resolution's own microtasks: +// it also reflects the interleaving with the ruler. What carries the claim here +// is that the two settlements are adjacent, in the order their reactions were +// attached. +var expected = [ + "start", + + "tick 1", + "tick 2", + "tick 3", + + // Were the safe path to take an extra microtask, these two would swap. + "settled safe", + "settled ordinary", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => actual.push("tick 3")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for resolving with an already-fulfilled promise" + ); + }); + + var safe = Promise.withResolvers(); + $262.safeResolvePromise(safe.promise, Promise.resolve("inner")); + var safeSettled = safe.promise.then(function(settledValue) { + actual.push("settled safe"); + assert.sameValue(settledValue, "inner", "fulfilled with the inner promise's value"); + }); + + var ordinary = Promise.withResolvers(); + ordinary.resolve(Promise.resolve("inner")); + var ordinarySettled = ordinary.promise.then(function(settledValue) { + actual.push("settled ordinary"); + assert.sameValue(settledValue, "inner", "fulfilled with the inner promise's value"); + }); + + actual.push("start"); + + return Promise.all([ruler, safeSettled, ordinarySettled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-object-prototype-then.js b/test/built-ins/Promise/safe-resolve-promise/deferred-object-prototype-then.js new file mode 100644 index 00000000000..de4ae48b5d9 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-object-prototype-then.js @@ -0,0 +1,86 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A callable "then" on Object.prototype defers resolution of an ordinary + object. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // Nothing has run yet: the inherited "then" was not called synchronously. + "start", + + "tick 1", + + // The deferred job calls the inherited "then". + "call Object.prototype.then", + + "tick 2", + + // Resolved from the deferred job, so its reaction runs after it. + "settled", +]; + +var actual = []; + +var thenCalledWith; +var value = {}; + +Object.defineProperty(Object.prototype, "then", { + value: function(resolve) { + actual.push("call Object.prototype.then"); + thenCalledWith = this; + resolve("from Object.prototype"); + }, + writable: true, + enumerable: false, + configurable: true, +}); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + try { + assert.compareArray( + actual, + expected, + "Ticks for a callable Object.prototype.then" + ); + assert.sameValue( + thenCalledWith, + value, + "\"then\" is called with the resolution as its this value" + ); + } finally { + delete Object.prototype.then; + } + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from Object.prototype", + "the inherited \"then\" resolves the promise" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-empty-handler.js b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-empty-handler.js new file mode 100644 index 00000000000..60b5d0b44da --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-empty-handler.js @@ -0,0 +1,74 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when the resolution is a Proxy exotic + object, even when its handler defines no traps. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 2. If _o_ has the [[GetPrototypeOf]] and [[GetOwnProperty]] internal methods + as defined in Proxy Object Internal Methods and Internal Slots, return + *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +var expected = [ + "start", + + "tick 1", + "tick 2", + + // Every resolution was deferred to a job, so each settlement lands after + // "tick 2" rather than before it, whatever the target's own shape was. + "settled plain object", + "settled non-callable then", + "settled array", + "settled function", +]; + +// The target's own shape must not matter: deferral is forced by the Proxy. +var targets = [ + ["plain object", {}], + ["non-callable then", { then: 42 }], + ["array", []], + ["function", function() {}], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for Proxy resolutions"); + }); + + var checks = []; + + targets.forEach(function(entry) { + var label = entry[0]; + var value = new Proxy(entry[1], {}); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the Proxy itself: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-get-trap.js b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-get-trap.js new file mode 100644 index 00000000000..b88ee328db4 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-get-trap.js @@ -0,0 +1,81 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A Proxy "get" trap returning a callable "then" is invoked from the deferred + job, not from SafePromiseResolve itself. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 3. Let _deferredSteps_ be a new Abstract Closure that captures _promise_ and + _resolution_ and performs the following steps when called: + a. Perform ? PerformPromiseResolution(_promise_, _resolution_, ~deferred~). + + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 4. Let _then_ be Completion(Get(_resolution_, *"then"*)). + ... + 9. If _thenCallTiming_ is ~deferred~, then + a. Perform ! PerformPromiseResolveThenable(_promise_, _resolution_, + _thenAction_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +var expected = [ + // Being a Proxy forces deferral before any trap can run. + "start", + + "tick 1", + + // The deferred job reads "then" once and calls what the trap returned. + "get:then", + "call then", + + "tick 2", + "settled", +]; + +var actual = []; + +// "then" is the only property read from the resolution, so any other lookup +// shows up in the comparison below as an unexpected entry. +var value = new Proxy({}, { + get: function(target, key) { + actual.push("get:" + String(key)); + if (key !== "then") { + return undefined; + } + return function(resolve) { + actual.push("call then"); + resolve("from the trap"); + }; + }, +}); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a Proxy \"get\" trap"); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from the trap", + "promise is fulfilled with the value passed to the resolving function" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-on-prototype-chain.js b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-on-prototype-chain.js new file mode 100644 index 00000000000..49b96c3b939 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-on-prototype-chain.js @@ -0,0 +1,93 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when an ordinary object has a Proxy on + its prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 2. If _o_ has the [[GetPrototypeOf]] and [[GetOwnProperty]] internal methods + as defined in Proxy Object Internal Methods and Internal Slots, return + *true*. + ... + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +var actual = []; + +// The Proxy is the immediate prototype, and further up the chain behind +// ordinary objects. +function makeValue(label, depth) { + var proxyProto = new Proxy({}, { + get: function(target, key, receiver) { + actual.push("get:" + String(key) + " " + label); + return Reflect.get(target, key, receiver); + }, + }); + + var value = proxyProto; + for (var i = 0; i < depth; i += 1) { + value = Object.create(value); + } + return value; +} + +var expected = [ + "start", + + "tick 1", + + // Each deferred job performs its own "then" lookup. + "get:then immediate", + "get:then deep", + + "tick 2", + + // No callable "then" is found, so each promise fulfills with the resolution. + "settled immediate", + "settled deep", +]; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a Proxy on the prototype chain" + ); + }); + + var checks = []; + + [["immediate", 1], ["deep", 3]].forEach(function(entry) { + var label = entry[0]; + var value = makeValue(label, entry[1]); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-revoked.js b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-revoked.js new file mode 100644 index 00000000000..9cbc6cd5923 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-revoked.js @@ -0,0 +1,70 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Resolving with a revoked Proxy rejects the promise from the deferred job, + without SafePromiseResolve throwing. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 2. If _o_ has the [[GetPrototypeOf]] and [[GetOwnProperty]] internal methods + as defined in Proxy Object Internal Methods and Internal Slots, return + *true*. + + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 4. Let _then_ be Completion(Get(_resolution_, *"then"*)). + 5. If _then_ is an abrupt completion, then + a. Perform RejectPromise(_promise_, _then_.[[Value]]). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +var expected = [ + // No lookup is attempted, so the revoked Proxy cannot throw here. + "start", + + "tick 1", + "tick 2", + + // The lookup in the deferred job throws, rejecting the promise. + "rejected", +]; + +var actual = []; + +var revocable = Proxy.revocable({}, {}); +revocable.revoke(); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a revoked Proxy"); + }); + + var capability = Promise.withResolvers(); + + $262.safeResolvePromise(capability.promise, revocable.proxy); + actual.push("start"); + + var settled = capability.promise.then( + function() { + actual.push("fulfilled"); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue( + reason instanceof TypeError, + true, + "the promise is rejected with the TypeError thrown by the revoked Proxy" + ); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-self-resolution.js b/test/built-ins/Promise/safe-resolve-promise/deferred-self-resolution.js new file mode 100644 index 00000000000..e5eee45d19c --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-self-resolution.js @@ -0,0 +1,74 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Resolving a promise with itself through SafePromiseResolve rejects it with a + TypeError from the deferred job. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 2. If SameValue(_resolution_, _promise_) is *true*, then + a. Let _selfResolutionError_ be a newly created *TypeError* object. + b. Perform RejectPromise(_promise_, _selfResolutionError_). + c. Return ~unused~. + + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +// SafePromiseResolve does not special-case a resolution which is the promise +// itself: the promise inherits a callable "then" from Promise.prototype, so +// RequiresDeferredPromiseResolution reports true and resolution is deferred like +// any other thenable. The self-resolution check then runs in the deferred job. +// See sync-self-resolution.js for the same error reported synchronously, which +// is what happens when the promise does not look thenable. +var expected = [ + // No check has run yet, so SafePromiseResolve reports nothing. + "start", + + "tick 1", + "tick 2", + + // The deferred job finds SameValue(resolution, promise) and rejects. + "rejected", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a deferred self resolution"); + }); + + var capability = Promise.withResolvers(); + + // SafePromiseResolve must not throw. + $262.safeResolvePromise(capability.promise, capability.promise); + actual.push("start"); + + var settled = capability.promise.then( + function(settledValue) { + throw new Test262Error("the promise must not be fulfilled: " + settledValue); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue( + reason instanceof TypeError, + true, + "the promise is rejected with a TypeError" + ); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-call-throws.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-call-throws.js new file mode 100644 index 00000000000..0da6cb220e6 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-call-throws.js @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolve-thenable +description: > + A "then" method that throws when called rejects the promise from the + deferred job, without SafePromiseResolve throwing. +info: | + PerformPromiseResolveThenable ( promiseToResolve, thenable, then ) + + 2. If _then_ is a function object, then + a. Let _thenCallResult_ be Completion(Call(_then_, _thenable_, + « _resolvingFunctions_.[[Resolve]], + _resolvingFunctions_.[[Reject]] »)). + ... + 4. If _thenCallResult_ is an abrupt completion, then + a. Return ! Call(_resolvingFunctions_.[[Reject]], *undefined*, + « _thenCallResult_.[[Value]] »). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // "then" has not been called, so nothing can have thrown yet. + "start", + + "tick 1", + + // The deferred job calls "then", which throws. + "call then", + + "tick 2", + "rejected", +]; + +var actual = []; + +var sentinel = new Error("thrown by \"then\""); +var value = { + then: function() { + actual.push("call then"); + throw sentinel; + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a throwing \"then\" call"); + }); + + var capability = Promise.withResolvers(); + + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then( + function() { + actual.push("fulfilled"); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue(reason, sentinel, "the promise is rejected with the thrown value"); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-own.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-own.js new file mode 100644 index 00000000000..520ab3cd73b --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-own.js @@ -0,0 +1,62 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve defers the call to an own callable "then" data property. +info: | + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // SafePromiseResolve returns without calling "then". + "start", + + "tick 1", + + // The deferred job calls "then". + "call then", + + "tick 2", + "settled", +]; + +var actual = []; + +var value = { + then: function(resolve) { + actual.push("call then"); + resolve("from then"); + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a deferred callable \"then\""); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from then", + "promise is fulfilled with the value passed to the resolving function" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-proto.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-proto.js new file mode 100644 index 00000000000..14f4be03371 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-proto.js @@ -0,0 +1,90 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve defers the call to a callable "then" inherited from the + prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). + + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var actual = []; + +var proto = { + then: function(resolve) { + actual.push("call then " + this.label); + resolve("from the prototype: " + this.label); + }, +}; + +var expected = [ + "start", + + "tick 1", + + // Each deferred job calls the inherited "then". + "call then immediate", + "call then deep", + + "tick 2", + + // Each promise was resolved during its job, so its reaction runs after it. + "settled immediate", + "settled deep", +]; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a deferred inherited callable \"then\"" + ); + }); + + // Directly inherited, and inherited from further up the chain. + var checks = []; + + [["immediate", 1], ["deep", 3]].forEach(function(entry) { + var label = entry[0]; + + var value = proto; + for (var i = 0; i < entry[1]; i += 1) { + value = Object.create(value); + } + value.label = label; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + "from the prototype: " + label, + "promise is fulfilled with the value passed to the resolving function: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-deleted-before-job.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-deleted-before-job.js new file mode 100644 index 00000000000..1b2b058f56e --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-deleted-before-job.js @@ -0,0 +1,80 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Deleting the callable "then" after SafePromiseResolve returns, but before + the deferred job runs, fulfills the promise with the resolution itself. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 4. Let _then_ be Completion(Get(_resolution_, *"then"*)). + ... + 8. If IsCallable(_thenAction_) is *false*, then + a. Perform FulfillPromise(_promise_, _resolution_). + b. Return ~unused~. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // Deferred, because "then" was callable at the time of the call. + "start", + + // Still within the deferral window, so this is observable by the job. + "deleted then", + + "tick 1", + "tick 2", + + // No callable "then" remains, so "call then" never appears and the promise + // is fulfilled with the resolution itself. + "settled", +]; + +var actual = []; + +var value = { + then: function(resolve) { + actual.push("call then"); + resolve("must not be observed"); + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a \"then\" deleted during the deferral window" + ); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + delete value.then; + actual.push("deleted then"); + + var settled = capability.promise.then( + function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself, as for any non-thenable" + ); + }, + function() { + actual.push("rejected"); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-own.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-own.js new file mode 100644 index 00000000000..2c000713011 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-own.js @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when the resolution has an own "then" + getter, and the getter runs in the deferred job. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + i. If _kind_ is either ~any~ or ~get~, and _desc_.[[Get]] is not + *undefined*, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // SafePromiseResolve returns without reading "then". + "start", + + "tick 1", + + // The deferred job reads "then" and calls it. + "get then", + "call then", + + "tick 2", + "settled", +]; + +var actual = []; + +var value = { + get then() { + actual.push("get then"); + return function(resolve) { + actual.push("call then"); + resolve("from the getter"); + }; + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a deferred \"then\" getter"); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from the getter", + "promise is fulfilled with the value passed to the resolving function" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-proto.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-proto.js new file mode 100644 index 00000000000..f762df6dce9 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-proto.js @@ -0,0 +1,84 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when a "then" getter is inherited from + the prototype chain, and the getter runs in the deferred job. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + i. If _kind_ is either ~any~ or ~get~, and _desc_.[[Get]] is not + *undefined*, return *true*. + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + ... + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // SafePromiseResolve returns without reading "then". + "start", + + "tick 1", + + // The deferred job reads "then" and calls it. + "get then", + "call then", + + "tick 2", + "settled", +]; + +var actual = []; + +var value; +var proto = { + get then() { + actual.push("get then"); + assert.sameValue( + this, + value, + "the getter receives the resolution, not the prototype, as its receiver" + ); + return function(resolve) { + actual.push("call then"); + resolve("from the inherited getter"); + }; + }, +}; + +value = Object.create(proto); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a deferred inherited \"then\" getter" + ); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from the inherited getter", + "promise is fulfilled with the value passed to the resolving function" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-throws.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-throws.js new file mode 100644 index 00000000000..6c9dc25694e --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-throws.js @@ -0,0 +1,68 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + A throwing "then" getter rejects the promise from the deferred job, without + SafePromiseResolve throwing. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 4. Let _then_ be Completion(Get(_resolution_, *"then"*)). + 5. If _then_ is an abrupt completion, then + a. Perform RejectPromise(_promise_, _then_.[[Value]]). + b. Return ~unused~. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // The getter has not run, so nothing can have thrown yet. + "start", + + "tick 1", + + // The deferred job reads "then", and the getter throws. + "get then", + + "tick 2", + "rejected", +]; + +var actual = []; + +var sentinel = new Error("thrown by the \"then\" getter"); +var value = { + get then() { + actual.push("get then"); + throw sentinel; + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a throwing \"then\" getter"); + }); + + var capability = Promise.withResolvers(); + + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then( + function() { + actual.push("fulfilled"); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue(reason, sentinel, "the promise is rejected with the thrown value"); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-reject.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-reject.js new file mode 100644 index 00000000000..42192a4f921 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-reject.js @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolve-thenable +description: > + A deferred "then" rejects the promise by calling the second resolving + function it is passed. +info: | + PerformPromiseResolveThenable ( promiseToResolve, thenable, then ) + + 1. Let _resolvingFunctions_ be CreateResolvingFunctions(_promiseToResolve_). + 2. If _then_ is a function object, then + a. Let _thenCallResult_ be Completion(Call(_then_, _thenable_, + « _resolvingFunctions_.[[Resolve]], + _resolvingFunctions_.[[Reject]] »)). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // "then" has not been called, so the promise cannot have been rejected yet. + "start", + + "tick 1", + + // The deferred job calls "then", which rejects through its second argument. + "call then", + + "tick 2", + "rejected", +]; + +var actual = []; + +var sentinel = new Error("passed to the reject function"); +var value = { + then: function(_resolve, reject) { + actual.push("call then"); + reject(sentinel); + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a rejecting deferred \"then\""); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then( + function() { + throw new Test262Error("the promise must not be fulfilled"); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue( + reason, + sentinel, + "the promise is rejected with the value passed to the reject function" + ); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-this-binding.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-this-binding.js new file mode 100644 index 00000000000..22c82766949 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-this-binding.js @@ -0,0 +1,56 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolve-thenable +description: > + The deferred "then" call receives the resolution as its this value and two + distinct fresh resolving functions. +info: | + PerformPromiseResolveThenable ( promiseToResolve, thenable, then ) + + 1. Let _resolvingFunctions_ be CreateResolvingFunctions(_promiseToResolve_). + 2. If _then_ is a function object, then + a. Let _thenCallResult_ be Completion(Call(_then_, _thenable_, + « _resolvingFunctions_.[[Resolve]], + _resolvingFunctions_.[[Reject]] »)). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var observed; +var value = { + then: function(resolve, reject) { + observed = { + thisValue: this, + argCount: arguments.length, + resolve: resolve, + reject: reject, + }; + resolve("done"); + }, +}; + +asyncTest(function() { + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + return capability.promise.then(function(settledValue) { + assert.sameValue(settledValue, "done", "promise is fulfilled with the resolved value"); + + assert.sameValue( + observed.thisValue, + value, + "\"then\" is called with the thenable as its this value" + ); + assert.sameValue(observed.argCount, 2, "\"then\" is called with exactly two arguments"); + assert.sameValue(typeof observed.resolve, "function", "the first argument is a function"); + assert.sameValue(typeof observed.reject, "function", "the second argument is a function"); + assert.notSameValue( + observed.resolve, + observed.reject, + "the resolving functions are distinct" + ); + }); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-after-deferred.js b/test/built-ins/Promise/safe-resolve-promise/latch-after-deferred.js new file mode 100644 index 00000000000..055d84d8a53 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-after-deferred.js @@ -0,0 +1,80 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A promise whose resolution was deferred is already latched on return from + SafePromiseResolve: later calls to its resolving functions are no-ops. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + ... + 5. Let _wrapper_ be OrdinaryObjectCreate(*null*). + 6. Perform ! CreateDataPropertyOrThrow(_wrapper_, *"then"*, _deferredThen_). + 7. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _wrapper_ »). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +function mustNotReject(reason) { + throw new Test262Error("promise was rejected: " + reason); +} + +asyncTest(function() { + // A thenable resolution, raced by both resolving functions. + var thenCallCount = 0; + var raced = Promise.withResolvers(); + $262.safeResolvePromise(raced.promise, { + then: function(resolve) { + thenCallCount += 1; + resolve("from the thenable"); + }, + }); + raced.resolve("racing resolve"); + raced.reject("racing reject"); + + var racedCheck = raced.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + "from the thenable", + "the deferred resolution wins over both racing calls" + ); + assert.sameValue(thenCallCount, 1, "the thenable's \"then\" still runs exactly once"); + }, mustNotReject); + + // A thenable resolution, raced by reject alone. + var rejected = Promise.withResolvers(); + $262.safeResolvePromise(rejected.promise, { + then: function(resolve) { + resolve("still fulfilled"); + }, + }); + rejected.reject(new Error("must be ignored")); + + var rejectedCheck = rejected.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + "still fulfilled", + "the deferred resolution wins over the racing reject" + ); + }, mustNotReject); + + // A deferred resolution which is not a thenable at all. + var proxy = new Proxy({}, {}); + var nonThenable = Promise.withResolvers(); + $262.safeResolvePromise(nonThenable.promise, proxy); + nonThenable.resolve("racing resolve"); + + var nonThenableCheck = nonThenable.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + proxy, + "the promise is fulfilled with the deferred resolution, not the racing value" + ); + }, mustNotReject); + + return Promise.all([racedCheck, rejectedCheck, nonThenableCheck]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-after-sync.js b/test/built-ins/Promise/safe-resolve-promise/latch-after-sync.js new file mode 100644 index 00000000000..a763f6b991f --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-after-sync.js @@ -0,0 +1,41 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A promise resolved through the synchronous path of SafePromiseResolve is + latched: later calls to its resolving functions are no-ops. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 1. If RequiresDeferredPromiseResolution(_resolution_) is *false*, then + a. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _resolution_ »). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +asyncTest(function() { + var capability = Promise.withResolvers(); + + $262.safeResolvePromise(capability.promise, "first"); + capability.resolve("racing resolve"); + capability.reject("racing reject"); + + return capability.promise.then( + function(settledValue) { + assert.sameValue( + settledValue, + "first", + "the promise keeps the value it was resolved with" + ); + }, + function(reason) { + throw new Test262Error( + "the racing reject must not reject an already-resolved promise: " + reason + ); + } + ); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-already-settled.js b/test/built-ins/Promise/safe-resolve-promise/latch-already-settled.js new file mode 100644 index 00000000000..49d97a71072 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-already-settled.js @@ -0,0 +1,67 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve is a no-op on a promise whose resolving functions have + already been used. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 1. If RequiresDeferredPromiseResolution(_resolution_) is *false*, then + a. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _resolution_ »). + ... + 7. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _wrapper_ »). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var thenCallCount = 0; +var thenable = { + then: function(resolve) { + thenCallCount += 1; + resolve("must not be observed"); + }, +}; + +asyncTest(function() { + // Already fulfilled. + var fulfilled = Promise.withResolvers(); + fulfilled.resolve("fulfilled first"); + $262.safeResolvePromise(fulfilled.promise, thenable); + + var fulfilledCheck = fulfilled.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + "fulfilled first", + "the promise keeps its original value" + ); + }); + + // Already rejected. + var reason = new Error("rejected first"); + var rejected = Promise.withResolvers(); + rejected.reject(reason); + $262.safeResolvePromise(rejected.promise, thenable); + + var rejectedCheck = rejected.promise.then( + function() { + throw new Test262Error("an already-rejected promise must stay rejected"); + }, + function(settledReason) { + assert.sameValue(settledReason, reason, "the promise keeps its original reason"); + } + ); + + return Promise.all([fulfilledCheck, rejectedCheck]).then(function() { + assert.sameValue( + thenCallCount, + 0, + "the ignored resolution's \"then\" is never called" + ); + }); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-double-safe-resolve.js b/test/built-ins/Promise/safe-resolve-promise/latch-double-safe-resolve.js new file mode 100644 index 00000000000..1abfec7be04 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-double-safe-resolve.js @@ -0,0 +1,63 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A second call to SafePromiseResolve on the same promise is a no-op; the + first resolution wins. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 7. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _wrapper_ »). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +asyncTest(function() { + // Both resolutions would be deferred. + var secondThenCallCount = 0; + + var deferred = Promise.withResolvers(); + $262.safeResolvePromise(deferred.promise, { + then: function(resolve) { + resolve("first"); + }, + }); + $262.safeResolvePromise(deferred.promise, { + then: function(resolve) { + secondThenCallCount += 1; + resolve("second"); + }, + }); + + var deferredCheck = deferred.promise.then(function(settledValue) { + assert.sameValue(settledValue, "first", "the first resolution wins"); + assert.sameValue( + secondThenCallCount, + 0, + "the second resolution's \"then\" is never called" + ); + }); + + // A deferred resolution followed by one which would be synchronous. + var mixed = Promise.withResolvers(); + $262.safeResolvePromise(mixed.promise, { + then: function(resolve) { + resolve("deferred first"); + }, + }); + $262.safeResolvePromise(mixed.promise, "synchronous second"); + + var mixedCheck = mixed.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + "deferred first", + "a subsequent synchronous resolution cannot overtake a deferred one" + ); + }); + + return Promise.all([deferredCheck, mixedCheck]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-thenable-resolving-functions.js b/test/built-ins/Promise/safe-resolve-promise/latch-thenable-resolving-functions.js new file mode 100644 index 00000000000..fc22bc938e5 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-thenable-resolving-functions.js @@ -0,0 +1,104 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolve-thenable +description: > + The pair of resolving functions passed to a deferred "then" shares a single + latch: the first settlement wins and the rest are no-ops. +info: | + PerformPromiseResolveThenable ( promiseToResolve, thenable, then ) + + 1. Let _resolvingFunctions_ be CreateResolvingFunctions(_promiseToResolve_). + 2. If _then_ is a function object, then + a. Let _thenCallResult_ be Completion(Call(_then_, _thenable_, + « _resolvingFunctions_.[[Resolve]], + _resolvingFunctions_.[[Reject]] »)). + ... + 4. If _thenCallResult_ is an abrupt completion, then + a. Return ! Call(_resolvingFunctions_.[[Reject]], *undefined*, + « _thenCallResult_.[[Value]] »). + + CreateResolvingFunctions ( toResolve ) + + 1. Let _promiseOrEmpty_ be the Record { [[Value]]: _toResolve_ }. + 2. Let _resolveSteps_ be a new Abstract Closure with parameters + (_resolution_) that captures _promiseOrEmpty_ ... + a. If _promiseOrEmpty_.[[Value]] is ~empty~, return *undefined*. + + Both functions close over the same Record, so using either one spends the + pair. Step 4 relies on this: a "then" which resolves and then throws must + keep its resolution rather than be rejected by the throw. +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +asyncTest(function() { + // resolve then reject: the rejection is ignored. + var resolveFirst = Promise.withResolvers(); + $262.safeResolvePromise(resolveFirst.promise, { + then: function(resolve, reject) { + resolve("resolved first"); + reject(new Error("must be ignored")); + }, + }); + var resolveFirstCheck = resolveFirst.promise.then( + function(settledValue) { + assert.sameValue( + settledValue, + "resolved first", + "a later reject cannot overtake the resolution" + ); + }, + function(reason) { + throw new Test262Error("the promise must not be rejected: " + reason); + } + ); + + // reject then resolve: the resolution is ignored. + var rejectReason = new Error("rejected first"); + var rejectFirst = Promise.withResolvers(); + $262.safeResolvePromise(rejectFirst.promise, { + then: function(resolve, reject) { + reject(rejectReason); + resolve("must be ignored"); + }, + }); + var rejectFirstCheck = rejectFirst.promise.then( + function(settledValue) { + throw new Test262Error("the promise must not be fulfilled: " + settledValue); + }, + function(reason) { + assert.sameValue( + reason, + rejectReason, + "a later resolve cannot overtake the rejection" + ); + } + ); + + // resolve then throw: PerformPromiseResolveThenable step 4 calls the spent + // reject function, which is a no-op. + var resolveThenThrow = Promise.withResolvers(); + $262.safeResolvePromise(resolveThenThrow.promise, { + then: function(resolve) { + resolve("resolved before throwing"); + throw new Error("must be ignored"); + }, + }); + var resolveThenThrowCheck = resolveThenThrow.promise.then( + function(settledValue) { + assert.sameValue( + settledValue, + "resolved before throwing", + "an exception after resolving cannot reject the promise" + ); + }, + function(reason) { + throw new Test262Error("the promise must not be rejected: " + reason); + } + ); + + return Promise.all([resolveFirstCheck, rejectFirstCheck, resolveThenThrowCheck]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-no-then.js b/test/built-ins/Promise/safe-resolve-promise/sync-no-then.js new file mode 100644 index 00000000000..c1765efbc86 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-no-then.js @@ -0,0 +1,88 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve fulfills synchronously when the resolution is an ordinary + object with no "then" property anywhere on its prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 5. Let _desc_ be ! _o_.[[GetOwnProperty]](_propertyKey_). + 6. If _desc_ is not *undefined*, then + ... + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). + + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. + 5. Return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + "start", + "tick 1", + + // Each promise was resolved during the synchronous section, so all of their + // reactions were queued before "tick 2". + "settled plain object", + "settled null prototype", + "settled inherits from null prototype", + "settled array", + "settled function", + "settled error", + + "tick 2", +]; + +// None of Object.prototype, Array.prototype, Function.prototype, Error.prototype +// nor a null prototype provides a "then" property. +var values = [ + ["plain object", {}], + ["null prototype", Object.create(null)], + ["inherits from null prototype", Object.create(Object.create(null))], + ["array", [1, 2, 3]], + ["function", function() {}], + ["error", new Error("not a thenable")], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for objects without \"then\""); + }); + + var checks = []; + + values.forEach(function(entry) { + var label = entry[0]; + var value = entry[1]; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-non-object.js b/test/built-ins/Promise/safe-resolve-promise/sync-non-object.js new file mode 100644 index 00000000000..73f566c3ddf --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-non-object.js @@ -0,0 +1,83 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve fulfills synchronously when the resolution is not an + Object. +info: | + RequiresDeferredPromiseResolution ( value ) + + 1. If _value_ is not an Object, return *false*. + + SafePromiseResolve ( promiseCapability, resolution ) + + 1. If RequiresDeferredPromiseResolution(_resolution_) is *false*, then + a. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _resolution_ »). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Symbol, BigInt] +---*/ + +var expected = [ + "start", + "tick 1", + + // Each promise was resolved during the synchronous section, so all of their + // reactions were queued before "tick 2". + "settled undefined", + "settled null", + "settled boolean", + "settled number", + "settled string", + "settled symbol", + "settled bigint", + + "tick 2", +]; + +var values = [ + ["undefined", undefined], + ["null", null], + ["boolean", true], + ["number", 42], + ["string", "then"], + ["symbol", Symbol("desc")], + ["bigint", 17n], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for non-object resolutions"); + }); + + var checks = []; + + values.forEach(function(entry) { + var label = entry[0]; + var value = entry[1]; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-null-proto-ignores-object-prototype-then.js b/test/built-ins/Promise/safe-resolve-promise/sync-null-proto-ignores-object-prototype-then.js new file mode 100644 index 00000000000..ee318ae5142 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-null-proto-ignores-object-prototype-then.js @@ -0,0 +1,81 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + An object with a null prototype resolves synchronously even when + Object.prototype has a callable "then". +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // The chain walk ends at the null prototype without reaching + // Object.prototype, so the promise is fulfilled here rather than in a job. + "start", + "tick 1", + + // Resolved during the synchronous section, so its reaction is already queued. + "settled", + + "tick 2", +]; + +var actual = []; + +var thenCallCount = 0; +var value = Object.create(null); + +Object.defineProperty(Object.prototype, "then", { + value: function(resolve) { + thenCallCount += 1; + resolve("from Object.prototype"); + }, + writable: true, + enumerable: false, + configurable: true, +}); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + try { + assert.compareArray( + actual, + expected, + "Ticks for a null-prototype object" + ); + assert.sameValue( + thenCallCount, + 0, + "Object.prototype.then is never called" + ); + } finally { + delete Object.prototype.then; + } + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-own-non-callable-then-shadows-callable.js b/test/built-ins/Promise/safe-resolve-promise/sync-own-non-callable-then-shadows-callable.js new file mode 100644 index 00000000000..8e50b1231f1 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-own-non-callable-then-shadows-callable.js @@ -0,0 +1,71 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve fulfills synchronously when an own non-callable "then" + data property shadows a callable "then" on the prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 5. Let _desc_ be ! _o_.[[GetOwnProperty]](_propertyKey_). + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + ... + b. Return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // The chain walk stops at the own "then", never reaching the callable one. + "start", + "tick 1", + + // Resolved during the synchronous section, so its reaction is already queued. + "settled", + + "tick 2", +]; + +var actual = []; + +var proto = { + then: function(resolve) { + actual.push("call inherited then"); + resolve("from the prototype"); + }, +}; + +var value = Object.create(proto); +value.then = 42; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a shadowed callable \"then\"" + ); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-self-resolution.js b/test/built-ins/Promise/safe-resolve-promise/sync-self-resolution.js new file mode 100644 index 00000000000..0c09f9f6e85 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-self-resolution.js @@ -0,0 +1,86 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + Resolving a promise with itself through SafePromiseResolve rejects it + synchronously when the promise does not look thenable. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 1. If RequiresDeferredPromiseResolution(_resolution_) is *false*, then + a. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _resolution_ »). + + CreateResolvingFunctions ( toResolve ), resolve steps + + 4. Perform ? PerformPromiseResolution(_promise_, _resolution_, ~sync~). + + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 2. If SameValue(_resolution_, _promise_) is *true*, then + a. Let _selfResolutionError_ be a newly created *TypeError* object. + b. Perform RejectPromise(_promise_, _selfResolutionError_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +// SafePromiseResolve does not special-case a resolution which is the promise +// itself; the self-resolution check belongs to PerformPromiseResolution, which +// both paths reach. Shadowing "then" with a non-callable value makes +// RequiresDeferredPromiseResolution report false, so the synchronous path runs +// and the TypeError is reported one microtask earlier than in +// deferred-self-resolution.js. The rejection timing of a self resolution +// therefore depends on whether the promise looks thenable. +var expected = [ + "start", + "tick 1", + + // Rejected during the synchronous section, so its reaction is already queued. + "rejected", + + "tick 2", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a synchronous self resolution"); + }); + + var capability = Promise.withResolvers(); + Object.defineProperty(capability.promise, "then", { + value: 42, + writable: true, + enumerable: false, + configurable: true, + }); + + $262.safeResolvePromise(capability.promise, capability.promise); + actual.push("start"); + + // "then" is shadowed on the promise, so reactions must be attached through + // Promise.prototype.then directly. + var settled = Promise.prototype.then.call( + capability.promise, + function(settledValue) { + throw new Test262Error("the promise must not be fulfilled: " + settledValue); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue( + reason instanceof TypeError, + true, + "the promise is rejected with a TypeError" + ); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable-proto.js b/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable-proto.js new file mode 100644 index 00000000000..02c6f917054 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable-proto.js @@ -0,0 +1,83 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve fulfills synchronously when a non-callable "then" data + property is inherited from the prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 5. Let _desc_ be ! _o_.[[GetOwnProperty]](_propertyKey_). + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + ... + b. Return *false*. + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + "start", + "tick 1", + + // Walking the chain to find a non-callable "then" cannot run user code, + // however deep the chain is, so both promises settle before "tick 2". + "settled immediate", + "settled deep", + + "tick 2", +]; + +// Inherited from the immediate prototype, and from further up a chain rooted at +// a null prototype. +var deepRoot = Object.create(null); +deepRoot.then = "not callable"; + +var values = [ + ["immediate", Object.create({ then: 42 })], + ["deep", Object.create(Object.create(Object.create(deepRoot)))], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for an inherited non-callable \"then\"" + ); + }); + + var checks = []; + + values.forEach(function(entry) { + var label = entry[0]; + var value = entry[1]; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable.js b/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable.js new file mode 100644 index 00000000000..a5a11c6cb2a --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable.js @@ -0,0 +1,77 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve fulfills synchronously when the resolution has an own + "then" data property whose value is not callable. +info: | + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. + 5. Return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Symbol] +---*/ + +var expected = [ + "start", + "tick 1", + + // Each promise was resolved during the synchronous section, so all of their + // reactions were queued before "tick 2". + "settled then: undefined", + "settled then: null", + "settled then: number", + "settled then: string", + "settled then: symbol", + "settled then: object", + + "tick 2", +]; + +var thenValues = [ + ["undefined", undefined], + ["null", null], + ["number", 42], + ["string", "then"], + ["symbol", Symbol("then")], + ["object", {}], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a non-callable \"then\""); + }); + + var checks = []; + + thenValues.forEach(function(entry) { + var label = entry[0]; + var value = { then: entry[1] }; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled then: " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself, then: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only-proto.js b/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only-proto.js new file mode 100644 index 00000000000..933313067dc --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only-proto.js @@ -0,0 +1,78 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve fulfills synchronously when a "then" accessor with a + setter but no getter is inherited from the prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + i. If _kind_ is either ~any~ or ~get~, and _desc_.[[Get]] is not + *undefined*, return *true*. + ii. If _kind_ is either ~any~ or ~set~, and _desc_.[[Set]] is not + *undefined*, return *true*. + b. Return *false*. + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + ... + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // Reading a setter-only "then" cannot run user code, so this is not deferred. + "start", + "tick 1", + + // Resolved during the synchronous section, so its reaction is already queued. + "settled", + + "tick 2", +]; + +var actual = []; + +var setterCallCount = 0; +var proto = {}; +Object.defineProperty(proto, "then", { + set: function(_v) { + setterCallCount += 1; + }, + configurable: true, +}); + +var value = Object.create(proto); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for an inherited setter-only \"then\"" + ); + assert.sameValue(setterCallCount, 0, "the setter is never called"); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only.js b/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only.js new file mode 100644 index 00000000000..bb6f2e58e28 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only.js @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve fulfills synchronously when the resolution has an own + "then" accessor with a setter but no getter. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + i. If _kind_ is either ~any~ or ~get~, and _desc_.[[Get]] is not + *undefined*, return *true*. + ii. If _kind_ is either ~any~ or ~set~, and _desc_.[[Set]] is not + *undefined*, return *true*. + b. Return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // Reading a setter-only "then" cannot run user code, so this is not deferred. + "start", + "tick 1", + + // Resolved during the synchronous section, so its reaction is already queued. + "settled", + + "tick 2", +]; + +var actual = []; + +var setterCallCount = 0; +var value = {}; +Object.defineProperty(value, "then", { + set: function(_v) { + setterCallCount += 1; + }, + configurable: true, +}); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a setter-only \"then\""); + assert.sameValue(setterCallCount, 0, "the setter is never called"); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself" + ); + }); + + return Promise.all([ruler, settled]); +});