diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts
index 37d9bc45fcf..19cc4df29db 100644
--- a/packages/bun-types/bun.d.ts
+++ b/packages/bun-types/bun.d.ts
@@ -8478,6 +8478,30 @@ declare module "bun" {
modifiers?: Modifier[];
}
+ interface NavigateOptions {
+ /**
+ * When to consider the navigation finished:
+ *
+ * - `"load"` — wait for the window `load` event (all subresources
+ * finished). Matches Playwright's default.
+ * - `"domcontentloaded"` — wait for `DOMContentLoaded`. Use this for
+ * pages that hold a connection open (SSE, long-polling, a hung
+ * subresource) and so never fire `load`.
+ *
+ * With the Chrome backend this subscribes to CDP
+ * `Page.lifecycleEvent`. The WebKit backend has no separate
+ * DOMContentLoaded delegate hook, so `"domcontentloaded"` behaves
+ * like `"load"` there — use `timeout` to bound the wait instead.
+ * @default "load"
+ */
+ waitUntil?: "load" | "domcontentloaded";
+ /**
+ * Maximum time to wait in milliseconds. `0` disables the timeout.
+ * @default 30000
+ */
+ timeout?: number;
+ }
+
/**
* Browser backend selection.
*
@@ -8704,16 +8728,22 @@ declare module "bun" {
onNavigationFailed: ((error: Error) => void) | null;
/**
- * Navigate to a URL. Resolves when the main frame's load completes
- * (WKNavigationDelegate `didFinishNavigation`).
+ * Navigate to a URL. Resolves when the navigation reaches the
+ * `options.waitUntil` milestone (default: the window `load` event),
+ * or rejects after `options.timeout` ms.
*
* @example
* ```ts
* await view.navigate("https://example.com");
* await view.navigate("data:text/html,
hello
");
+ *
+ * // Page holds an SSE stream open — `load` never fires.
+ * await view.navigate("https://example.com/stream", {
+ * waitUntil: "domcontentloaded",
+ * });
* ```
*/
- navigate(url: string): Promise;
+ navigate(url: string, options?: WebView.NavigateOptions): Promise;
/**
* Run a JavaScript expression in the page's main frame and return the
@@ -8931,11 +8961,11 @@ declare module "bun" {
resize(width: number, height: number): Promise;
/** Navigate back in session history. */
- back(): Promise;
+ goBack(options?: WebView.NavigateOptions): Promise;
/** Navigate forward in session history. */
- forward(): Promise;
+ goForward(options?: WebView.NavigateOptions): Promise;
/** Reload the current page. */
- reload(): Promise;
+ reload(options?: WebView.NavigateOptions): Promise;
/**
* Close the view and release its WebContent process. After close,
diff --git a/src/runtime/webview/ChromeBackend.cpp b/src/runtime/webview/ChromeBackend.cpp
index 59292fd6df1..56e5c368ab2 100644
--- a/src/runtime/webview/ChromeBackend.cpp
+++ b/src/runtime/webview/ChromeBackend.cpp
@@ -710,6 +710,27 @@ void Transport::handleResponse(uint32_t id, std::span result, std::s
JSWebView* view = viewFor(entry.viewId);
if (!view) return; // user dropped both view and the awaited promise
+ // Navigate-slot entries carry the view's m_navGeneration at
+ // enqueue time. armNavTimeout rejects the navigate (bumping gen)
+ // without pruning m_pending, so a response for the abandoned
+ // navigation can arrive after a .catch() retry refilled
+ // m_pendingNavigate. Mismatch → this response is stale; settling
+ // would resolve/reject the RETRY's promise (or, for the attach
+ // chain, create a second tab whose events route to this view).
+ // Covers PageTitle, PageNavigate errorText, PageGetNavigationHistory
+ // boundary, and TargetCreateTarget→…→PageEnable in one place.
+ if (entry.navGen && entry.navGen != view->m_navGeneration) {
+ // TargetCreateTarget already ran in Chrome by the time we
+ // see the response — close the orphaned tab so it doesn't
+ // leak for process lifetime.
+ if (entry.method == Method::TargetCreateTarget && error.empty()) {
+ auto tid = jsonString(jsonField(result, { "targetId", 8 }));
+ if (!tid.empty())
+ send(0, Command(nextId(), "Target.closeTarget"_s).str("targetId"_s, WTF::String::fromUTF8(tid)));
+ }
+ return;
+ }
+
if (!error.empty()) {
// {"code":-32000,"message":"..."}
auto msgSlice = jsonString(jsonField(error, { "message", 7 }));
@@ -731,7 +752,7 @@ void Transport::handleResponse(uint32_t id, std::span result, std::s
auto tid = jsonString(jsonField(result, { "targetId", 8 }));
view->m_targetId = WTF::String::fromUTF8(tid);
uint32_t cid = nextId();
- m_pending.add(cid, Pending { Method::TargetAttachToTarget, entry.slot, entry.viewId });
+ m_pending.add(cid, Pending { Method::TargetAttachToTarget, entry.slot, entry.viewId, entry.navGen });
send(cid, Command(cid, "Target.attachToTarget"_s).str("targetId"_s, view->m_targetId).boolean("flatten"_s, true));
return;
}
@@ -750,7 +771,7 @@ void Transport::handleResponse(uint32_t id, std::span result, std::s
auto ss = view->m_sessionId.utf8();
std::span sidSpan(ss.data(), ss.length());
uint32_t cid = nextId();
- m_pending.add(cid, Pending { Method::PageEnable, entry.slot, entry.viewId });
+ m_pending.add(cid, Pending { Method::PageEnable, entry.slot, entry.viewId, entry.navGen });
send(cid, Command(cid, "Page.enable"_s, sidSpan));
return;
}
@@ -765,12 +786,24 @@ void Transport::handleResponse(uint32_t id, std::span result, std::s
uint32_t rid = nextId();
send(0, Command(rid, "Runtime.enable"_s, sidSpan));
+ // Page.setLifecycleEventsEnabled — fire-and-forget. Chrome then
+ // emits Page.lifecycleEvent {frameId, loaderId, name} for commit/
+ // DOMContentLoaded/load/networkIdle. navigate({waitUntil:
+ // 'domcontentloaded'}) settles on that instead of loadEventFired,
+ // so pages that never fire `load` (SSE, long-poll, a hung
+ // subresource) don't hang the await. Enabling replays the current
+ // document's events, but m_frameId/m_loaderId are unset until the
+ // USER url's frameNavigated, so the about:blank replay never
+ // matches.
+ uint32_t lid = nextId();
+ send(0, Command(lid, "Page.setLifecycleEventsEnabled"_s, sidSpan).boolean("enabled"_s, true));
+
// Page.navigate with the url stashed by the first navigate() call.
// The response confirms the navigation STARTED; Page.loadEventFired
// confirms completion. We keep the pending entry alive for the
// response so errorText rejects the right slot.
uint32_t cid = nextId();
- m_pending.add(cid, Pending { Method::PageNavigate, entry.slot, entry.viewId });
+ m_pending.add(cid, Pending { Method::PageNavigate, entry.slot, entry.viewId, entry.navGen });
send(cid, Command(cid, "Page.navigate"_s, sidSpan).str("url"_s, view->m_pendingChromeNavigateUrl));
view->m_pendingChromeNavigateUrl = WTF::String();
return;
@@ -837,7 +870,7 @@ void Transport::handleResponse(uint32_t id, std::span result, std::s
int32_t entryId = elem ? elem->getInteger("id"_s).value_or(0) : 0;
// Chain into navigateToHistoryEntry. Page.loadEventFired settles.
uint32_t cid = nextId();
- m_pending.add(cid, Pending { Method::PageNavigateToHistoryEntry, entry.slot, entry.viewId });
+ m_pending.add(cid, Pending { Method::PageNavigateToHistoryEntry, entry.slot, entry.viewId, entry.navGen });
send(cid, Command(cid, "Page.navigateToHistoryEntry"_s, sidSpan(view->m_sessionId)).num("entryId"_s, entryId));
return;
}
@@ -1091,14 +1124,47 @@ void Transport::handleEvent(std::span method, std::span
return;
}
+ // Chained from lifecycleEvent/loadEventFired: Runtime.evaluate(
+ // "document.title") so view.title is populated when navigate()
+ // resolves — matches WKWebView's NavDone which packs url+title in
+ // one reply. One extra roundtrip (~1ms), but the user-visible
+ // guarantee (`await view.navigate(); view.title` works) is worth
+ // it. PageTitle's response handler is the settle point.
+ //
+ // Sets m_navTitleChained: on a fast page, lifecycleEvent(DCL),
+ // lifecycleEvent(load) and loadEventFired can all arrive before
+ // the first PageTitle response — each would otherwise enqueue a
+ // duplicate PageTitle whose LATER response could settle a
+ // subsequent navigate()'s promise. After the first call, further
+ // triggers for the same document see the flag and drop. Cleared
+ // by beginChromeNavigation() for the next navigation. m_loaderId
+ // is left populated so loadEventFired can still tell THIS
+ // document's event from a stale one (m_loaderId empty = a new
+ // navigation started and hasn't committed yet).
+ auto chainTitle = [&]() {
+ view->m_navTitleChained = true;
+ uint32_t tid = nextId();
+ m_pending.add(tid, Pending { Method::PageTitle, PendingSlot::Navigate, view->m_viewId, view->m_navGeneration });
+ send(tid, Command(tid, "Runtime.evaluate"_s, sidSpan(view->m_sessionId)).str("expression"_s, "document.title"_s).boolean("returnByValue"_s, true));
+ };
+
// Page.frameNavigated — commit. Update m_url and fire onNavigated.
// Same timing as WKWebView's NavDone (didFinishNavigation): the URL is
// now the new document, resources may still be loading.
if (method.size() == 19 && memcmp(method.data(), "Page.frameNavigated", 19) == 0) {
auto frame = jsonField(params, { "frame", 5 });
+ // Subframe commits have frame.parentId set; only the main frame
+ // updates m_url and the lifecycle loaderId. (frameNavigated for
+ // subframes is rare without Page.setFrameTree, but an