async-cancelable-event
v1.0.2
Published
A web standard Event that can asynchronously preventDefault
Maintainers
Readme
async-cancelable-event
Async-aware cancelable events for the DOM — because
preventDefault()shouldn't require synchronous timing.
The Problem
In the DOM event model, event.preventDefault() must be called
synchronously during listener execution for event.defaultPrevented to
reflect the cancellation. This creates a fundamental problem for async
listeners:
target.addEventListener('save', async (event) => {
const confirmed = await checkWithServer(); // ← async work
if (!confirmed) event.preventDefault(); // ← too late! dispatcher already moved on
});By the time the await completes and the listener calls preventDefault(),
the dispatching code has already read defaultPrevented and proceeded with the
default action.
The Solution
AsyncCancelableEvent extends the standard Event class with a coordination
mechanism between dispatchers and async listeners:
- Dispatchers
awaittheasyncDefaultPreventedproperty instead of readingdefaultPreventedsynchronously. This promise resolves only after all async listeners that registered a hold have completed. - Async listeners call
withAsyncPreventDefault()to register an ongoing async operation, receiving a disposable handle. The handle's disposal signals that the listener's async work is complete. The listener can still call the standardpreventDefault()at any point.
Installation
npm install async-cancelable-eventUsage
Dispatching
import AsyncCancelableEvent from 'async-cancelable-event';
const event = new AsyncCancelableEvent('save');
target.dispatchEvent(event);
if (await event.asyncDefaultPrevented) {
// At least one listener called preventDefault()
console.log('Save was cancelled');
} else {
// No listener prevented the default action
console.log('Proceeding with save');
}Listening (recommended: using)
target.addEventListener('save', async (event) => {
using _ = event.withAsyncPreventDefault();
// ↑ The dispatcher will now wait for this listener to complete
const confirmed = await checkWithServer();
if (!confirmed) event.preventDefault();
// `using` ensures the hold is released even if an error is thrown
});Listening (manual disposal)
If you need more control over when the hold is released, you can call
dispose() manually:
target.addEventListener('save', async (event) => {
const handle = event.withAsyncPreventDefault();
try {
const confirmed = await checkWithServer();
if (!confirmed) event.preventDefault();
} finally {
handle?.dispose();
}
});Multiple async listeners
All async listeners that call withAsyncPreventDefault() are tracked.
asyncDefaultPrevented waits for every hold to be released before
resolving:
target.addEventListener('save', async (event) => {
using _ = event.withAsyncPreventDefault();
await validateForm(); // may call preventDefault()
});
target.addEventListener('save', async (event) => {
using _ = event.withAsyncPreventDefault();
await checkPermissions(); // may call preventDefault()
});
// asyncDefaultPrevented resolves only after BOTH listeners completeNon-cancelable events
If the event is not cancelable, withAsyncPreventDefault() returns undefined
— mirroring the standard behavior where preventDefault() is a no-op on
non-cancelable events:
const event = new AsyncCancelableEvent('info', { cancelable: false });
event.withAsyncPreventDefault(); // → undefinedAPI
class AsyncCancelableEvent extends Event
constructor(eventName: string, init?: EventInit)
Creates a new AsyncCancelableEvent. The event is cancelable by default
(cancelable: true), since the entire purpose of this class is to support
async cancellation. Pass { cancelable: false } in init to override.
get asyncDefaultPrevented: Promise<boolean>
An asynchronously-resolved version of Event.defaultPrevented. Waits for all
async listener holds to be released, then returns the final value of
defaultPrevented.
- If no async listeners registered a hold, resolves immediately with the current synchronous value.
- If one or more holds are active, resolves only after all have been disposed.
⚠️ Always
awaitthis property. Reading it withoutawaityields aPromise<boolean>, not a boolean — synchronous checks will always appear as if the event was not prevented.
withAsyncPreventDefault(): ExplicitlyDisposable | undefined
Registers an asynchronous hold on this event and returns a disposable handle. The handle must be released when the async listener's work is complete.
- Returns
undefinedif the event is not cancelable. - The returned handle implements both
dispose()(explicit) andSymbol.dispose(forusingdeclarations).
type ExplicitlyDisposable = { dispose(): void } & Disposable
A type representing an object that is both explicitly disposable (via
a dispose() method) and implicitly disposable (via Symbol.dispose, enabling
using declarations).
How It Works
Under the hood, withAsyncPreventDefault() uses Promise.withResolvers() to
create a promise/resolve pair. The promise is pushed into an internal registry,
and the resolve function becomes the disposal callback. When
asyncDefaultPrevented is accessed, it uses Promise.all() on the registry to
wait for every hold to be released, then reads the standard defaultPrevented
property.
withAsyncPreventDefault() → creates { promise, resolve }
promise → pushed to #asyncHandlers
resolve → returned as dispose / Symbol.dispose
asyncDefaultPrevented → Promise.all(#asyncHandlers)
.then(() => this.defaultPrevented)This design means preventDefault() remains the single mechanism for
cancellation — the hold system only controls timing, ensuring the
dispatcher waits until async listeners have had their say.
Browser Support
This module relies on two relatively recent JavaScript features:
| Feature | Chrome | Firefox | Safari | Node.js |
|---|---|---|---|---|
| Promise.withResolvers() | 119+ | 121+ | 17.4+ | 22+ |
| using / Symbol.dispose | 123+ | 119+ | Pending | 20.9+ |
As of early 2026, Chrome 123+ and Firefox 119+ support all of these features;
Safari support for Explicit Resource Management is still pending
Promise.withResolvers() has broader support, available since Chrome 119 and
Firefox 121
If you need to support older browsers, you can:
- For
Promise.withResolvers: Use a polyfill — the function has a trivial one-line implementation. - For
using/Symbol.dispose: Use manualdispose()calls instead ofusingdeclarations, and polyfillSymbol.disposeif needed. TypeScript has supportedusingandDisposablesince version 5.2
License
Copyright 2026 Devin Weaver
Released under the MIT License.
- Tests written by a human
- Implementation written by a human
- Documentation generated by an AI
