The purpose of this document is to explain the integration of AsyncContext with
the web platform. In particular, when a callback is run, what values do
AsyncContext.Variable
s have? In other words, which AsyncContext.Snapshot
is
restored?
In this document we look through various categories of web platform APIs and we propose their specific AsyncContext behavior. We also look into how this could be implemented, in the initial rollout and over time, as well as consider existing or experimental web platform features that could use the AsyncContext machinery.
Although this document focuses on the web platform, and on web APIs, it is also expected to be relevant to other JavaScript environments and runtimes. This will necessarily be the case for WinterTC-style runtimes, since they will implement web APIs. However, the integration with the web platform is also expected to serve as a model for other APIs in other JavaScript environments.
For details on the memory management aspects of this proposal, see this companion document.
The AsyncContext proposal allows associating state implicitly
with a call stack, such that it propagates across asynchronous tasks and promise
chains. In a way it is the equivalent of thread-local storage, but for async
tasks. APIs like this (such as Node.js’s AsyncLocalStorage
, whose API
AsyncContext
is inspired by) are fundamental for a number of diagnostics tools
such as performance tracers.
This proposal provides AsyncContext.Variable
, a class whose instances store a
JS value. The value after creation can be set from the constructor and is
undefined
by default. After initialization, though, the value can only be
changed through the .run()
method, which takes a callback and synchronously
runs it with the changed value. After it returns, the previous value is
restored.
const asyncVar = new AsyncContext.Variable();
console.log(asyncVar.get()); // undefined
asyncVar.run("foo", () => {
console.log(asyncVar.get()); // "foo"
asyncVar.run("bar", () => {
console.log(asyncVar.get()); // "bar"
});
console.log(asyncVar.get()); // "foo"
});
console.log(asyncVar.get()); // undefined
What makes this equivalent to thread-local storage for async tasks is that the
value stored for each AsyncContext.Variable
gets preserved across awaits, and
across any asynchronous task.
const asyncVar = new AsyncContext.Variable();
asyncVar.run("foo", async () => {
console.log(asyncVar.get()); // "foo"
await new Promise(resolve => setTimeout(resolve, 2000));
console.log(asyncVar.get()); // "foo"
});
asyncVar.run("bar", async () => {
console.log(asyncVar.get()); // "bar"
await new Promise(resolve => setTimeout(resolve, 1000));
await asyncVar.run("baz", async () => {
console.log(asyncVar.get()); // "baz"
await new Promise(resolve => setTimeout(resolve, 2000));
console.log(asyncVar.get()); // "baz"
});
console.log(asyncVar.get()); // "bar"
});
Note that the above sample can’t be implemented by changing some private state
of the asyncVar
object without awareness of async
/await
, because the
promise in foo resolves in the middle of the baz run.
If you have multiple AsyncContext.Variable
instances active when an await
happens, all of their values must be stored before the await
, and then
restored when the promise resolves. The same goes for any other kind of async
continuation. An alternative way to see this is having a single global
(per-agent) variable storing a map whose keys are AsyncContext.Variable
instances, which would be replaced by a modified copy at the start of every
.run()
call. Before the await
, a reference would be taken to the current
map, and after the promise resolves, the current map would be set to the stored
reference.
Being able to store this map and restore it at some point would also be useful
in userland to build custom userland schedulers, and AsyncContext.Snapshot
provides this capability. An AsyncContext.Snapshot
instance represents a value
of the map, where constructing an instance takes a reference to the current map,
and calling .run()
with a callback lets you restore it. Notably, this API does
not allow iterating through the map or observing its contents directly – you can
only observe the value associated with an AsyncContext.Variable
instance if
you have access to that instance.
const deferredFunctions = [];
// `deferFunction` is a userland scheduler
export function deferFunction(cb) {
const snapshot = new AsyncContext.Snapshot();
deferredFunctions.push({cb, snapshot});
}
export function callDeferredFunctions() {
for (const {cb, snapshot} of deferredFunctions) {
snapshot.run(cb);
}
deferredFunctions = [];
}
Capturing and restoring AsyncContext.Snapshot
instances is a very common
operation, due to its implicit usage in every await
. For this reason, it is
expected to be implemented as a simple pointer copy. See the
V8 AsyncContext Design Doc
for a concrete implementation design.
Web frameworks such as React may decide to save and restore
AsyncContext.Snapshot
s when re-rendering subtrees. More outreach to frameworks
is needed to confirm exactly how this will be used.
The AsyncContext API isn’t designed to be used directly by most JavaScript application developers, but rather used by certain third-party libraries to provide good DX to web developers. AsyncContext makes it so users of those libraries don’t need to explicitly integrate with it. Instead, the AsyncContext mechanism handles implicitly passing contextual data around.
In general, contexts should propagate along an algorithm’s data flow. If an algorithm running in the event loop synchronously calls another algorithm or performs a script execution, that algorithm and script would have the same context as the caller’s. This is handled automatically. However, when the data flow is asynchronous –such as queuing a task or microtask, running some code in parallel, or storing an algorithm somewhere to invoke it later–, the propagation must be handled by some additional logic.
To propagate this context without requiring further JavaScript developer
intervention, web platform APIs which will later run JavaScript callbacks should
propagate the context from the point where the API was invoked to where the
callback is run (i.e. save the current AsyncContext.Snapshot
and restore it
later). Without built-in web platform integration, web developers may need to
“monkey-patch” many web APIs in order to save and restore snapshots, a technique
which adds startup cost and scales poorly as new web APIs are added.
In some cases there is more than one incoming data flow, and therefore multiple
possible AsyncContext.Snapshot
s that could be restored:
{
/* context 1 */
giveCallbackToAPI(() => {
// What context here?
});
}
{
/* context 2 */
callCallbackGivenToAPI();
}
We propose that, in general, APIs should call callbacks using the context from which
the call to API is effectively caused (context 2
in the above code snipped).
This matches the behavior you'd get if web APIs were implemented in JavaScript,
internally using only promises and continuation callbacks. This will thus match how
most userland libraries behave, unless they modify how AsyncContext
flows by manually
snapshotting and restoring it.
Some callbacks can be sometimes triggered by some JavaScript code that we can propagate
the context from, but not always. An example is .addEventListener
: some events can only
be triggered by JavaScript code, some only by external causes (e.g. user interactions),
and some by either (e.g. user clicking on a button or the .click()
method). In these
cases, when the action is not triggered by some JavaScript code, the callback will run
in the empty context instead (where every AsyncContext.Variable
is set to its default
value). This matches the behavior of JavaScript code running as a top-level operation (like
JavaScript code that runs when a page is just loaded).
In the rest of this document, we look at various kinds of web platform APIs which accept callbacks or otherwise need integration with AsyncContext, and examine which context should be propagated.
For web APIs that take callbacks, the context in which the callback is run would depend on the kind of API:
These are web APIs whose sole purpose is to take a callback and schedule it in the event loop in some way. The callback will run asynchronously at some point, when there is no other JS code in the call stack.
For these APIs, there is only one possible context to propagate: the one that was active when the API was called. After all, that API call starts a background user-agent-internal operation that results in the callback being called.
Examples of scheduler web APIs:
setTimeout()
[HTML]setInterval()
[HTML]queueMicrotask()
[HTML]requestAnimationFrame()
[HTML]requestIdleCallback()
[REQUESTIDLECALLBACK]scheduler.postTask()
[SCHEDULING-APIS]HTMLVideoElement
:requestVideoFrameCallback()
method [VIDEO-RVFC]
These web APIs start an asynchronous operation, and take callbacks to indicate that the operation has completed. These are usually legacy APIs, since modern APIs would return a promise instead.
These APIs propagate the context from where the web API is called, which is the point that
starts the async operation. This would also make these callbacks behave the same as they would
when passed to the .then()
method of a promise.
HTMLCanvasElement
:toBlob()
method [HTML]DataTransferItem
:getAsString()
method [HTML]Notification.requestPermission()
[NOTIFICATIONS]BaseAudioContext
:decodeAudioData()
method [WEBAUDIO]navigator.geolocation.getCurrentPosition()
method [GEOLOCATION]- A number of async methods in [ENTRIES-API]
Some of these APIs started out as legacy APIs that took completion callbacks,
and then they were changed to return a promise – e.g. BaseAudioContext
’s
decodeAudioData()
method. For those APIs, the callback’s context would behave
similarly to other async completion callbacks, and the promise rejection context
would behave similarly to other promise-returning web APIs (see below).
These APIs always invoke the callback to run user code as part of an asynchronous operation that they start, and which affects the behavior of the operation. These callbacks are also caused by the original call to the web API, and thus run in the context that was active at that moment.
This context also matches the way these APIs could be implemented in JS:
async function api(callback) {
await doSomething();
await callback();
await doSomethingElse();
}
Document
:startViewTransition()
method [CSS-VIEW-TRANSITIONS-1]LockManager
:request()
method [WEB-LOCKS]
Tip
In all these cases actually propagating the context through the internal asynchronous steps of the algorithms gives the same result as capturing the context when the API is called and storing it together with the callback. This applies boths to "completion callbacks" and to "progress callbacks".
Events are a single API that is used for a great number of things, including cases which have a clear JavaScript-originating cause, and cases which the callback is almost always triggered as a consequence of user interaction.
For consistency, event listener callbacks should be called with the dispatch
context. If that does not exist, the empty context should be used, where all
AsyncContext.Variable
s are set to their initial values.
Event dispatches can be one of the following:
- Synchronous dispatches, where the event dispatch happens synchronously
when a web API is called. Examples are
el.click()
which synchronously fires aclick
event, settinglocation.hash
which synchronously fires apopstate
event, or calling anEventTarget
'sdispatchEvent()
method. For these dispatches, the TC39 proposal's machinery is enough to track the context from the API that will trigger the event, with no help from web specs or browser engines. - Browser-originated dispatches, where the event is triggered by browser or user actions, or by cross-agent JS, with no involvement from JS code in the same agent. Such dispatches can't have propagated any context from some non-existing JS code that triggerted them, so the listener is called with the empty context. (Though see the section on fallback context below.)
- Asynchronous dispatches, where the event originates from JS calling into some web API, but the dispatch happens at a later point. In these cases, the context should be tracked along the data flow of the operation, even across code running in parallel (but not through tasks enqueued on other agents' event loops).
For events triggered by JavaScript code (either synchronously or asynchronously), the goal is to follow the same principle state above: they should propagate the context as if they were implemented by a JavaScript developer that is not explicitly thinking about AsyncContext propagation: listeners for events dispatched either synchronously or asynchronously from JS or from a web API would use the context that API is called with.
Expand this section for examples of the equivalece with JS-authored code
Let's consider a simple approximation of the EventTarget
interface, authored in JavaScript:
class EventTarget {
#listeners = [];
addEventListener(type, listener) {
this.#listeners.push({ type, listener });
}
dispatchEvent(event) {
for (const { type, listener } of this.#listeners) {
if (type === event.type) {
listener.call(this, event);
}
}
}
}
An example synchronous event is AbortSignal
's abort
event. A naive approximation
in JavaScript would look like the following:
class AbortController {
constructor() {
this.signal = new AbortSignal();
}
abort() {
this.signal.aborted = true;
this.signal.dispatchEvent(new Event("abort"));
}
}
When calling abortController.abort()
, there is a current async context active in the agent. All operations that lead to the abort
event being dispatched are synchronous and do not manually change the current async context: the active async context will remain the same through the whole .abort()
process,
including in the event listener callbacks:
const abortController = new AbortController();
const asyncVar = new AsyncContext.Variable();
abortController.signal.addEventListener("abort", () => {
console.log(asyncVar.get()); // "foo"
});
asyncVar.run("foo", () => {
abortController.abort();
});
Let's consider now a more complex case: the asynchronous "load"
event of XMLHttpRequest
. Let's try
to implement XMLHttpRequest
in JavaScript, on top of fetch:
class XMLHttpRequest extends EventTarget {
#method;
#url;
open(method, url) {
this.#method = method;
this.#url = url;
}
send() {
(async () => {
try {
const response = await fetch(this.#url, { method: this.#method });
const reader = response.body.getReader();
let done;
while (!done) {
const { done: d, value } = await reader.read();
done = d;
this.dispatchEvent(new ProgressEvent("progress", { /* ... */ }));
}
this.dispatchEvent(new Event("load"));
} catch (e) {
this.dispatchEvent(new Event("error"));
}
})();
}
}
And lets trace how the context propagates from .send()
in the following case:
const asyncVar = new AsyncContext.Variable();
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://example.com");
xhr.addEventListener("load", () => {
console.log(asyncVar.get()); // "foo"
});
asyncVar.run("foo", () => {
xhr.send();
});
- when
.send()
is called, the value ofasyncVar
is"foo"
. - it is synchronously propagated up to the
fetch()
call in.send()
- the
await
snapshots the context before pausing, and restores it (toasyncVar: "foo"
) when thefetch
completes - the
await
s in the reader loop propagate the context as well - when
this.dispatchEvent(new Event("load"))
, is called, the current active async context is thus the same one as when.send()
was called - the
"load"
callback thus runs withasyncVar
set to"foo"
.
Note that this example uses await
, but due to the proposed semantics for .then
and setTimeout
(and similar APIs), the same would happen when using other asynchronicity primitives. Note that most APIs
dealing with I/O are not actually polyfillable in JavaScript, but you can still emulate/mock them with
testing data.
Event listeners for events dispatched from the browser rather than as a consequence of some JS action (e.g. a user clicking on a button) will by default run in the root (empty) context. This is the same context that the browser uses, for example, for the top-level execution of scripts.
Warning
To keep agents isolated, events dispatched from different agents (e.g. from a worker, or from a cross-origin iframe) will behave like events dispatched by user interaction. This also applies to events dispatched from cross-origin iframes in the same agent, to avoid exposing the fact that they're in the same agent.
Fallback context (#107)
This use of the empty context for browser-originated dispatches, however,
clashes with the goal of allowing “isolated” regions of code that share an event
loop, and being able to trace in which region an error originates. A solution to
this would be the ability to define fallback values for some AsyncContext.Variable
s
when the browser runs some JavaScript code due to a browser-originated dispatch.
const widgetID = new AsyncContext.Variable();
widgetID.run("weather-widget", () => {
captureFallbackContext(widgetID, () => {
renderWeatherWidget();
});
});
In this example, event listeners registered by renderWeatherWidget
would be guaranteed
to always run as a consequence of some "widget": if the event is user-dispatched, then
it defaults to weather-widget
rather than to widgetID
's default value (undefined
,
in this case). There isn't a single global valid default value, because a page might have
multiple widgets that thus need different fallbacks.
Expand this section to read the full example
This complete example shows that when clicking on a button (thus, without a JavaScript cause
that could propagate the context), some asynchronus operations start. These operations
might reject, firing a unhandledrejection
event on the global object.
If there was no fallback context, the "click"
event would run with widgetID
unset, that
would thus be propagated unset to unhandledrejection
as well. Thanks to captureFallbackContext
,
the user-dispatched "click"
event will fallback to running with widgetID
set to
"weather-widget"
, which will then be propagated to unhandledrejection
.
const widgetID = new AsyncContext.Variable();
widgetID.run("weather-widget", () => {
captureFallbackContext(widgetID, () => {
renderWeatherWidget();
});
});
addEventListener("unhandledrejection", event => {
console.error(`Unhandled rejection in widget "${widgetID.get()}"`);
// Handle the rejection. For example, disable the widget, or report
// the error to a server that can then notify the widget's developers.
});
function renderWeatherWidget() {
let day = Temporal.Now.plainDate();
const widget = document.createElement("div");
widget.innerHTML = `
<button id="prev">Previous day</button>
<output>...</output>
<button id="next">Next day</button>
`;
document.body.appendChild(widget);
const load = async () => {
const response = await fetch(`/weather/${day}`);
widget.querySelector("output").textContent = await response.text();
};
widget.querySelector("#prev").addEventListener("click", async () => {
day = day.subtract({ days: 1 });
await load();
});
widget.querySelector("#next").addEventListener("click", async () => {
day = day.add({ days: 1 });
await load();
});
load();
}
When the user clicks on one of the buttons and the fetch
it triggers fails,
without using captureFallbackContext
the unhandledrejection
event listener
would not know that the failure is coming from the weather-widget
widget.
Thanks to captureFallbackContext
, that information is properly propagated.
This fallback is per-variable and not based on AsyncContext.Snapshot
, to avoid
accidentally keeping alive unnecessary objects.
There are still some questions about captureFallbackContext
that need to be
answered:
- should it take just one variable or a list of variables?
- should it just be for event targets, or for all web APIs that take a callback which can run when triggered from outside of JavaScript? (e.g. observers)
- should it be a global, or a static method of
EventTarget
?
These APIs register a callback or constructor to be invoked when some action runs. They’re also commonly used as a way to associate a newly created class instance with some action, such as in worklets or with custom elements.
In cases where the action always originates due to something happening outside of
the web page (such as some user action), there is never some JS code that triggers
the callback. These would behave like async-completion/progress APIs,
that propagate the context from the point where the API is called (making, for
example, navigator.geolocation.watchPosition(cb)
propagate the same way as
navigator.geolocation.getCurrentPosition(cb)
).
navigator.mediaSession.setActionHandler()
method [MEDIASESSION]navigator.geolocation.watchPosition()
method [GEOLOCATION]RemotePlayback
:watchAvailability()
method [REMOTE-PLAYBACK]
Worklets work similarly: you provide a class to an API that is called always from outside of the worklet thread when there is some work to be done.
While in theory there always is only one possible context to propagate to the class methods,
that is the one when .register*()
was called (because there is never in-thread JS code actually
calling those methods), in practice that context will always match the root context of the
worklet scope (because register*()
is always called at the top-level). Hence, to simplify
implementations we propose that Worklet methods always run in the root context.
Custom elements are also registered by passing a class to a web API, and this class has some methods that are called at different points of the custom element's lifecycle.
However, differently from worklets, lifecycle callbacks are almost always triggered
synchronously by a call from userland JS to an API annotated with
[CEReactions]
.
We thus propose that they behave similarly to events, running in the same context that was
active when the API that triggers the callback was called.
There are cases where lifecycle callbacks are triggered by user interaction, so there is no context to propagate:
- If a custom element is contained inside a
<div contenteditable>
, the user could remove the element from the tree as part of editing, which would queue a microtask to call itsdisconnectedCallback
hook. - A user clicking a form reset when a form-associated custom element is in the
form would queue a microtask to call its
formResetCallback
lifecycle hook, and there would not be a causal context.
Similarly to events, in this case lifecycle callbacks would run in the empty context, with the fallback context mechanism.
Observers are a kind of web API pattern where the constructor for a class takes
a callback, the instance’s observe()
method is called to register things that
should be observed, and then the callback is called when those observations have
been made.
Observer callbacks are not called once per observation. Instead, multiple observations can be batched into one single call. This means that there is not always a single JS action that causes some work that eventually triggers the observer callback; rather, there might be many.
Given this, observer callbacks should always run with the empty context, using the same fallback context mechanism as for events. This can be explained by saying that, e.g. layout changes are always considered to be a browser-internal trigger, even if they were caused by changes injected into the DOM or styles through JavaScript.
MutationObserver
[DOM]ResizeObserver
[RESIZE-OBSERVER]IntersectionObserver
[INTERSECTION-OBSERVER]PerformanceObserver
[PERFORMANCE-TIMELINE]ReportingObserver
[REPORTING]
Note
An older version of this proposal suggested to capture the context at the time the observer is created, and use it to run the callback. This has been removed due to memory leak concerns.
In some cases it might be useful to expose the causal context for individual
observations, by exposing an AsyncContext.Snapshot
property on the observation
record. This should be the case for PerformanceObserver
, where
PerformanceEntry
would expose the snapshot as a resourceContext
property. This
is not included as part of this initial proposed version, as new properties can
easily be added as follow-ups in the future.
The underlying source, sink and transform APIs for streams are callbacks/methods passed during stream construction.
The start
method runs as a direct consequence of the stream being constructed,
thus it propagates the context from there. For other methods there would be a
different causal context, depending on what causes the call to that method. For example:
- If
ReadableStreamDefaultReader
’sread()
method is called and that causes a call to thepull
method, then that would be its causal context. This would be the case even if the queue is not empty and the call topull
is deferred until previous invocations resolve. - If a
Request
is constructed from aReadableStream
body, and that is passed tofetch
, the causal context for thepull
method invocations should be the context active at the time thatfetch
was called. Similarly, if a response bodyReadableStream
obtained fromfetch
is piped to aWritableStream
, itswrite
method’s causal context is the call tofetch
.
In general, the context that should be used is the one that matches the data flow through the algorithms (see the section on implicit propagation below).
TODO: Piping is largely implementation-defined. We will need to explicitly define how propagation works there, rather than relying on the streams usage of promises, to ensure interoperability.
TODO: If a stream gets transferred to a different agent, any cross-agent interactions will have to use the empty context. What if you round-trip a stream through another agent?
The error
event on a window or worker global object is fired whenever a script
execution throws an uncaught exception. The context in which this exception was
thrown is the causal context. Likewise, the unhandledrejection
is fired
whenever a promise resolves without a rejection, without a registered rejection
handler, and the causal context is the one in which the promise was rejected.
Having access to the contexts which produced these errors is useful to determine which of multiple independent streams of async execution caused this error, and therefore how to clean up after it. For example:
async function doOperation(i: number, signal: AbortSignal) {
// ...
}
const operationNum = new AsyncContext.Variable();
const controllers: AbortController[] = [];
for (let i = 0; i < 20; i++) {
controllers[i] = new AbortController();
operationNum.run(i, () => doOperation(i, controllers[i].signal));
}
window.onerror = window.onunhandledrejection = () => {
const idx = operationNum.get();
controllers[idx].abort();
};
The context propagating to a unhandledrejection
handler could be unexpected
in some cases. For example, in the following code sample, developers might expect asyncVar
to map
to "bar"
in that context, since the throw that causes the promise rejection
takes place inside a()
. However, the promise that rejects without having a
registered rejection handled is the promise returned by b()
, which only
outside of the asyncVar.run("bar", ...)
returns. Therefore, asyncVar
would
map to "foo"
. The correct mental model is that the context does not propagate
from where the first rejection happens, but from the outermost promise that
the developer forgot to handle.
async function a() {
console.log(asyncVar.get()); // "bar"
throw new Error();
}
async function b() {
console.log(asyncVar.get()); // "foo"
await asyncVar.run("bar", async () => {
const p1 = a();
await p1;
});
}
asyncVar.run("foo", () => {
const p2 = b();
});
If a promise created by a web API rejects, the unhandledrejection
event
handlers context would be tracked following the normal tracking mechanism. According to the
categories in the “Writing Promise-Using Specifications” guide:
- For one-and-done operations, the rejection-time context of the returned promise should be the context when the web API that returns it was called.
- For one-time “events”, the rejection context would be the context in which the
promise is caused to reject. In many cases, the promise is created at the same
time as an async operation is started which will eventually resolve it, and so
the context would flow from creation to rejection (e.g. for the
loaded
property of aFontFace
instance, creating theFontFace
instance causes both the promise creation and the loading of the font). But this is not always the case, as for theready
property of aWritableStreamDefaultWriter
, which could be caused to reject by a different context. In such cases, the context should be propagated implicitly. - More general state transitions are similar to one-time “events” which can be reset, and so they should behave in the same way.
When a cross-document navigation happens, even if it is same-origin, the context
will be reset such that document load and tasks that directly flow from it
(including execution of classic scripts found during parsing) run with the
empty AsyncContext snapshot, which will be an empty mapping (i.e. every
AsyncContext.Variable
will be set to its initial value).
When you import a JS module multiple times, it will only be fetched and evaluated once. Since module evaluation should not be racy (i.e. it should not depend on the order of various imports), the context should be reset so that module evaluation always runs with the empty AsyncContext snapshot.
An agent always has an associated AsyncContext mapping, in its
[[AsyncContextMapping]]
field1. When the agent is created, this mapping will be
set to an HTML-provided initial state, but JS user code can change it in a
strictly scoped way.
In the current proposal, the only way JS code can modify the current mapping is
through AsyncContext.Variable
and AsyncContext.Snapshot
’s run()
methods,
which switch the context before calling a callback and switch it back after it
synchronously returns or throws. This ensures that for purely synchronous
execution, the context is automatically propagated along the data flow. It is
when tasks and microtasks are queued that the data flow must be tracked through
web specs.
The TC39 proposal spec text includes two abstract operations that web specs can use to store and switch the context:
AsyncContextSnapshot()
returns the current AsyncContext mapping.AsyncContextSwap(context)
sets the current AsyncContext mapping tocontext
, and returns the previous one.context
must only be a value returned by one of these two operations.
We propose adding a web spec algorithm “run the AsyncContext Snapshot”, that could be used like this:
- Let context be AsyncContextSnapshot().
- Queue a global task to run the following steps:
- Run the AsyncContext Snapshot context while performing the following steps:
- Perform some algorithm, which might call into JS.
This algorithm, when called with an AsyncContext mapping context and a set of steps steps, would do the following:
- Let previousContext be AsyncContextSwap(context).
- Run steps. If this throws an exception e, then:
- AsyncContextSwap(previousContext).
- Throw e.
- AsyncContextSwap(previousContext).
For web APIs that take a callback and eventually call it with the same context as when
the web API was called, this should be handled in WebIDL by storing the result of AsyncContextSnapshot()
alongside the callback function, and swapping it when the function is called. Since this should not happen
for every callback, there should be a WebIDL extended attribute applied to callback types to control this.
There are use cases in the web platform that would benefit from using AsyncContext variables built into the platform, since there are often relevant pieces of contextual information which would be impractical to pass explicitly as parameters. Some of these use cases are:
-
Task attribution. The soft navigations API [SOFT-NAVIGATIONS] needs to be able to track which tasks in the event loop are caused by other tasks, in order to measure the time between the user interaction that caused the soft navigation, and the end of the navigation. Currently this is handled by modifying a number of event loop-related algorithms from the HTML spec, but basing it on AsyncContext might be easier. It seems like this would also be useful to identify scripts that enqueued long tasks, or to build dependency trees for the loading of resources. See WICG/soft-navigations#44.
-
scheduler.yield
priority and signal. In order to provide a more ergonomic API, ifscheduler.yield()
is called inside a task enqueued byscheduler.postTask()
[SCHEDULING-APIS], itspriority
andsignal
arguments will be “inherited” from the call topostTask
. This inheritance should propagate across awaits. See WICG/scheduling-apis#94. -
Future possibility: ambient
AbortSignal
. This would allow using anAbortSignal
without needing to pass it down across the call stack until the leaf async operations. See https://gist.github.com/littledan/47b4fe9cf9196abdcd53abee940e92df -
Possible refactoring: backup incumbent realm. The HTML spec infrastructure for the incumbent realm uses a stack of backup incumbent realms synchronized with the JS execution stack, and explicitly propagates the incumbent realm through
await
s using JS host hooks. This might be refactored to build on top of AsyncContext, which might help fix some long-standing disagreements between certain browsers and the spec.
For each of these use cases, there would need to be an AsyncContext.Variable
instance backing it, which should not be exposed to JS code. We expect that
algorithms will be added to the TC39 proposed spec text, so that web specs don’t
need to create JS objects.
Footnotes
-
The reason this field is agent-wide rather than per-realm is so calling a function from a different realm which calls back into you doesn’t lose the context, even if the functions are async. ↩