Description
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:
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).- 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.