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",