Skip to content

Integration with web APIs #82

Open
@andreubotella

Description

I've been looking at how AsyncContext would integrate with various APIs and events in the web platform. These are my conclusions:

Web APIs

These can be grouped in various categories. I've listed APIs shipping in at least two browser engines, with a few single-engine additions that I thought relevant.

Schedulers

For these APIs, their sole purpose is to take a callback and schedule it in the event loop in some way. The callback will run at most once (except for setInterval) after the function returns, when there is no other JS code running on the stack.

Since the default context for these callbacks would be the empty context, they should be called with the context in which the API was called:

  • setTimeout
  • setInterval
  • queueMicrotask
  • requestAnimationFrame
  • HTMLVideoElement's requestVideoFrameCallback method
  • requestIdleCallback
  • scheduler.postTask

Completion callbacks

These APIs take callbacks that are called once, to indicate the completion of an asynchronous operation that they start. These act just like the callbacks passed to .then/.catch in promises, and therefore they should behave the same, being called with the context in which the web API was called:

  • <canvas>'s toBlob method
  • DataTransferItem's getAsString method (drag & drop)
  • Notification.requestPermission
  • BaseAudioContext's decodeAudioData method (web audio API)
  • RTCPeerConnection's createOffer, setLocalDescription, createAnswer, setRemoteDescription and addIceCandidate methods (WebRTC)
  • Geolocation's getCurrentPosition method
  • The legacy filesystem entries API:
    • FileSystemEntry's getParent method
    • FileSystemDirectoryEntry's getFile and getDirectory methods
    • FileSystemFileEntry's file method
    • FileSystemDirectoryReader's readEntries method

Callbacks run as part of an async algorithm

The following APIs call the callback to run user code at most once as part of an asynchronous operation that they start. Therefore, they should probably use the context in which the web API was called:

  • Document's startViewTransition method (CSS view transitions)
  • LockManager's request method (web locks)

Note that these APIs are conceptually similar to this JS code, where the callback would be called with the same context as the call to api:

async function api(callback) {
  const state = await setup();
  await callback(state);
  await finish(state);
}

(See also streams in "others" below.)

Observers

Observers are web API classes that take a callback in its constructor, and provide an observe() method to register things to observe. Whenever something relevant happens to the observed things, the callback will be called asynchronously to indicate that those observations have taken place.

You could think of both the construction of an observer, and the time its observe() method is called, as being contexts that could be relevant to a call resulting from that observation. However, for all web API observers, the updates are batched so that the callback is called with an array of observation records, which can go back to multiple calls to observe().

Unlike with the case with multiple async originating contexts in events, here it doesn't make sense to go with the latest originating call to observe(), since that context only matters for one of the observations. Therefore, the only context that seems to make sense is the one active when the observer class is constructed.

These are the web APIs which follow this pattern:

  • MutationObserver
  • ResizeObserver
  • IntersectionObserver
  • PerformanceObserver
  • ReportingObserver

There is also an observer-like class defined in TC39, namely FinalizationRegistry. It differs from web APIs in that the callback is called once for each registered object that has been GC'd, rather than with a list of observations. This would make it possible to call the callback with the context at which register() (the equivalent of observe()) was called, but we chose to have it behave the same as web API observers instead. See #69 for the relevant discussion.

Action registrations

These APIs register a callback or constructor to be invoked whenever some action runs:

  • MediaSession's setActionHandler method
  • Geolocation's watchPosition method
  • RemotePlayback's watchAvailability method
  • Worklets' registration functions (AudioWorkletGlobalScope's registerProcessor, PaintWorkletGlobalScope's registerPaint)

In all of the above cases, the action will be invoked browser-internally, so there is no JS on the stack or any other context that could be used other than that active when the API is called. (In the terms we define below for events, they have no originating context.)

