-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Remove Job from Promise Resolve Functions #2772
base: main
Are you sure you want to change the base?
Conversation
This aligns the behavior of handling thenables with the Promises A+ spec, which says when the resolution value is a thenable, you're supposed to synchronously call `thenable.then(onRes, onRej)` ([Step 2.3.3.3][call-then]). Given the example code: ```javascript new Promise(resolve => { resolve(thenable) }); ``` The current behavior requires 2 ticks for the outer promise to fully resolve. One tick is created by the Promise Resolving Functions (and is removed in this PR), and one tick is created by the wrapping of the `onRes`/`onRej` callbacks passed to [`Promise.p.then`][then]. This made it noticeably slower to resolve with a thenable than to invoke the thenable's `.then` directly: `thenable.then(onRes, onRej)` only requires a single tick (for the wrapped `onRes`/`onRej`). With this change, we could revert tc39#1250 without slowing down `Await`. Fixes tc39#2770. [call-then]: https://promisesaplus.com/#point-56 [then]: https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-performpromisethen
What are the observable changes as a result of this PR, besides "number of ticks greater than zero"? |
4e7ffb3
to
fd32c2a
Compare
Also, it's worth investigating how this would impact websites deployed with es6-shim, core-js, and other Promise shims - if browsers start having different behavior here, it might cause the shims to kick in when they otherwise wouldn't. |
Also very curious about the web compat aspect of this change. I don't doubt some code out there now relies on |
Case 1: Observable Change new Promise(res => {
let sync = false;
resolve({
then(onRes, onRej) {
sync = true;
onRes();
}
});
console.assert(sync === true);
}) Before, Case 2: Observable Change const parent = Promise.resolve();
parent.then(() => {
let sync = false;
parent.then(() => {
console.assert(sync === true);
});
return {
then(onRes) {
sync = true;
onRes();
}
};
}); Same deal, now There are no other orderings of |
The `[[Reject]]` can never fail.
16345dc
to
d1812b2
Compare
Both es6-shim and core-js behave with the current semantics. I do not know if they actually test for this behavior, but it'd be ill advised to overwrite the native Promise impl if they did, given that Promise is used all over, and native APIs/async functions will never return the userland impl. I imagine most other Promises impls are using A+ semantics, because it actually has a portable test suite and isn't written in spec language. |
For what it's worth, this change would partially break my "hack" to detect promise adoption/redirect through node's |
Not only is the proposed change a breaking change but it is also counter-intuitive. |
@raulsebastianmihaila As far as I understand, with this change the function called to then is still called asynchronously. The difference is only that |
Even so it's a breaking change and counter-intuitive. |
That's not the contract of thenable. const outer = Promise.resolve({
then(onRes) {
// onRes is sync called.
onRes(1);
}
}); Regardless of the behavior of the inner thenable or how we receive its value, we don't change the behavior of the outer promise. Chaining const parent = Promise.resolve();
const outer = parent.then(() => {
// Fetch returns a native promise
return fetch('...');
}); Here, it used to take 2 ticks in order for |
I'm not necessarily arguing what the contract of 'thenable' is, as I'm not certain what that means. If it is what's specified at the moment (as I have learned it) then calling What I'm saying is that the current spec matches the intuition that whatever the steps encapsulated in the
You can do the same like this:
but resolving 'nothing' is less intuitive. Another way of thinking about a thenable (which is illustrated in the snippet above) is that it encapsulates some steps not only syntactically but also from a time perspective. This means that the thenable itself represents a kind of encapsulation, not only a callback passed to a Also having both the |
You're unfortunately comparing snippets with two very different behaviors, and one is doing the work now and the other is deferring a tick. This is demonstrated by // current spec text
// t = 0
Promise.resolve({
get then() {
// t = 0
console.log('logged immediately');
},
}); A more equivalent snippet is to defer work till later in both branches: // proposed spec
// t = 0
Promise.resolve().then(() => {
// t = 1
console.log('logged in the future');
});
// t = 0
Promise.resolve().then(() => {
// t = 1
return {
then() {
// t = 1
console.log('logged in the future');
}
};
}); But frankly, I doubt many devs actually write/depend a thenable that cares which tick it's invoked in. As long as the core behavior of native promises is maintained (callbacks passed to // proposed spec
// t = 0
Promise.resolve().then(() => {
// t = 1
return {
then(onRes) {
// t = 1, used to be 2
onRes();
}
};
}).then(() => {
// t = 2, used to be 3
}); And some promise-heavy projects will be considerably faster. That's a big deal. |
The only time this would matter is if you had a Note that you can call the |
I think you're mischaracterizing my example. I don't understand why you introduced a getter as my examples didn't include one. Also a piece of your example is incorrect. You wrote:
If understand you're intention, this code is incorrect because
In any case it seems to me you've made the discussion more complicated for reasons I don't understand. |
The mechanics of promises can't call the |
Yes. My point is that as a producer of thenables you should not design on the assumption that |
Yes, and my point is that fortunately you don't have to care about that because the promises behavior is consistent with regard to how it handles userland code. |
My claim is that as a consumer you don't have to care about it either way. This PR would not change that. |
This part is not very clear to me because it does make changes relevant to producing, which makes it a breaking change that we should care about. |
It does not make changes relevant to producing. Producers already have to be aware that |
Unfortunately I can not agree with that. The reason being that producers can rely on how promises work and this change will break such code. Take the small example I gave previously for executing code in a future tick. Even if there is universal agreement with your view on how thenables should be written in general, there are certainly exceptions where such snippets are used in practice as workarounds in codebases that are not well taken care of. I have seen it in real code (way before |
It is simply a fact that But there is separately a question of whether this change significantly complicates the mental model for authors of new code, which was what you were discussing above, and I claim it does not. |
This change does complicate the mental model because it introduces a new rule to follow. But I think this is less relevant nowadays than the other things I was discussing (like this being a breaking change, which is precisely what I started with). |
It does not introduce a new rule to follow. |
This discussion is getting off track, please take it to matrix. |
Dug up where this change was introduced: domenic/promises-unwrapping#105 The concern was specifically that If we don't want to outright revert the above PR, we could instead add a fast-path in Promise Resolve Functions which says that if The cost is that this is a place userland thenables take an extra tick vs real promises, but since that's already true for |
That seems like a pretty reasonable/safe/best-of-both-worlds compromise. |
I think is already broken for async/await since #1250. Doing
I found a few more comments about untrusted code in the repo:
In particular, it seems to boil down to: const cast = Promise.resolve(untrustedThenable);
cast.then(untrustedFulfilled, untrustedRejected); The desire is that at no point during this tick should untrusted code be run. But as #2772 (comment) points out, that's been broken for a while because the Note that during the time these comments were made, the Also, the PS. const p = Promise.resolve();
p.then = () => {
alert('Gotcha!');
};
const cast = Promise.resolve(p);
cast.then(); // Gotcha! |
Thankfully #1250 does not impact my use case since I only care about promises which are adopted by a user reachable promise, like as returned by an async function.
Agreed. That compromise also wouldn't break my fringe scenario, but more importantly, the more or less legitimate assumptions that custom
Correct, it's already broken, but only in the case of evil promises using get traps. I believe the argument here is that it's a lot easier to write code that assumes the
Right, and that's why I would really like if we could expose a clean |
Another alternative fast-path would be to use the same test as This would have the advantage of skipping two observable lookups (of |
Wouldn't this immediately call
|
That was the idea, yes. (Same as my earlier suggestion, just skipping some intermediate steps.)
|
However, sometimes I see issues about the order of promises resolution in similar cases, so I'm pretty sure that it will break something in the Web. |
@zloirock could you clarify "the"/"this"? Does core-js follow the A+ rules (synchronous call) or the currently specified ES Promises (delayed call). If taking the hybrid approach of only delaying non native promises and fast pathing native promise with no custom then, the only impact should be on programs that are particularly sensitive to the number of ticks. |
I mean
Sure, delayed call, like in the actual spec. |
This makes promises & async take two ticks instead of three ticks. See tc39/ecma262#1250 See tc39/ecma262#2770 See tc39/ecma262#2772 50% faster when measuring call overhead. Similar improvements for https://github.com/v8/promise-performance-tests and when measuring with the following snippet: ```js import { run, bench } from "mitata"; bench("sync", () => {}); bench("async", async () => {}); run(); ```
For interested parties, I've created https://github.com/tc39/proposal-faster-promise-adoption to track the proposal. |
Promise resolution order changes in some instances, resulting in different orders for some errors within the errors array, as well as in different values of hasNext within incremental delivery payloads. This PR introduces an async `completePromisedValue` helper function rather than using a promise chain (see below links). https://github.com/tc39/proposal-faster-promise-adoption tc39/ecma262#2770 tc39/ecma262#2772 tc39/ecma262#1250 https://v8.dev/blog/fast-async Depends on #3793
This aligns the behavior of handling thenables with the Promises A+ spec, which says when the resolution value is a thenable, you're supposed to synchronously call
thenable.then(onRes, onRej)
(Step 2.3.3.3).Given the example code:
The current behavior requires 2 ticks for the outer promise to fully resolve. One tick is created by the Promise Resolving Functions (and is removed in this PR), and one tick is created by the wrapping of the
onRes
/onRej
callbacks passed toPromise.p.then
. This made it noticeably slower to resolve with a thenable than to invoke the thenable's.then
directly:thenable.then(onRes, onRej)
only requires a single tick (for the wrappedonRes
/onRej
).With this change, we could revert #1250 without slowing downPartially, I didn't see the 2nd promise allocation. This PR makes the pre-#1250 run in 2 ticks instead of 3. We still want 1 tick.Await
.Fixes #2770.