Skip to content
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

[css-view-transitions-1] Fix some promise timing, and avoid duplicate unhandled rejections #8454

Merged
merged 6 commits into from
Feb 24, 2023

Conversation

jakearchibald
Copy link
Contributor

In the current spec, this will cause three unhandled rejections:

const transition = document.startViewTransition(() => {
  doesNotExist();
});

As it will cause transition.updateCallbackDone, transition.ready, and transition.finished to reject with the same error.

This PR always marks transition.finished as handled, as it only ever rejects for the same reason as transition.updateCallbackDone. It marks transition.ready as handled if it's rejected with the same reason as transition.updateCallbackDone.

@@ -897,7 +897,7 @@ urlPrefix: https://html.spec.whatwg.org/multipage/rendering.html; type: dfn;
{{UpdateCallback|updateCallback}} is called asynchronously, once the current state of the document is captured.
Then, when the promise returned by {{UpdateCallback|updateCallback}} fulfills, the new state of the document is captured.

{{UpdateCallback|updateCallback}} is _always_ called, even if the transition cannot happen (e.g. due to duplicate `view-transition-name` values).
{{UpdateCallback|updateCallback}} is *always* called, even if the transition cannot happen (e.g. due to duplicate `view-transition-name` values).
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like bikeshed's markdown parser doesn't support emphasis with _. Just a formatting fix.

Comment on lines +1440 to +1446
1. Let |callbackPromise| be null.

* If |transition|'s [=ViewTransition/update callback=] is null, then resolve |callbackPromise|.
1. If |transition|'s [=ViewTransition/update callback=] is null,
then set |callbackPromise| to [=a promise resolved with=] undefined,
in |transition|'s [=relevant Realm=].

* Otherwise, let |callbackPromise| be the result of [=/invoking=] |transition|'s [=ViewTransition/update callback=].
1. Otherwise, set |callbackPromise| to the result of [=/invoking=] |transition|'s [=ViewTransition/update callback=].
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No normative change here, just using more standard spec prose.

Comment on lines 1450 to 1453
1. [=Resolve=] |transition|'s [=ViewTransition/update callback done promise=]
with the result of [=reacting=] to |callbackPromise|:

* If |callbackPromise| was rejected with reason |r|, then [=reject=] |transition|'s [=ViewTransition/update callback done promise=] with |r|.
- If the promise was fulfilled, then return undefined.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically a normative change, but it doesn't change any currently observable behaviour. It protects us from possible race conditions in future.

Comment on lines 1468 to 1483
then [=resolve=] |transition|'s [=ViewTransition/finished promise=].
1. [=Resolve=] |transition|'s [=ViewTransition/finished promise=] with the result of [=reacting=] to |transition|'s [=ViewTransition/update callback done promise=]:

* If |transition|'s [=ViewTransition/update callback done promise=] was rejected with reason |reason|,
then [=reject=] |transition|'s [=ViewTransition/finished promise=] with |reason|.
- If the promise was fulfilled, then return undefined.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, technically a normative change, but no observable behaviour change.

1. [=Skip the view transition=] |transition| with |reason|.

* If the promise was resolved, then:
* If the promise was fulfilled, then:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

css-view-transitions-1/Overview.bs Show resolved Hide resolved
[=marked as handled=].

Note: This is [=marked as handled=] to prevent duplicate {{unhandledrejection}}s,
as this promise only ever rejects along with the [=update callback done promise=].
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would only be a duplicate if update callback done promise is not handled. If it is handled, but the finished promise is not handled, then should that still cause an unhandledrejection?

In other words, I feel like we shouldn't try too hard to prevent duplicate unhandledrejections, since these are different promises that would both be not handled if rejected

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imagine code like this:

