fix: skip wait_for_lifecycle on same-document navigation (#1059)

Chrome returns loader_id: None for same-document navigations (e.g., hash
routing in SPAs). In these cases, Page.loadEventFired never fires, causing
wait_for_lifecycle to hang forever.

The fix checks nav_result.loader_id.is_some() before waiting for lifecycle
events. Also added regression test e2e_navigate_same_url_twice_should_not_hang.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hechang27-sprt
2026-03-29 12:25:37 -06:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 369f48752a
commit 312db04e5e
2 changed files with 89 additions and 2 deletions
+7 -2
View File
@@ -492,8 +492,13 @@ impl BrowserManager {
return Err(format!("Navigation failed: {}", error_text));
}
self.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
.await?;
// Only wait for lifecycle events if Chrome created a new loader (full navigation).
// If loader_id is None, it was a same-document navigation (e.g., hash routing)
// which does not fire Page.loadEventFired or Page.domContentEventFired.
if nav_result.loader_id.is_some() {
self.wait_for_lifecycle(wait_until, &session_id, &mut lifecycle_rx)
.await?;
}
let page_url = self.get_url().await.unwrap_or_else(|_| url.to_string());
let title = self.get_title().await.unwrap_or_default();
+82
View File
@@ -1073,6 +1073,88 @@ async fn e2e_wait() {
assert_success(&resp);
}
// ---------------------------------------------------------------------------
// Same-document navigation regression test
// ---------------------------------------------------------------------------
//
// Chrome may perform a same-document navigation when it determines the target
// URL is the same document as the current page (ignoring fragment). This
// causes Page.loadEventFired to not fire, making wait_for_lifecycle
// hang forever waiting for an event that never comes.
//
// The fix checks loader_id in the Page.navigate response - if None,
// it's a same-document navigation and we skip waiting for lifecycle events.
#[tokio::test]
#[ignore]
async fn e2e_navigate_same_url_twice_should_not_hang() {
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate to about:blank first to start from a known state
let resp = execute_command(
&json!({ "id": "2", "action": "navigate", "url": "about:blank" }),
&mut state,
)
.await;
assert_success(&resp);
// Create a simple HTML page that changes its own URL via history.pushState
// This simulates SPA routing behavior which triggers same-document navigation
let base_page = "data:text/html,<html><body><script>
// On first load, change URL via pushState without navigation
history.pushState({}, '', '/#/home');
</script><h1>Test</h1></body></html>";
// Navigate to the page (first time)
let resp = execute_command(
&json!({ "id": "3", "action": "navigate", "url": base_page }),
&mut state,
)
.await;
assert_success(&resp);
// Verify URL changed due to pushState
let resp = execute_command(&json!({ "id": "4", "action": "url" }), &mut state).await;
assert_success(&resp);
let url_after_push = get_data(&resp)["url"].as_str().unwrap();
// URL should have changed to include /#/home due to pushState
assert!(
url_after_push.contains("/%23/home") || url_after_push.contains("/#/home"),
"URL should have changed via pushState, got: {}",
url_after_push
);
// Navigate to the SAME base URL again
// Without fix: Chrome may do same-document nav, wait_for_lifecycle hangs
// With fix: We detect loader_id is None and skip waiting
let start = std::time::Instant::now();
let resp = execute_command(
&json!({ "id": "5", "action": "navigate", "url": base_page }),
&mut state,
)
.await;
let elapsed = start.elapsed().as_secs();
// Should complete quickly (< 5 seconds) without hanging
// Without fix, this times out after 25 seconds (default_timeout_ms)
assert!(
elapsed < 5,
"Second navigation should not hang, but took {}s",
elapsed
);
assert_success(&resp);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
// ---------------------------------------------------------------------------
// Viewport with deviceScaleFactor (retina)
// ---------------------------------------------------------------------------