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
'srequestVideoFrameCallback
methodrequestIdleCallback
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>
'stoBlob
methodDataTransferItem
'sgetAsString
method (drag & drop)Notification.requestPermission
BaseAudioContext
'sdecodeAudioData
method (web audio API)RTCPeerConnection
'screateOffer
,setLocalDescription
,createAnswer
,setRemoteDescription
andaddIceCandidate
methods (WebRTC)Geolocation
'sgetCurrentPosition
method- The legacy filesystem entries API:
FileSystemEntry
'sgetParent
methodFileSystemDirectoryEntry
'sgetFile
andgetDirectory
methodsFileSystemFileEntry
'sfile
methodFileSystemDirectoryReader
'sreadEntries
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
'sstartViewTransition
method (CSS view transitions)LockManager
'srequest
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
'ssetActionHandler
methodGeolocation
'swatchPosition
methodRemotePlayback
'swatchAvailability
method- Worklets' registration functions (
AudioWorkletGlobalScope
'sregisterProcessor
,PaintWorkletGlobalScope
'sregisterPaint
)
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
andTreeWalker
. These APIs are created from a document method (document.createNodeIterator()
anddocument.createTreeWalker()
), which takes either a function or an object with anacceptNode()
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 asit.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 forTransformStream
) with an optionalsize
callback field. While thestart
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 aReadableStream
as a request body tofetch()
). 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 theHostPromiseRejectTracker
host hook is called. Bloomberg's use case for this proposal needs this originating context to be accessible from theunhandledrejection
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 anAsyncContext.Snapshot
property in the event object, for example. (This was previously discussed in Specifyingunhandledrejection
behavior #16.) - The
message
event onwindow
andMessagePort
is an asynchronous event, whose async originating context is that active whenwindow.postMessage()
ormessagePort.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 beforescheduler.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
-
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. ↩ -
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. ↩
-
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
. ↩