Skip to content

Commit dee2804

Browse files
Expand on the fallback context in web integration (#119)
Co-authored-by: Andreu Botella <andreu@andreubotella.com>
1 parent aa05034 commit dee2804

File tree

1 file changed

+213
-21
lines changed

1 file changed

+213
-21
lines changed

WEB-INTEGRATION.md

Lines changed: 213 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -426,36 +426,228 @@ Event dispatches can be one of the following:
426426
some web API, but the dispatch happens at a later point. In these cases, the
427427
context should be tracked along the data flow of the operation, even across
428428
code running in parallel (but not through tasks enqueued on other agents'
429-
event loops). [See below on implicit context
430-
propagation](#implicit-context-propagation) for how this data flow tracking
431-
should happen.
429+
event loops).
432430

433-
This classification of event dispatches is the way it should be in theory, as
434-
well as a long-term goal. However, as we describe later in the section on
435-
implicit context propagation, for the initial rollout we propose treating the
436-
vast majority of asynchronous dispatches as if they were browser-originated.
437-
The exceptions would be:
431+
For events triggered by JavaScript code (either synchronously or asynchronously),
432+
the goal is for them to behave equivalently as if they were implemented by a
433+
JavaScript developer that is not explicitly thinking about AsyncContext propagation:
434+
listeners for events dispatched either **synchronously** or **asynchronously** from
435+
JS or from a web API would use the context that API is called with.
438436

439-
- The `popstate` event
440-
- The `message` and `messageerror` events
441-
- All events dispatched on `XMLHttpRequest` or `XMLHttpRequestUpload` objects
442-
- The `unhandledrejection` and `rejectionhandled` events on the global object
443-
(see below)
437+
<details>
438+
<summary>Expand this section for examples of the equivalece with JS-authored code</summary>
444439

445-
> TODO: The exact principle for which event listeners are included in this list is still under discussion.
440+
Let's consider a simple approximation of the `EventTarget` interface, authored in JavaScript:
441+
```javascript
442+
class EventTarget {
443+
#listeners = [];
446444

447-
The list above is not meant to be hard-coded in the events machinery as a "is this event part of that list?" check. Instead, the spec text and browser code that fires
448-
each of these individual events would be modified so that it keeps track of the
449-
context in which these events were scheduled (e.g. the context of `window.postMessage` or `xhr.send()`), and so that that context is restored before firing the event.
445+
addEventListener(type, listener) {
446+
this.#listeners.push({ type, listener });
447+
}
448+
449+
dispatchEvent(event) {
450+
for (const { type, listener } of this.#listeners) {
451+
if (type === event.type) {
452+
listener.call(this, event);
453+
}
454+
}
455+
}
456+
}
457+
```
458+
459+
An example _synchronous_ event is `AbortSignal`'s `abort` event. A naive approximation
460+
in JavaScript would look like the following:
461+
462+
```javascript
463+
class AbortController {
464+
constructor() {
465+
this.signal = new AbortSignal();
466+
}
467+
468+
abort() {
469+
this.signal.aborted = true;
470+
this.signal.dispatchEvent(new Event("abort"));
471+
}
472+
}
473+
```
474+
475+
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,
476+
including in the event listener callbacks:
477+
478+
```javascript
479+
const abortController = new AbortController();
480+
const asyncVar = new AsyncContext.Variable();
481+
abortController.signal.addEventListener("abort", () => {
482+
console.log(asyncVar.get()); // "foo"
483+
});
484+
asyncVar.run("foo", () => {
485+
abortController.abort();
486+
});
487+
```
488+
489+
Let's consider now a more complex case: the asynchronous `"load"` event of `XMLHttpRequest`. Let's try
490+
to implement `XMLHttpRequest` in JavaScript, on top of fetch:
491+
492+
```javascript
493+
class XMLHttpRequest extends EventTarget {
494+
#method;
495+
#url;
496+
open(method, url) {
497+
this.#method = method;
498+
this.#url = url;
499+
}
500+
send() {
501+
(async () => {
502+
try {
503+
const response = await fetch(this.#url, { method: this.#method });
504+
const reader = response.body.getReader();
505+
let done;
506+
while (!done) {
507+
const { done: d, value } = await reader.read();
508+
done = d;
509+
this.dispatchEvent(new ProgressEvent("progress", { /* ... */ }));
510+
}
511+
this.dispatchEvent(new Event("load"));
512+
} catch (e) {
513+
this.dispatchEvent(new Event("error"));
514+
}
515+
})();
516+
}
517+
}
518+
```
519+
520+
And lets trace how the context propagates from `.send()` in the following case:
521+
```javascript
522+
const asyncVar = new AsyncContext.Variable();
523+
const xhr = new XMLHttpRequest();
524+
xhr.open("GET", "https://example.com");
525+
xhr.addEventListener("load", () => {
526+
console.log(asyncVar.get()); // "foo"
527+
});
528+
asyncVar.run("foo", () => {
529+
xhr.send();
530+
});
531+
```
532+
- when `.send()` is called, the value of `asyncVar` is `"foo"`.
533+
- it is synchronously propagated up to the `fetch()` call in `.send()`
534+
- the `await` snapshots the context before pausing, and restores it (to `asyncVar: "foo"`) when the `fetch` completes
535+
- the `await`s in the reader loop propagate the context as well
536+
- when `this.dispatchEvent(new Event("load"))`, is called, the current active async context is thus
537+
the same one as when `.send()` was called
538+
- the `"load"` callback thus runs with `asyncVar` set to `"foo"`.
450539

451-
### Fallback context
540+
Note that this example uses `await`, but due to the proposed semantics for `.then` and `setTimeout`
541+
(and similar APIs), the same would happen when using other asynchronicity primitives. Note that most APIs
542+
dealing with I/O are not actually polyfillable in JavaScript, but you can still emulate/mock them with
543+
testing data.
544+
545+
</details>
546+
547+
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
548+
context that the browser uses, for example, for the top-level execution of scripts.
549+
550+
> NOTE: 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.
551+
552+
### Fallback context ([#107](https://github.com/tc39/proposal-async-context/issues/107))
452553

453554
This use of the empty context for browser-originated dispatches, however,
454555
clashes with the goal of allowing “isolated” regions of code that share an event
455556
loop, and being able to trace in which region an error originates. A solution to
456-
this would be the ability to define a fallback context for a region of code. We
457-
have a proposal for this being fleshed out at issue
458-
[#107](https://github.com/tc39/proposal-async-context/issues/107).
557+
this would be the ability to define fallback values for some `AsyncContext.Variable`s
558+
when the browser runs some JavaScript code due to a browser-originated dispatch.
559+
560+
```javascript
561+
const widgetID = new AsyncContext.Variable();
562+
563+
widgetID.run("weather-widget", () => {
564+
captureFallbackContext(widgetID, () => {
565+
renderWeatherWidget();
566+
});
567+
});
568+
```
569+
570+
In this example, event listeners registered by `renderWeatherWidget` would be guaranteed
571+
to always run as a consequence of some "widget": if the event is user-dispatched, then
572+
it defaults to `weather-widget` rather than to `widgetID`'s default value (`undefined`,
573+
in this case). There isn't a single global valid default value, because a page might have
574+
multiple widgets that thus need different fallbacks.
575+
576+
<details>
577+
<summary>Expand this section to read the full example</summary>
578+
579+
This complete example shows that when clicking on a button (thus, without a JavaScript cause
580+
that could propagate the context), some asynchronus operations start. These operations
581+
might reject, firing a `unhandledrejection` event on the global object.
582+
583+
If there was no fallback context, the `"click"` event would run with `widgetID` unset, that
584+
would thus be propagated unset to `unhandledrejection` as well. Thanks to `captureFallbackContext`,
585+
the user-dispatched `"click"` event will fallback to running with `widgetID` set to
586+
`"weather-widget"`, which will then be propagated to `unhandledrejection`.
587+
588+
```javascript
589+
const widgetID = new AsyncContext.Variable();
590+
591+
widgetID.run("weather-widget", () => {
592+
captureFallbackContext(widgetID, () => {
593+
renderWeatherWidget();
594+
});
595+
});
596+
597+
addEventListener("unhandledrejection", event => {
598+
console.error(`Unhandled rejection in widget "${widgetID.get()}"`);
599+
// Handle the rejection. For example, disable the widget, or report
600+
// the error to a server that can then notify the widget's developers.
601+
});
602+
```
603+
604+
```javascript
605+
function renderWeatherWidget() {
606+
let day = Temporal.Now.plainDate();
607+
608+
const widget = document.createElement("div");
609+
widget.innerHTML = `
610+
<button id="prev">Previous day</button>
611+
<output>...</output>
612+
<button id="next">Next day</button>
613+
`;
614+
document.body.appendChild(widget);
615+
616+
const load = async () => {
617+
const response = await fetch(`/weather/${day}`);
618+
widget.querySelector("output").textContent = await response.text();
619+
};
620+
621+
widget.querySelector("#prev").addEventListener("click", async () => {
622+
day = day.subtract({ days: 1 });
623+
await load();
624+
});
625+
widget.querySelector("#next").addEventListener("click", async () => {
626+
day = day.add({ days: 1 });
627+
await load();
628+
});
629+
630+
load();
631+
}
632+
```
633+
634+
When the user clicks on one of the buttons and the `fetch` it triggers fails,
635+
without using `captureFallbackContext` the `unhandledrejection` event listener
636+
would not know that the failure is coming from the `weather-widget` widget.
637+
638+
Thanks to `captureFallbackContext`, that information is properly propagated.
639+
640+
</details>
641+
642+
This fallback is per-variable and not based on `AsyncContext.Snapshot`, to avoid
643+
accidentally keeping alive unnecessary objects.
644+
645+
There are still some questions about `captureFallbackContext` that need to be
646+
answered:
647+
- should it take just one variable or a list of variables?
648+
- should it just be for event targets, or for all web APIs that take a callback
649+
which can run when triggered from outside of JavaScript? (e.g. observers)
650+
- should it be a global, or a static method of `EventTarget`?
459651

460652
## Script errors and unhandled rejections
461653

0 commit comments

Comments
 (0)