From 51c9e1df638610b9ebf3d4f862d1dfeceb64c4c8 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 24 Sep 2026 06:45:08 +0800 Subject: [PATCH] fix(history): preserve each queued History API traversal Coalescing consecutive History API requests discarded intermediate same-document traversals and their events when joint traversal plans entered the shared queue. Keep these requests in order and derive the next index from the last queued step, while retaining Navigation API promise reuse. Apply top-level traversals to the page's default Window when an isolated realm initiated the request. Add local back/forward coverage across same- and cross-document entries, and verify every event and microtask turn in the existing history tests and fixture. Fixes: f8f46bf48 (fix(history): coordinate complete joint traversal plans) --- ...forward_same_turn_traverses_in_order.html} | 16 +-- moli-core/tests/history.rs | 10 +- .../src/domains/page/tests/navigation.rs | 109 ++++++++++++++++++ .../history_runtime/traversal.rs | 8 +- .../src/native_bridge/history_queue.rs | 29 +++-- .../page_vm/tests/history_traversal.rs | 42 +++++-- .../script_vm/tests/browser_api/traversal.rs | 19 ++- moli-test-support/src/lib.rs | 4 +- moli-test-support/src/routes_core.rs | 4 +- moli-test-support/src/server_routes.rs | 4 +- 10 files changed, 199 insertions(+), 46 deletions(-) rename moli-core/tests/fixtures/runtime/{history_back_forward_same_turn_coalesces.html => history_back_forward_same_turn_traverses_in_order.html} (69%) diff --git a/moli-core/tests/fixtures/runtime/history_back_forward_same_turn_coalesces.html b/moli-core/tests/fixtures/runtime/history_back_forward_same_turn_traverses_in_order.html similarity index 69% rename from moli-core/tests/fixtures/runtime/history_back_forward_same_turn_coalesces.html rename to moli-core/tests/fixtures/runtime/history_back_forward_same_turn_traverses_in_order.html index c4dc87946c..e9f6c58cdf 100644 --- a/moli-core/tests/fixtures/runtime/history_back_forward_same_turn_coalesces.html +++ b/moli-core/tests/fixtures/runtime/history_back_forward_same_turn_traverses_in_order.html @@ -1,6 +1,6 @@ - + diff --git a/moli-core/tests/history.rs b/moli-core/tests/history.rs index d5f5fc8d80..d828d1b955 100644 --- a/moli-core/tests/history.rs +++ b/moli-core/tests/history.rs @@ -1062,14 +1062,14 @@ async fn history_back_traversal_is_not_synchronous() -> Result<()> { } #[tokio::test] -async fn history_same_turn_back_then_forward_coalesces_without_popstate() -> Result<()> { +async fn history_same_turn_back_then_forward_preserves_both_popstates() -> Result<()> { let server = FixtureServer::spawn().await?; let browser = Browser::new(AppConfig::default())?; let mut page = browser - .fetch(&server.url("/compat/history-back-forward-same-turn-coalesces")) + .fetch(&server.url("/compat/history-back-forward-same-turn-traverses-in-order")) .await?; - wait_for_body_attribute(&browser, &mut page, "data-timeout-state", "2").await?; + wait_for_body_attribute(&browser, &mut page, "data-popstate-log", "1,2").await?; assert!( page.serialize_html_async() @@ -1083,7 +1083,7 @@ async fn history_same_turn_back_then_forward_coalesces_without_popstate() -> Res page.serialize_html_async() .await .unwrap() - .contains("data-timeout-state=\"2\""), + .contains("data-async-state=\"2\""), "{}", page.serialize_html_async().await.unwrap() ); @@ -1091,7 +1091,7 @@ async fn history_same_turn_back_then_forward_coalesces_without_popstate() -> Res page.serialize_html_async() .await .unwrap() - .contains("data-popstate-log=\"\""), + .contains("data-popstate-log=\"1,2\""), "{}", page.serialize_html_async().await.unwrap() ); diff --git a/moli-protocol/src/domains/page/tests/navigation.rs b/moli-protocol/src/domains/page/tests/navigation.rs index ba4642f346..b31a6abf90 100644 --- a/moli-protocol/src/domains/page/tests/navigation.rs +++ b/moli-protocol/src/domains/page/tests/navigation.rs @@ -599,6 +599,115 @@ async fn joint_history_traversal_cross_document_before_same_document() { assert_joint_history_multi_frame_traversal("a", false, false).await; } +#[tokio::test(flavor = "multi_thread")] +async fn queued_history_back_preserves_same_document_before_cross_document() { + assert_queued_history_same_then_cross_document(false).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn queued_history_forward_preserves_same_document_before_cross_document() { + assert_queued_history_same_then_cross_document(true).await; +} + +async fn assert_queued_history_same_then_cross_document(forward: bool) { + let app = axum::Router::new() + .fallback(|| async { axum::response::Html("history fixture") }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + ctx.process_async(json!({ + "id": 9350, "method": "Page.navigate", "sessionId": "SID-1", + "params": {"url": format!("http://{addr}/top")} + })) + .await; + assert!(take_response_by_id(&mut ctx, 9350)["error"].is_null()); + wait_until_message(&mut ctx, Some("SID-1"), "history fixture load", |message| { + message["method"] == "Page.domContentEventFired" + }) + .await; + joint_history_test_evaluate( + &mut ctx, + r#" + globalThis.frame = document.createElement('iframe'); + globalThis.withFrameLoad = action => new Promise(resolve => { + frame.onload = () => resolve(true); + action(); + }); + globalThis.withFrameHash = action => new Promise(resolve => { + frame.contentWindow.addEventListener('hashchange', () => resolve(true), {once:true}); + action(); + }); + frame.src = '/child?first'; + withFrameLoad(() => document.body.append(frame)); + "#, + ) + .await; + // Separate protocol commands start the next navigation outside the + // preceding load handler, preserving push rather than replace semantics. + let hash = "withFrameHash(() => frame.contentWindow.location.hash = '#same')"; + let load = "withFrameLoad(() => frame.contentWindow.location.search = '?second')"; + for script in if forward { [hash, load] } else { [load, hash] } { + joint_history_test_evaluate(&mut ctx, script).await; + } + if forward { + joint_history_test_evaluate( + &mut ctx, + "withFrameLoad(() => frame.contentWindow.history.back())", + ) + .await; + joint_history_test_evaluate( + &mut ctx, + "withFrameHash(() => frame.contentWindow.history.back())", + ) + .await; + } + let method = if forward { "forward" } else { "back" }; + let synchronous = joint_history_test_evaluate( + &mut ctx, + &format!( + r#" + globalThis.traversalEvents = []; + const snapshot = kind => [kind, frame.contentWindow.location.search, + frame.contentWindow.location.hash]; + frame.contentWindow.addEventListener('hashchange', event => {{ + const destination = new URL(event.newURL); + traversalEvents.push(['hash', destination.search, destination.hash]); + }}, {{once:true}}); + globalThis.traversalsFinished = new Promise(resolve => {{ + frame.onload = () => {{ + traversalEvents.push(snapshot('load')); + resolve(traversalEvents); + }}; + }}); + frame.contentWindow.history.{method}(); + frame.contentWindow.history.{method}(); + [frame.contentWindow.location.search, frame.contentWindow.location.hash]; + "# + ), + ) + .await; + assert_eq!( + synchronous, + if forward { + json!(["?first", ""]) + } else { + json!(["?second", "#same"]) + } + ); + let events = joint_history_test_evaluate(&mut ctx, "traversalsFinished").await; + assert_eq!( + events, + if forward { + json!([["hash", "?first", "#same"], ["load", "?second", "#same"]]) + } else { + json!([["hash", "?second", ""], ["load", "?first", ""]]) + } + ); + server.abort(); +} + #[tokio::test(flavor = "multi_thread")] async fn joint_history_traversal_same_document_before_cross_document() { assert_joint_history_multi_frame_traversal("b", false, false).await; diff --git a/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs b/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs index c1d10c4842..c548248036 100644 --- a/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs +++ b/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs @@ -589,7 +589,13 @@ fn history_traversal_target_window<'s>( ) -> Option> { match target.dispatch_scope() { crate::native_bridge::OwnerDispatchScope::Top => { - Some(scope.get_current_context().global(scope)) + // An isolated realm may initiate this task, but the Page's + // default Window owns the history being traversed. + Some( + host.page_default_context(scope) + .unwrap_or_else(|| scope.get_current_context()) + .global(scope), + ) } crate::native_bridge::OwnerDispatchScope::Child(child_handle) => { host.child_browsing_context_window_wrapper(scope, child_handle) diff --git a/moli-renderer-v8/src/native_bridge/history_queue.rs b/moli-renderer-v8/src/native_bridge/history_queue.rs index 7a2f3691cb..b54c12ba9a 100644 --- a/moli-renderer-v8/src/native_bridge/history_queue.rs +++ b/moli-renderer-v8/src/native_bridge/history_queue.rs @@ -118,6 +118,7 @@ impl HistoryQueueState { fn pending_history_traversal_target_index(&self, target: WindowTaskTarget) -> Option { self.pending_history_traversal_tasks .iter() + .rev() .find_map(|queued| match &queued.action { PendingHistoryTraversalAction::SameDocument(pending) if pending.target == target => @@ -140,18 +141,22 @@ impl HistoryQueueState { info: Option>, result: Option, ) -> Option { - if let Some(pending) = self - .pending_history_traversal_tasks - .iter_mut() - .find_map(|queued| match &mut queued.action { - PendingHistoryTraversalAction::SameDocument(pending) - if pending.target == target => - { - Some(pending) - } - PendingHistoryTraversalAction::SameDocument(_) - | PendingHistoryTraversalAction::ChildCrossDocument(_) => None, - }) + // History API requests are ordered steps, including a same-document + // traversal followed by a cross-document traversal. Only Navigation + // API requests with result promises can share a pending task. + if result.is_some() + && let Some(pending) = + self.pending_history_traversal_tasks + .iter_mut() + .find_map(|queued| match &mut queued.action { + PendingHistoryTraversalAction::SameDocument(pending) + if pending.target == target && !pending.results.is_empty() => + { + Some(pending) + } + PendingHistoryTraversalAction::SameDocument(_) + | PendingHistoryTraversalAction::ChildCrossDocument(_) => None, + }) { pending.target_index = target_index; pending.joint_step = joint_step; diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/history_traversal.rs b/moli-renderer-v8/src/runtime/page_vm/tests/history_traversal.rs index 969938113b..93a92f0a89 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/history_traversal.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/history_traversal.rs @@ -72,9 +72,10 @@ history.back(); } #[tokio::test(flavor = "current_thread")] -async fn history_back_coalesces_into_one_typed_turn_and_never_enters_page_timer() { +async fn history_back_preserves_each_typed_turn_and_never_enters_page_timer() { run_page_vm_async_test(async move { - let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); let document_url = Url::parse("https://example.com/history-typed-turn").unwrap(); let (mut page_vm, _resource_source, _owner_wake_rx) = page_vm_with_bound_task_sources_and_owner_wake(&loader, document_url); @@ -112,28 +113,45 @@ location.hash !page_vm.vm().has_ready_timeout(), "history traversal admission must not manufacture a PageTimer descriptor" ); - assert_eq!( - page_vm.vm().ms_to_next_timeout(), - None - ); + assert_eq!(page_vm.vm().ms_to_next_timeout(), None); assert!( page_vm - .run_exact_selected_page_task_for_test(PageSelectedTaskTestSelector::HistoryTraversal, &loader) + .run_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::HistoryTraversal, + &loader + ) .await?, - "the coalesced traversal should consume one production selected task" + "the first traversal should consume one production selected task" + ); + assert_eq!(page_vm.vm_mut().eval("location.hash")?, "#one"); + assert_eq!( + page_vm.vm_mut().eval("__historyTurnLog.join('|')")?, + "popstate:#one|microtask:#one", + "the selected traversal must checkpoint its event microtasks before the next turn" + ); + assert!( + page_vm + .run_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::HistoryTraversal, + &loader + ) + .await?, + "the second traversal should retain its own production selected task" ); assert_eq!(page_vm.vm_mut().eval("location.hash")?, ""); assert_eq!( page_vm.vm_mut().eval("__historyTurnLog.join('|')")?, - "popstate:|microtask:", - "the selected traversal must checkpoint its event microtasks before the next turn" + "popstate:#one|microtask:#one|popstate:|microtask:" ); assert!( !page_vm - .run_exact_selected_page_task_for_test(PageSelectedTaskTestSelector::HistoryTraversal, &loader) + .run_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::HistoryTraversal, + &loader + ) .await?, - "two pending history.back() calls for one LocalWindow must coalesce into one source position" + "the history source should drain after both ordered traversal tasks" ); Ok::<_, anyhow::Error>(()) }) diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/traversal.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/traversal.rs index 911dd2f21b..2743b0a4e3 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/traversal.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/traversal.rs @@ -1345,7 +1345,7 @@ result.finished.catch(error => __lmClosedHistoryRoute.push("finished:" + error.n } #[tokio::test] -async fn history_back_calls_from_default_and_isolated_world_coalesce_per_window() { +async fn history_back_calls_from_default_and_isolated_world_preserve_each_step() { let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); let mut vm = new_storage_page_task_executor_test_vm_with_loader("https://example.com/base", &loader); @@ -1379,9 +1379,24 @@ history.pushState(null, "", "#two"); .await .expect("default realm traversal should run") ); + assert_eq!( + vm.eval("location.hash") + .expect("first queued traversal should preserve its destination"), + "#one" + ); + assert!( + vm.run_one_history_traversal_executor_turn(&loader) + .await + .expect("isolated realm traversal should run after the default realm traversal") + ); + assert_eq!( + vm.eval("location.hash") + .expect("second queued traversal should reach the initial entry"), + "" + ); assert!( !vm.run_one_history_traversal_executor_turn(&loader) .await - .expect("history source should be drained after one Window traversal position") + .expect("history source should be drained after both traversals") ); } diff --git a/moli-test-support/src/lib.rs b/moli-test-support/src/lib.rs index e5278d8eac..dd5c80db96 100644 --- a/moli-test-support/src/lib.rs +++ b/moli-test-support/src/lib.rs @@ -172,8 +172,8 @@ const HISTORY_BACK_SAME_TURN_TRAVERSES_ASYNCHRONOUSLY_HTML: &str = include_str!( const HISTORY_BACK_IGNORES_PAGE_TAMPERED_QUEUE_MICROTASK_HTML: &str = include_str!( "../../moli-core/tests/fixtures/runtime/history_back_ignores_page_tampered_queue_microtask.html" ); -const HISTORY_BACK_FORWARD_SAME_TURN_COALESCES_HTML: &str = include_str!( - "../../moli-core/tests/fixtures/runtime/history_back_forward_same_turn_coalesces.html" +const HISTORY_BACK_FORWARD_SAME_TURN_TRAVERSES_IN_ORDER_HTML: &str = include_str!( + "../../moli-core/tests/fixtures/runtime/history_back_forward_same_turn_traverses_in_order.html" ); const HISTORY_STATE_MUTATION_DOES_NOT_MUTATE_STORED_SNAPSHOT_HTML: &str = include_str!( "../../moli-core/tests/fixtures/runtime/history_state_mutation_does_not_mutate_stored_snapshot.html" diff --git a/moli-test-support/src/routes_core.rs b/moli-test-support/src/routes_core.rs index 34f9f902de..3bea85c3d3 100644 --- a/moli-test-support/src/routes_core.rs +++ b/moli-test-support/src/routes_core.rs @@ -776,8 +776,8 @@ pub(super) async fn history_back_ignores_page_tampered_queue_microtask_page() -> Html(HISTORY_BACK_IGNORES_PAGE_TAMPERED_QUEUE_MICROTASK_HTML) } -pub(super) async fn history_back_forward_same_turn_coalesces_page() -> Html<&'static str> { - Html(HISTORY_BACK_FORWARD_SAME_TURN_COALESCES_HTML) +pub(super) async fn history_back_forward_same_turn_traverses_in_order_page() -> Html<&'static str> { + Html(HISTORY_BACK_FORWARD_SAME_TURN_TRAVERSES_IN_ORDER_HTML) } pub(super) async fn history_state_mutation_does_not_mutate_stored_snapshot_page() diff --git a/moli-test-support/src/server_routes.rs b/moli-test-support/src/server_routes.rs index 68e4fdc3f6..dad73c6095 100644 --- a/moli-test-support/src/server_routes.rs +++ b/moli-test-support/src/server_routes.rs @@ -235,8 +235,8 @@ pub(super) fn build_router() -> Router { get(history_back_ignores_page_tampered_queue_microtask_page), ) .route( - "/compat/history-back-forward-same-turn-coalesces", - get(history_back_forward_same_turn_coalesces_page), + "/compat/history-back-forward-same-turn-traverses-in-order", + get(history_back_forward_same_turn_traverses_in_order_page), ) .route( "/compat/history-state-mutation-does-not-mutate-stored-snapshot",