Additionally, custom element classes can be registered with customElements.define, and their constructor and reaction methods are also invoked whenever some action runs. However, these can be called with JS code on the stack (when the element is changed via web APIs), or without it (when it's changed through a user interaction, such as editing). (In event terms, they could optionally have a sync call-time context.)

Others

  • The DOM spec's NodeIterator and TreeWalker. These APIs are created from a document method (document.createNodeIterator() and document.createTreeWalker()), which takes either a function or an object with an acceptNode() method to act as a filter. When the various methods of these classes are called, the filter is invoked to check whether a node should be returned or skipped. The possibilities are to use the creation-time context, or the call-time context of the methods of those classes. This is essentially the same as it.filter(cb) in the iterator helpers proposal, and the behavior should be consistent with whatever we decide there (see Interaction with iterator helpers #75).

  • Streams ({Readable,Writable,Transform}Stream). Constructors for these classes take an object on which methods will be called, and they also take an options bag (two for TransformStream) with an optional size callback field. While the start method of the first argument will only ever be called synchronously in the class's construction (so it's not a concern wrt AsyncContext), the other methods and callbacks are trickier.

    These methods are run as part of an algorithm, but they differ from the APIs listed above in that they're registered by a different API than they're used by (they're registered by the constructor and used by e.g. reader.read()). Furthermore, they can be run with JS code on the stack (e.g. reader.read()), or without it (e.g. when passing a ReadableStream as a request body to fetch()). Although piping could make things more complicated, and more research is needed, the possibilities for these methods seem to be registration-time context and originating context, as with events.

Events

The web platform has many events. Just to give you an idea, according to BCD (which provides browser compatibility data for MDN and caniuse.com), there are 263 different event names which are supported in at least two browser engines. Furthermore, the same event name can have different meanings in different APIs (e.g. the error event on window indicates uncaught script errors, but the error event on XMLHttpRequest indicates a fetch failure), so that's certainly an underestimate of the amount of work needed to figure out all of them. I have not yet analyzed them all, but these are my findings from analyzing a subset.

Background

Every time an event callback is invoked, there is at least one relevant context: the time at which the event listener or handler was registered (i.e. when addEventListener was called, or e.g. onclick was set). We will be calling this the registration-time context.

Although most of the time events are fired asynchronously, there are some times when calling an API will caused an event to be fired synchronously. Some examples are calling the abort() method on XMLHttpRequest, which will fire an abort error; or changing the value of location.hash to a non-empty value, which will cause a synchronous navigation which in turn fires the popstate event on window. The context when this synchronous API is called is the sync call-time context, and it is the default that would be used if we don't change the web specs.1

In all other cases, the event will fire when there's no other JS code on the stack. Sometimes this is because the event was triggered by the browser (i.e. after a user interaction), in which the only possible context that matters is the registration-time one. But other times there are APIs that asynchronously cause the event to fire, such as XMLHttpRequest's send() method causing the load event. These are async originating contexts.

It seems like whenever there is a sync call-time context in the web platform, there are no async originating contexts that matter. After all, the immediate cause of the event is the synchronous web API call.2 However, one same type of event could sometimes be fired with a call-time context, sometimes with an async originating context, and sometimes with no originating contexts. An example is the click event, which is usually browser-triggered, but el.click() will fire it synchronously.

Sometimes there are multiple async contexts that could originate an event dispatch. For example, if you have a <video> element, and in quick succession the user hits play, and then some JS code runs video.load() and video.play(), which would be the async originating context (if any) for the load event? But you could define some criterion to sort them: for example, always use the most important of such contexts (which would need to be judged independently for each API), and if there are multiple, the latest.

If we define some such criterion, then for every event there would be at most one originating context, which would be the sync call-time context if there is one, and otherwise the "winning" async originating context. Only if the event was browser-triggered there would be no originating context. And with that, the decision for every event would be a binary one between the registration-time context and the originating context.

The choice of context

This decision is not yet a settled question for every event, but there are two events in particular which we know have specific needs:

  • The unhandledrejection event is an asynchronous event, and its async originating context is that active when the HostPromiseRejectTracker host hook is called. Bloomberg's use case for this proposal needs this originating context to be accessible from the unhandledrejection event listener, although it does not necessarily need to be the active context when the listener is called. For their needs it would be sufficient to expose that context as an AsyncContext.Snapshot property in the event object, for example. (This was previously discussed in Specifying unhandledrejection behavior #16.)
  • The message event on window and MessagePort is an asynchronous event, whose async originating context is that active when window.postMessage() or messagePort.postMessage() was called to enqueue the relevant message. Although this event is meant for communication, it is often used in the wild as a way to schedule tasks, since it has scheduling properties that other scheduling APIs didn't have before scheduler.postTask(). As such, the event listener should be called with the originating context by default (at least when the message is being sent to the same window), so it behaves like other scheduling APIs.

For other events, there are various possibilities. However, it seems clear that if there is an originating context, advanced users of AsyncContext need to be able to access it, and using the registration-time context would make this impossible.3 At the same time, it seems like the context that non-advanced users would expect is the registration-time.

The choice between registration-time and originating context actually reflects different use cases for AsyncContext (something that Yoav Weiss discusses in some detail in this blog post, applied to the needs of task attribution). So the best course of action is probably to let listeners opt into the originating context, if one exists. This could be done by having an AsyncContext.Snapshot as a property of the event object, or by using AsyncContext.callingContext() (#77).


cc @annevk @domenic @smaug---- @yoavweiss @shaseley

Footnotes

  1. All event types can be called with a sync call-time context, through the dispatchEvent() method. We will be ignoring this, though, since we're focusing on events fired by web platform APIs.

  2. One example of an API where this wouldn't be the case would be something that enqueues tasks or events, and runs them synchronously at some later point when some API is called. As far as I know, there's nothing like this built into the web platform.

  3. The inverse is not true: if events use the originating context, an event listener could make sure the callback is called with the registration-time context by wrapping it with AsyncContext.Snapshot.wrap.

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions