[whatwg/dom] Robust events (#1016)

As a note, this issue is primarily targeted at `AbortSignal`'s `"abort"` event, however the infrastructure could be useful to many event types where listeners after a certain point are probably a mistake.

Anyway, currently with `AbortSignal` we currently have a problem where `AbortSignal`s may live a long time (possibly very long times if they're forwarded from program level). This means if we pass an `AbortSignal` around a program, it is very easy to leak memory accidentally by using `.addEventListener("abort", () => doSomethingOnAbort())`. Similarly if an event handler is subscribed late, it will never be collected (due to the existence of `.dispatchEvent`)

A related issue is also due to the existence of `.dispatchEvent`, and that's that even if we add a `"abort"` handler, if we don't write awkward boilerplate to check if the event `.isTrusted`, then there is no guarantee about consistency with `.aborted` or with behaviour of web APIs, i.e.:

```js
const controller = new AbortController();
const { signal } = controller;

const req = await fetch("/some/url", { signal });
signal.addEventListener("abort", () => {
    console.log("Some cleanup action!");
});

// This triggers listeners, but leaves an inconsistent state as
// fetch does not care about this event and so the fetch is not cancelled
// Additionally because more .dispatchEvent or real events could be fired
// on the signal, the handler will be never be removed
signal.dispatchEvent(new Event("abort"));
```

I'd like to propose a solution that solves both of these issues at once, and that is the idea I'm referring to as "robust events". The basic idea of "robust events" is essentially that certain events are marked so that `.dispatchEvent` is not allowed.

Essentially the idea here is that some APIs (in particular `AbortSignal` `"abort"`) would declare their events as robust, what this entails is that only events which are trusted can be dispatched on the event target, further the API can declare when a certain type of event may no longer be fired and as such mark all listeners as garbage collectable.

With this idea effectively we get this:

```js
const controller = new AbortController();
const { signal } = controller;

const req = await fetch("/some/url", { signal });

const finalizationRegistry = new FinalizationRegistry((handler) => {
    // This will eventually happen, assuming normal GC, despite no
    // explicit removal of the event listener
    console.log("Collected the handler!");
});

let listener = () => {
    // Guaranteed to be true
    console.log(signal.aborted);
};

signal.addEventListener("abort", listener);

// Make only reference to listener inside signal event handler list
listener = null;

try {
    signal.dispatchEvent(new Event("");
} catch {
    // This happens because dispatchEvent throws an error
    console.log("Some error was thrown!");
}

// Triggers the listener, this works as expected and the listener is fired
// After this the handlers are all eligible for collection
controller.abort();
```

Note that this might be useful outside of `AbortSignal`, there's actually a number of events where only real (`.isTrusted`) events are generally of interest. For example `audioNode.onended` events, `animation.onfinished`. It's also worth noting that this isn't just useful for "one-time" events, but rather any events which never occur again after a certain point (i.e. maybe websocket messages, or similar).

Some places where this idea is not a good idea would be all DOM events, DOM events are often dispatched explicitly in order to transform events from one kind to another so if you have a handler you probably do want fake events in the majority of cases.

Some open questions remain with this idea though:

- Is changing `"abort"` to disallow `.dispatchEvent` web-compatible?
  - If not this could be done as an option to `.addEventListener`, i.e. `.addEventListener("abort", callback, { robust: true })`, when `robust: true` is set then only trusted events are considered, and robust callbacks can be collected
    - Note in this sub-idea: regular callbacks will still be called for non-trusted events and will never be collected as long as the event handler lives
- Similarly if other APIs want to use this are changing those APIs web-compatible? Certainly new APis would be, but there are probably cases where old APIs may wish to allow GC of handlers
- Should we have the event handler option anyyway, this means `.dispatchEvent` would never throw, but in general whether or not `.addEventListener` observes those events would be based on some set default, i.e. for `AbortSignal` we'd declare that `"abort"` handlers are robust by default, so they would both ignore `.dispatchEvent` events, and be released for garbage collection on the real event
- Should we allow userland `EventTarget` instances the ability to have robust events?
  - If so we'd do something like 
      ```js
      const target = new EventTarget({
          robustEvents: ["my-event-1", "my-event-2"],
          // Dunno what the best API shape would be here
          start: async ([dispatchEvent1, dispatchEvent2]) => {
              // On garbage collection of either dispatchEvent1 or dispatchEvent2
              // the handlers for those events could be collected also
              
              // When passing this event into dispatchEvent1 it also gets marked
              // as .isTrusted === true, this makes it more similar to a UA event
              dispatchEvent1(new Event("my-event-1"));
          },
      });
      ```

-- 
You are receiving this because you are subscribed to this thread.
Reply to this email directly or view it on GitHub:
https://github.com/whatwg/dom/issues/1016

Received on Friday, 17 September 2021 00:29:16 UTC