async function attemptTransition() {
  const transition = document.startViewTransition(callback);

  try {
    await transition.updateCallbackDone;
  } catch (err) {
    reportBadError(err);
    return;
  }

  try {
    await transition.ready;
    // Do some JS animations here
  } catch (err) {
    reportSkippedTransition(err);
    return;
  }

  try {
    await transition.finished;
    // Do some cleanup
  } catch (err) {
    reportCleanupError(err);
    return;
  }
}

If the update callback throws, the above code will result in two identical unhandled rejections. Although the updateCallbackDone promise is handled, the function exits, leaving ready and finished unhandled. That doesn't seem useful, and annoying to work around. So, my goal in this PR is to avoid unhandled rejections on ready and finished when the rejection would be the same as updateCallbackDone.

Note that as soon as the developer does:

await transition.finished;

…they're creating a new unhandled promise. This PR only impacts cases where the developer doesn't do anything with the promises.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was imagining the case where updateCallbackDone is listened to by some app state monitor, to make sure that all of the proper dom modifications take place, whereas finished is listened to by some logging / metrics subsystem to just report failures. In that case, suppose that updateCallbackDone rejects and is handled appropriately by the app state monitor. However, the logging system doesn't handle it (maybe as an oversight) but it does not result in unhandled rejection because updateCallbackDone handled it.

I think my case is fairly contrived, but I'm just wary of handling rejections for promises that seem different enough.

WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I see what you're saying. But it doesn't feel that different to what would happen if both systems were listening to updateCallbackDone, which I think it more likely in this case (finished only ever rejects if updateCallbackDone rejects).

  • The app state handles updateCallbackDone.
  • The logging system intends to handle updateCallbackDone, but fails to for some code path.

There's no unhandled rejection from updateCallbackDone, because the app state already handled it.

I'll ask Domenic for a second opinion on this though.

Tangent: I kinda think unhandled rejections in general are a bad design. They seem to be built around single consumer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Domenic likes the pattern in this PR (he did a similar thing in the navigation API) #8455 (comment).

But I'm not opposed to take it to the group internally, or CSSWG, if you want more eyes on it.

@@ -1135,9 +1143,14 @@ urlPrefix: https://html.spec.whatwg.org/multipage/rendering.html; type: dfn;

Note: This happens if |transition| was [=skip the view transition|skipped=] before this point.

1. [=Mark as handled=] |transition|'s [=ViewTransition/ready promise=].
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise here, it seems that this is only a duplicate if update callback done promise is not explicitly handled? Or am I misunderstanding?

1. [=promise/React=] to |callbackPromise|:

* If |callbackPromise| was resolved, then [=resolve=] |transition|'s [=ViewTransition/update callback done promise=].
1. [=Resolve=] |transition|'s [=ViewTransition/update callback done promise=]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this is an extremely subtle point which I wouldn't have known if you didn't link to the your 2014 article.

"resolve the promise with the result of reacting to callbackPromise". This does, in fact, mean reject the promise if callbackPromise was rejected, right?

I'm not sure what race conditions you're referencing below, but I think it would be better to be explicit here "reject if rejects, resolve if resolved" type of language

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"resolve the promise with the result of reacting to callbackPromise". This does, in fact, mean reject the promise if callbackPromise was rejected, right?

Yeah, the process of react is very similar to promise.then(), as in, if you don't react to the reject case, then the rejection is passed through.

I'll add a note to make this clear.

The only reason the fulfilled case is handled is to ensure that updateCallbackDone fulfills with undefined rather than whatever the callback returns.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what race conditions you're referencing below

We didn't have any in the spec right now, but since our intention was to have updateCallbackDone follow callbackPromise, except resolving with undefined, it seems better to spec it with that intent.

Here's the difference between the two patterns: https://jsbin.com/butulov/edit?js,console

css-view-transitions-1/Overview.bs Show resolved Hide resolved
@jakearchibald jakearchibald merged commit 37902c4 into w3c:main Feb 24, 2023
@jakearchibald jakearchibald deleted the handled-promises-2 branch February 24, 2023 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants