Promise Rejection Events

Elide implements the [HTML promise-rejection events][spec] required by the [WinterCG Minimum Common API][wintercg]. Two events fire at globalThis to surface promise rejections that escape userland handling:

  • unhandledrejection — dispatched after the microtask checkpoint completes for every promise rejected without a handler attached. Cancelable: calling preventDefault() on the event suppresses the runtime's default action of writing the rejection to stderr.
  • rejectionhandled — dispatched when a handler is attached to a previously-rejected promise after unhandledrejection already fired for it. Non-cancelable.

[spec]: https://html.spec.whatwg.org/#unhandled-promise-rejections [wintercg]: https://min-common-api.proposal.wintercg.org/

PromiseRejectionEvent

js
new PromiseRejectionEvent(type, eventInitDict)
// eventInitDict: { promise: Promise<any> /* required */, reason?: any,
//                  bubbles?: boolean = false, cancelable?: boolean = false,
//                  composed?: boolean = false }
Subclass of Event: event instanceof Event is true, and every Event member works on a PromiseRejectionEvent. Adds two readonly attributes:
MemberTypeNotes
promisePromiseReadonly; the rejected promise, taken from the init dictionary or the runtime tracker.
reasonanyReadonly; the rejection reason (the value passed to Promise.reject, the value of a thrown error inside async code, etc.). For rejectionhandled events the runtime reads this from the promise's stored result.
The promise member is required in the init dictionary — calling new PromiseRejectionEvent("foo") or new PromiseRejectionEvent("foo", {}) raises TypeError, matching the WebIDL required marker.

Plain-call PromiseRejectionEvent(...) (without new) raises TypeError per the standard WebIDL rule for interfaces with constructors.

Event delivery on globalThis

globalThis exposes the standard EventTarget triad — addEventListener, removeEventListener, dispatchEvent — wired up at realm-init time. Listener registration is identical to a regular EventTarget:
js
globalThis.addEventListener("unhandledrejection", (event) => {
  console.warn("unhandled rejection:", event.reason);
  event.preventDefault();   // suppress the default stderr line
});

globalThis.addEventListener("rejectionhandled", (event) => {
  console.info("late handler attached for:", event.reason);
});

The two events also surface as event-handler properties:

js
globalThis.onunhandledrejection = (e) => { /* ... */ };
globalThis.onrejectionhandled = (e) => { /* ... */ };

These follow the standard HTML EventHandler reflection algorithm — assigning a function replaces the previous handler (the previous one is removeEventListener'd first), assigning null or any non-callable value clears the slot. Both addEventListener-style and on-property-style listeners receive the same trusted PromiseRejectionEvent instance and may interleave.

When the events fire

The runtime tracks promise-rejection state through three transitions, mirroring the HTML "notify about rejected promises" algorithm:

1. Promise rejects with no handler attached — buffered into a per-realm "about-to-be-notified" list. No event yet; a handler attached later in the same microtask drain can still suppress it. 2. Microtask drain completes — every entry still in the list dispatches unhandledrejection on globalThis. If the listener calls preventDefault() the runtime skips the default stderr-log action; the promise still moves to the "outstanding" set so a later handler-attach can fire rejectionhandled for it. 3. Handler attaches after unhandledrejection already fired — fires rejectionhandled on globalThis for that promise.

Promises whose handlers attach during the same microtask cycle (before the drain checkpoint) never see either event — the rejection is considered handled in time.

isTrusted

Events fired by the runtime tracker have isTrusted === true — the spec-mandated bit that distinguishes implementation-fired events from those constructed in user code. Events created via new PromiseRejectionEvent(...) and dispatched manually have isTrusted === false, matching dispatchEvent's behaviour for every other Event subclass.

Notes on the implementation

  • globalThis is not (yet) itself the canonical EventTarget instance — addEventListener and friends delegate to a hidden per-realm event target. Listeners observe event.target set to that hidden target, not globalThis. Manual globalThis.dispatchEvent(event) calls land on the same hidden target, so userland code that registers and dispatches through globalThis is internally consistent.
  • The runtime tracker is installed on the JS context via graal-js's JSContext.setPromiseRejectionTracker hook, so the buffering and microtask-boundary timing are driven by the same code path graal-js uses for promise reactions — there's no separate scheduler.
  • The default stderr line is Uncaught (in promise) — best-effort string of the reason value via JSRuntime.safeToString, which avoids re-entering user code while logging.