tc39 / proposal-async-context

Async Context for JavaScript
https://tc39.es/proposal-async-context/
Creative Commons Zero v1.0 Universal
591 stars 14 forks source link

Standardized `AsyncContext.Variable` values #103

Open dgp1130 opened 2 months ago

dgp1130 commented 2 months ago

I'm sure this idea has come up before, but I couldn't find any existing content on it, so I figured I'd put together an issue.

I want to explore the idea of having standardized AsyncContext.Variable objects for specific use cases, much like how Symbol can create arbitrary symbols, but also contains a list of Symbol with specific semantics (ex. Symbol.iterator). Can we define specific AsyncContext variables for particular use cases which might be leveraged by standard implementations?

The main use case I'm thinking of is AbortSignal. Currently, developers much manually pass through AbortSignal into all relevant async APIs.

async function parent({ signal }: { signal?: AbortSignal } = {}): Promise<void> {
  await child({ signal });
}

async function child({ signal }: { signal?: AbortSignal } = {}): Promise<void> {
  await grandchild({ signal });
}

async function grandchild({ signal }: { signal?: AbortSignal } = {}): Promise<void> {
  const res1 = await fetch('/one', { signal });
  const res2 = await fetch('/two', { signal });
  // ...
}

To do this correctly, every async operation needs to accept a signal as an input and properly pass it through to all async functions they call. That's a lot of boilerplate and it's easy to forget.

I'd like to propose a standardized AsyncContext.signal value. In practice, this is just a standard AsyncContext.Variable containing an optional AbortSignal and defaulting to undefined.

AsyncContext.signal = new AsyncContext.Variable<AbortSignal | undefined>(undefined);

Then, anyone can read/write to this context rather than passing through signal in function parameters.

async function parent(): Promise<void> {
  await child();
}

async function child(): Promise<void> {
  await grandchild();
}

async function grandchild(): Promise<void> {
  const signal = AsyncContext.abort.get(); // Get the signal.

  // Use it.
  if (signal) {
    if (signal.aborted) return;
    signal.addEventListener('abort', () => { /* ... */ }, { once: true });
  }

  // ...
}

const signal = AbortSignal.timeout(1_000); // Create an `AbortSignal`.
await AsyncContext.abort.run(signal, () => parent()); // Automatically times out after 1sec.

Now anyone could define their own AsyncContext.Variable<AbortSignal | undefined> for this purpose, however by having a standard location for it, we get two additional benefits:

  1. AsyncContext.signal can be shared across libraries and more consistently used in the JavaScript ecosystem (different libraries don't need to define their own variable).
  2. Standard functions can use AsyncContext.signal as well.

Expanding on 2., what if fetch was aware of AsyncContext.signal and listened to it? Then, the following could work:

// No `AbortSignal` anywhere in the function definitions!
async function parent(): Promise<void> {
  await child();
}

async function child(): Promise<void> {
  await grandchild();
}

async function grandchild(): Promise<void> {
  const res1 = await fetch('/one'); // Inherits `AsyncContext.signal`.
  const res2 = await fetch('/two'); // Inherits `AsyncContext.signal`.
  // ...
}

// Run `parent()` and timeout after 1sec.
await AsyncContext.signal.run(AbortSignal.timeout(1_000), () => parent());

fetch could read AsyncContext.signal and automatically cancel the active request when it aborts!

This feels very useful to me and fixes a lot of the ecosystem problems with AbortSignal today. Not requiring developers to understand and design this concept into their APIs feels like a huge win. Aborting async operations basically "just works".

There are probably other use cases which might benefit from a standardized AsyncContext.Variable, this is just the most obvious one to me. This particular one is probably more of a follow-up standard after AsyncContext lands on its own, but I think it's worth mentioning here to foster some discussion about the use case and to use this as additional motivation for why AsyncContext could be useful.

Jamesernator commented 2 months ago

See also this which is basically the same thing: https://gist.github.com/littledan/47b4fe9cf9196abdcd53abee940e92df

mmocny commented 1 month ago

FYI @shaseley, given how prioritized task scheduling also has continuation preserved signal.