Created
March 13, 2026 13:26
-
-
Save bartlomieju/bdc1ef45bd9bba24dc4ed082555092b6 to your computer and use it in GitHub Desktop.
Investigation: Deno issue #15176 - Resolving promises in beforeunload event
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Investigation: Deno Issue #15176 — Resolving Promises in `beforeunload` Event | |
| ## The Problem | |
| ```js | |
| await new Promise(resolve => { | |
| window.onbeforeunload = resolve; | |
| }); | |
| ``` | |
| Errors with: `Top-level await promise never resolved` | |
| This also affects `process.on("beforeExit", resolve)` in Node compat mode. | |
| ## Node.js Comparison | |
| Node.js handles the equivalent case correctly: | |
| ```js | |
| // Works in Node.js, prints "done" | |
| await new Promise(resolve => { process.on("beforeExit", resolve); }); | |
| console.log("done"); | |
| ``` | |
| Node fires `beforeExit` **inside** the event loop drain cycle (as part of libuv). | |
| If the handler schedules new work or resolves a promise, the loop continues. | |
| In Deno, both `beforeunload` and `process.beforeExit` are dispatched **after** | |
| the event loop has already concluded there's nothing left — so any pending | |
| promise waiting on them is treated as an error. | |
| ## Architecture | |
| The CLI loop (`cli/lib/worker.rs:896-909`): | |
| ```rust | |
| loop { | |
| self.worker.run_event_loop(false).await?; // ← errors here, promise pending | |
| let web_continue = self.worker.dispatch_beforeunload_event()?; // ← never reached | |
| if !web_continue { | |
| let node_continue = self.worker.dispatch_process_beforeexit_event()?; | |
| if !node_continue { break; } | |
| } | |
| } | |
| ``` | |
| The event loop pending state (`libs/core/runtime/jsruntime.rs:2558-2648`): | |
| `EventLoopPendingState::is_pending()` checks ops, timers, dynamic imports, etc. | |
| None of these know about `beforeunload`/`beforeExit` handlers, so when there are | |
| no pending ops but a top-level await is waiting for one of these events, the loop | |
| concludes prematurely. | |
| The error is raised in `with_event_loop_promise()` (`jsruntime.rs:2031-2056`) and | |
| in `evaluate_module()` (`runtime/worker.rs:829-850`), which does a `select!` | |
| between the module evaluation receiver and the event loop — when the event loop | |
| finishes first and the promise is still pending, it errors. | |
| ## Proposed Fix: Generic "before-exit" callback | |
| **Don't make `libs/core/` aware of `beforeunload`/`beforeExit` specifically.** | |
| Instead, add a generic callback mechanism. | |
| Add a callback to `PollEventLoopOptions` (or `JsRuntime`) that fires when | |
| `is_pending()` returns false, **before** returning `Poll::Ready`: | |
| ```rust | |
| // In jsruntime.rs, around line 2257: | |
| if !pending_state.is_pending() { | |
| // Give the embedder a chance to inject work before exiting | |
| if let Some(ref cb) = poll_options.before_exit_callback { | |
| if cb(scope) { | |
| // Callback scheduled new work (e.g., beforeunload fired | |
| // and preventDefault was called), go around again | |
| continue; | |
| } | |
| } | |
| return Poll::Ready(Ok(())); | |
| } | |
| ``` | |
| The runtime layer (`runtime/worker.rs`) would register a callback that dispatches | |
| `beforeunload` + `process.beforeExit`. If the event is cancelled or new work is | |
| scheduled, return `true` to keep the loop alive. | |
| ### Why this approach? | |
| - **Same pattern as Node/libuv**: `beforeExit` fires inside the loop drain | |
| - **Generic**: `deno_core` doesn't know about specific events | |
| - **Fixes both**: `beforeunload` and `process.beforeExit` in one shot | |
| - **Simplifies CLI**: The loop in `cli/lib/worker.rs:896-909` collapses to a | |
| single `run_event_loop()` call since beforeunload dispatch moves into the callback | |
| ### Alternative (simpler but less correct) | |
| Make `evaluate_module()` not error when the promise is pending and the event loop | |
| drains. Let the CLI loop handle it by dispatching beforeunload and running the | |
| loop again. This is less clean because it splits the concern across layers. | |
| ## Key Files | |
| - `libs/core/runtime/jsruntime.rs` — event loop, `EventLoopPendingState`, `is_pending()`, `with_event_loop_promise()` | |
| - `libs/core/error.rs` — `PendingPromiseResolution` error | |
| - `runtime/worker.rs` — `evaluate_module()`, `dispatch_beforeunload_event()` | |
| - `runtime/js/99_main.js` — `dispatchBeforeUnloadEvent()` JS function | |
| - `cli/lib/worker.rs` — CLI main loop (`run()` method, lines 896-909) | |
| - `cli/worker.rs` — CLI worker loop | |
| ## Status | |
| Issue has been open since July 2022. Workaround exists: use `addEventListener("beforeunload", e => { e.preventDefault(); })` instead of promise resolution. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment