Skip to content

webview: navigate() accepts {waitUntil, timeout}#30645

Open
robobun wants to merge 14 commits into
mainfrom
farm/46eec509/webview-navigate-waituntil-timeout
Open

webview: navigate() accepts {waitUntil, timeout}#30645
robobun wants to merge 14 commits into
mainfrom
farm/46eec509/webview-navigate-waituntil-timeout

Conversation

@robobun

@robobun robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

await view.navigate(url) settles only on Page.loadEventFired. A page that holds a subresource connection open (SSE, long-poll, a hung <img>) never fires the window load event, so the await hangs forever with no way to bound it.

Change

navigate(), reload(), goBack(), goForward() now accept a second options argument:

await view.navigate(url, {
  waitUntil: "load" | "domcontentloaded",  // default "load"
  timeout: 30000,                           // ms; 0 disables
});

waitUntil — Chrome backend

Page.setLifecycleEventsEnabled({enabled:true}) is sent in the attach chain right after Page.enable. Page.frameNavigated stashes the main frame's id + loaderId; the new Page.lifecycleEvent handler settles the Navigate slot when frameId/loaderId match and name == "DOMContentLoaded" (or "load" — satisfies domcontentloaded too, for same-document navs that skip DCL). Subframe lifecycle events and the about:blank replay that setLifecycleEventsEnabled triggers never match because m_frameId/m_loaderId aren't set until the user's URL commits.

waitUntil:"load" (the default) keeps using Page.loadEventFired — no behavior change.

waitUntil — WebKit backend

WKNavigationDelegate exposes only didFinishNavigation (= load). "domcontentloaded" is accepted but degrades to "load"; the type docs say so. Use timeout to bound the wait on WebKit.

timeout

Parent-side WTF::RunLoop::dispatchAfter (backed by Bun's WTFTimerEventLoopTimer). The closure captures only {viewId, backend, navGeneration}; each navigation start and each Navigate-slot settle bumps m_navGeneration, so a stale timer fires into a generation mismatch and no-ops. No explicit cancel, no RefPtr<DispatchTimer> lifetime dance. timeout: 0 skips arming. Works on both backends.

Tests

  • test/js/bun/webview/webview-navigate-options.test.ts (new): a mock CDP WebSocket server speaks just enough protocol to drive the attach chain and emit Page.lifecycleEvent/loadEventFired on demand. 6 tests cover DCL-settles-without-load, default-load-still-works, reload-with-DCL, timeout-rejects, stale-timer-doesn't-reject-next-nav, option validation. Runs on every CI lane without a Chrome binary.
  • test/js/bun/webview/webview-chrome.test.ts: 5 new real-Chrome tests (todo-gated where Chrome is absent) — a Bun.serve that holds an <img> response open forever, subframe-filtering with <iframe srcdoc>, option validation.

Gate

git stash -- src/ packages/ && bun bd test webview-navigate-options.test.ts
  → 5 fail, 1 pass (the backward-compat 'load' test)
git stash pop && bun bd test webview-navigate-options.test.ts
  → 6 pass

@robobun robobun requested a review from alii as a code owner May 13, 2026 23:15
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

WebView navigation methods accept optional waitUntil and timeout options; lifecycle events (DOMContentLoaded or load) drive promise settlement, parent-side timeouts can reject navigations, and Chrome backend matches lifecycle events to committed frame/loader IDs.

Changes

WebView Navigation with waitUntil and Timeout Support

Layer / File(s) Summary
Navigation Options Contract and Type Definitions
packages/bun-types/bun.d.ts, src/runtime/webview/JSWebView.h
WebView.NavigateOptions interface with waitUntil and timeout. NavWaitUntil enum and JSWebView fields (m_navWaitUntil, m_navGeneration, m_frameId, m_loaderId) added; method signatures updated to accept these parameters.
Promise Settlement and Timeout Mechanism
src/runtime/webview/JSWebView.cpp
Implements generation-gated timeout arming (armNavTimeout, beginChromeNavigation) and dispatchAfter-based timeout rejection; settleSlot increments m_navGeneration to invalidate stale timers.
Chrome Backend Lifecycle Event Handling
src/runtime/webview/ChromeBackend.cpp
Enables Page.lifecycleEvent, records committed main-frame frameId/loaderId, matches lifecycle events to the pending navigation, and centralizes title-fetch via chainTitle() for settlement.
JavaScript API Binding, Parsing, and Constructor Wiring
src/runtime/webview/JSWebViewPrototype.cpp, src/runtime/webview/JSWebViewConstructor.cpp, packages/bun-types/bun.d.ts
Adds parseNavOptions (defaults waitUntil to "load"), updates WebView.prototype.navigate/goBack/goForward/reload to accept optional options and forward parsed waitUntil/timeout to native methods, and makes constructor initial navigations use NavWaitUntil::Load with timeoutMs=0.
Chrome Backend Integration Tests
test/js/bun/webview/webview-chrome.test.ts
Adds tests covering navigate/reload behavior with waitUntil and timeout against the Chrome backend, including hanging subresource scenarios and option validation.
Mock CDP Tests with Full Lifecycle Coverage
test/js/bun/webview/webview-navigate-options.test.ts
Subprocess-isolated mock CDP server tests that exercise DOMContentLoaded vs load settling, silent/timeouts, stale-event and stale-timer regressions, back-to-back navigate behavior, and synchronous option validation error cases.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main change: adding support for waitUntil and timeout options to the navigate() method and related navigation methods.
Description check ✅ Passed The PR description comprehensively addresses the template requirements with detailed Problem and Change sections explaining motivation, implementation details for both backends, timeout behavior, and thorough test coverage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webview/JSWebView.cpp`:
- Around line 320-359: The comment says timeouts are armed for symmetry but in
JSWebView::goBack, JSWebView::goForward, and JSWebView::reload the WK_DISPATCH
path returns directly so armNavTimeout(g, timeoutMs) is never called; either
update the comment to state WebKit doesn't arm the timeout, or change the
WK_DISPATCH branches to mirror navigate by saving the promise, calling
armNavTimeout(g, timeoutMs), then returning that promise (i.e., follow
navigate's pattern) so behavior matches the comment; reference the WK_DISPATCH
macro, armNavTimeout, and the navigate implementation when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 82a744d8-3bde-42f2-ae9d-10dca0d6447d

📥 Commits

Reviewing files that changed from the base of the PR and between b9c757b and a6d1fe0.

📒 Files selected for processing (8)
  • packages/bun-types/bun.d.ts
  • src/runtime/webview/ChromeBackend.cpp
  • src/runtime/webview/JSWebView.cpp
  • src/runtime/webview/JSWebView.h
  • src/runtime/webview/JSWebViewConstructor.cpp
  • src/runtime/webview/JSWebViewPrototype.cpp
  • test/js/bun/webview/webview-chrome.test.ts
  • test/js/bun/webview/webview-navigate-options.test.ts

Comment thread src/runtime/webview/JSWebView.cpp
Comment thread src/runtime/webview/ChromeBackend.cpp
Comment thread packages/bun-types/bun.d.ts Outdated
Comment thread src/runtime/webview/JSWebView.cpp
Comment thread src/runtime/webview/JSWebViewPrototype.cpp Outdated
Comment thread src/runtime/webview/JSWebViewConstructor.cpp Outdated
Comment thread src/runtime/webview/JSWebViewPrototype.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webview/JSWebViewPrototype.cpp`:
- Around line 243-255: The code currently coerces non-number JS values for
options.timeout via toNumber(); change it to reject any non-number (except
undefined) the same way waitUntil does: after retrieving t (from o->get(...,
"timeout")), if t.isUndefined() skip, otherwise require t.isNumber() and if not
call Bun::ERR::INVALID_ARG_VALUE (using "options.timeout"_s and t) and return
false; then call t.toNumber(g), check RETURN_IF_EXCEPTION, validate finiteness
and non-negativity, and clamp into timeout as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5a93f631-b47b-421c-b2f8-d91884de81cd

📥 Commits

Reviewing files that changed from the base of the PR and between a6d1fe0 and 2092e2f.

📒 Files selected for processing (6)
  • packages/bun-types/bun.d.ts
  • src/runtime/webview/ChromeBackend.cpp
  • src/runtime/webview/JSWebView.cpp
  • src/runtime/webview/JSWebViewConstructor.cpp
  • src/runtime/webview/JSWebViewPrototype.cpp
  • test/js/bun/webview/webview-navigate-options.test.ts

Comment thread src/runtime/webview/JSWebViewPrototype.cpp Outdated
Comment thread src/runtime/webview/ChromeBackend.cpp
Comment thread src/runtime/webview/JSWebViewConstructor.cpp
Comment thread src/runtime/webview/JSWebView.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webview/ChromeBackend.cpp`:
- Around line 1204-1206: The branch handling the "Page.loadEventFired" event
must not clear view->m_loading for a stale/cleared loader; modify the check so
m_loading is only set to false when the current loader is still active (e.g.,
require that view->m_loaderId is non-empty or otherwise matches the event's
loader id) before clearing m_loading and calling chainTitle; update the
Page.loadEventFired handling in ChromeBackend.cpp to gate setting
view->m_loading = false behind the loader-id validity check (and then call
chainTitle() only as before when view->m_pendingNavigate is true).

In `@src/runtime/webview/JSWebView.cpp`:
- Around line 348-377: The Chrome branches for JSWebView::reload and
JSWebView::goBack/JSWebView::goForward must set the view loading flag the same
way navigate() does: set m_loading (or call the same setter navigate() uses) to
true when beginning a Chrome navigation (i.e. before calling
CDP::Ops::reload/goBack/goForward and before armNavTimeout), and for the
history-path that queries Page.getNavigationHistory ensure you set m_loading =
true when the history response confirms there is an entry to traverse to; this
will keep view.loading in sync with in-flight Chrome reload/history navigations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5482aa6a-e445-4dee-a7d2-5086baecfde9

📥 Commits

Reviewing files that changed from the base of the PR and between 2092e2f and b52522d.

📒 Files selected for processing (4)
  • src/runtime/webview/ChromeBackend.cpp
  • src/runtime/webview/JSWebView.cpp
  • src/runtime/webview/JSWebViewConstructor.cpp
  • test/js/bun/webview/webview-navigate-options.test.ts

Comment thread src/runtime/webview/ChromeBackend.cpp Outdated
Comment thread src/runtime/webview/JSWebView.cpp
Comment thread test/js/bun/webview/webview-navigate-options.test.ts Outdated
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status — all CI lanes green, ready to merge

All review threads resolved. 8/8 mock-CDP tests (test.concurrent) + full webview suite pass locally; gate passes. CodeRabbit auto-paused.

Branch rebased onto main@11a2e2c → build #54352 (3534b57). Diff unchanged: 9 files, all src/runtime/webview/ + packages/bun-types/bun.d.ts + two webview test files.

CI — #54352 final

Every individual BuildKite lane passes. gh pr checks shows zero per-lane failures and zero pending. The four jobs that initially Expired (darwin-aarch64 build-cpp/build-rust, darwin-14-x64-test, package-binary-size — agent-scheduling infra) were retried and passed.

Only the aggregate buildkite/bun check is stuck at "failed" because BuildKite does not flip build-level state back to green after a retry. The sole annotation is test/js/web/fetch/fetch-tcp-keepalive.test.ts flaking on 10 Linux lanes — it passed on retry on all 10 (lanes green). That test was introduced on main by #30627/#30640 and came in via the rebase; this diff touches no fetch/socket/keepalive code.

No webview test appears in any BuildKite annotation across builds #54113–54352. webview-navigate-options.test.ts passed 8/8 on every lane it ran on. One ci: retrigger already spent (9031bc3).

This is mergeable as-is — every lane is green; only the sticky aggregate status needs an override.

What's in

  • navigate/reload/goBack/goForward accept {waitUntil: 'load'|'domcontentloaded', timeout}.
  • Chrome: Page.setLifecycleEventsEnabled; settle on matching Page.lifecycleEvent (frameId/loaderId-gated) for 'domcontentloaded', Page.loadEventFired for 'load'.
  • Parent-side RunLoop::dispatchAfter timeout, generation-counter-invalidated.
  • All Navigate-slot CDP responses generation-gated via Pending.navGen: a timeout-then-retry can't be settled by any stale response (PageNavigate errorText, PageGetNavigationHistory boundary, PageTitle, attach chain). Stale TargetCreateTarget closes its orphaned tab.
  • Event-side: m_loaderId (cleared per-nav, repopulated by frameNavigated) + m_navTitleChained (dedupe) gate lifecycleEvent/loadEventFired.

Deferred — maintainer scope call

  • WebKit timeout doesn't reset host m_navPending — needs new Op::CancelNavigate IPC. WebKit timeout is documented best-effort; view.close() works.
  • Stale frameNavigated can re-arm the event gate in a sub-ms timeout-vs-commit race. Needs expected-loaderId from Page.navigate's response (Playwright's LifecycleWatcher approach), which Page.reload/navigateToHistoryEntry don't provide.
  • Stale TargetAttachToTarget doesn't close its tab in the one-RTT post-createTarget window. Needs targetId carried in Pending.
  • goBack/goForward don't set m_loading=true; a boundary-no-op after a DCL-settled nav can leave view.loading stuck. reload() does set it.
  • view.title no longer auto-refreshes on uninitiated page-side navs. view.url still does via frameNavigated.

Comment thread src/runtime/webview/ChromeBackend.cpp
Comment thread src/runtime/webview/JSWebViewPrototype.cpp
Comment thread src/runtime/webview/ChromeBackend.cpp
Comment thread test/js/bun/webview/webview-navigate-options.test.ts Outdated
Comment thread src/runtime/webview/ChromeBackend.cpp Outdated
Comment thread src/runtime/webview/ChromeBackend.cpp
Comment thread src/runtime/webview/ChromeBackend.cpp
Comment thread src/runtime/webview/JSWebView.cpp
Comment thread test/js/bun/webview/webview-navigate-options.test.ts
robobun and others added 14 commits May 14, 2026 17:30
navigate() (and reload/goBack/goForward) previously settled only on
Page.loadEventFired. A page that holds a subresource connection open
(SSE, long-poll, a hung image) never fires `load`, so `await
view.navigate()` hung forever.

Now accepts Playwright-shaped options:

  await view.navigate(url, {
    waitUntil: "load" | "domcontentloaded",  // default "load"
    timeout: 30000,                           // ms; 0 disables
  });

Chrome backend: Page.setLifecycleEventsEnabled is sent in the attach
chain; Page.frameNavigated stashes the main frame id + loaderId; the
Page.lifecycleEvent handler settles the Navigate slot when
frameId/loaderId match and name == DOMContentLoaded (or load).
Subframe events and the about:blank replay never match because
m_frameId/m_loaderId are only set after the user URL commits.

Timeout is a parent-side RunLoop::dispatchAfter timer. It captures a
per-view navigation generation counter; settleSlot bumps the counter,
so a late fire no-ops instead of rejecting the next navigation. No
explicit cancel, no RefPtr lifetime dance.

WebKit backend: parses the same options. `timeout` applies (same
parent-side timer). WKNavigationDelegate has no DOMContentLoaded
hook, so `domcontentloaded` degrades to `load` there — noted in the
type docs.

Tests: new webview-navigate-options.test.ts drives a mock CDP
WebSocket server so the full lifecycleEvent path is exercised without
a Chrome binary; webview-chrome.test.ts gets real-Chrome coverage
(todo-gated where Chrome is absent).
The WebKit branch uses the Misc slot and WK_DISPATCH returns directly;
armNavTimeout is intentionally not called there (it would no-op on an
empty Navigate slot). Comment now says so instead of implying symmetry.
…eneration bump

Three review findings:

1) A navigate({waitUntil:'domcontentloaded'}) can settle before the
   page's `load` fires. If a second navigate() starts in that window,
   the first page's trailing Page.lifecycleEvent(load)/loadEventFired
   would pass the gate (the old loaderId was still cached) and
   chainTitle() would settle the SECOND navigate's promise with the
   FIRST page's title. Fix: clear m_loaderId at the start of every
   Chrome navigation (beginChromeNavigation helper); lifecycleEvent's
   loaderId check and loadEventFired's !m_loaderId.isEmpty() guard
   then drop the stale events. loadEventFired also gates on
   m_pendingNavigate so an uninitiated load can't enqueue a PageTitle
   that races a later navigate. New mock-CDP regression test.

2) bun.d.ts declared back()/forward() but the runtime prototype
   registers goBack()/goForward(). Pre-existing, but this PR touched
   those lines. Renamed to match runtime.

3) On WebKit, goBack/goForward/reload use the Misc slot and can run
   concurrently with a navigate() on the Navigate slot. The
   unconditional ++m_navGeneration before WK_DISPATCH was silently
   disarming that concurrent navigate's timeout. Moved the waitUntil/
   generation/loaderId setup into the Chrome branch only (via
   beginChromeNavigation); WebKit leaves Navigate-slot state alone.
- uint32 ms is ~49.7 days, not ~24.8 (that's INT32_MAX ms). Comment-only.
- Constructor's initial navigate (for {url}) drops the returned promise,
  so a 30s timeout would surface as unhandledrejection on slow pages.
  Pass timeoutMs=0 there instead — the user's first explicit await
  serializes behind it anyway.
- goBack/goForward/reload: re-check m_closed after parseNavOptions(),
  same as navigate() — option getters can call user code that closes
  the view.
…imeout

chainTitle() now clears m_loaderId after enqueuing the PageTitle
fetch. On a fast page with waitUntil:'domcontentloaded',
lifecycleEvent(DCL), lifecycleEvent(load) and loadEventFired can
all arrive before the first PageTitle response — each was passing
the gate and enqueuing a duplicate PageTitle, and a duplicate
response could settle the NEXT navigate()'s promise with the
previous page's title. With the clear, only the first trigger
chains; subsequent ones for the same document hit the empty-
m_loaderId gate. New mock-CDP regression test.

Also: the timeout closure no longer clears m_loading — it tracks
the REAL load state (flipped by loadEventFired), and a timeout is
the user giving up on the wait, not the browser finishing. And the
WebKit constructor comment no longer claims equivalence with
view.navigate(url) (it passes timeout:0, navigate() defaults 30s).
…omments

Split the chainTitle() dedupe from the stale-event gate:
m_navTitleChained (set by chainTitle, cleared by
beginChromeNavigation) prevents duplicate PageTitle enqueues;
m_loaderId stays populated until the NEXT navigation clears it, so
Page.loadEventFired can distinguish "this document just finished
loading" (m_loaderId non-empty → clear m_loading) from "a prior
document's trailing load arrived after a new nav started"
(m_loaderId empty → drop). The stale-load test now also asserts
view.loading stays true.

reload() now sets m_loading = true on Chrome (pre-existing omission
in code this PR was already editing). goBack/goForward left as-is
since the at-boundary case resolves without navigating.

Two stale test comments corrected.
A PageTitle request enqueued by chainTitle() can outlive its
navigation: if the navigate's timeout fires (or the user retries
from .catch()) before the Runtime.evaluate response arrives, the
PageTitle handler's settle(Navigate) would hit the NEW navigate's
promise. m_navTitleChained already distinguishes the two —
chainTitle() set it, the new beginChromeNavigation() cleared it —
so gate the response handler (both success and the generic CDP
error path) on it. A stale PageTitle now drops instead of
mis-settling.

Also: ceil options.timeout before the uint32 cast so values in
(0,1) arm a 1ms timer instead of truncating to the 0="disable"
sentinel. And one more stale test comment from the 742d651b6ea944 refactor.
Pending now carries navGen (the view's m_navGeneration at enqueue
time, 0 = ungated). handleResponse drops a Navigate-slot response
whose navGen doesn't match the current generation — covers every
in-flight request armNavTimeout can orphan (PageNavigate errorText,
PageGetNavigationHistory boundary, PageTitle, and the whole
TargetCreateTarget → AttachToTarget → PageEnable chain) in one
place, instead of the PageTitle-only m_navTitleChained shortcut.
Chained responses carry entry.navGen forward.

For a stale TargetCreateTarget response (first-navigate attach
chain timed out and user retried), Chrome already created the tab;
send Target.closeTarget(targetId) so it doesn't leak.
@robobun robobun force-pushed the farm/46eec509/webview-navigate-waituntil-timeout branch from 16c6719 to 3534b57 Compare May 14, 2026 17:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant