diff --git a/moli-page-types/src/navigation_history.rs b/moli-page-types/src/navigation_history.rs index b2144fef9..2147b66cf 100644 --- a/moli-page-types/src/navigation_history.rs +++ b/moli-page-types/src/navigation_history.rs @@ -310,7 +310,10 @@ pub fn apply_child_browsing_context_navigation_to_entry_seed( history_state_json: Option, navigation_state_json: Option, ) { - seed.session_history.commit = crate::SessionHistoryCommit::Push; + seed.session_history = crate::SessionHistorySeed { + commit: crate::SessionHistoryCommit::Push, + ..Default::default() + }; let next_index = seed.current_index + 1; let current_navigation_index = seed .entries @@ -368,7 +371,10 @@ pub fn replace_child_browsing_context_navigation_in_entry_seed( history_state_json: Option, navigation_state_json: Option, ) { - seed.session_history.commit = crate::SessionHistoryCommit::Replace; + seed.session_history = crate::SessionHistorySeed { + commit: crate::SessionHistoryCommit::Replace, + ..Default::default() + }; let current_index = seed.current_index; let current_navigation_index = seed .entries @@ -412,7 +418,10 @@ pub fn replace_child_browsing_context_navigation_in_entry_seed( pub fn apply_child_browsing_context_javascript_url_navigation_to_entry_seed( seed: &mut NavigationHistoryEntrySeed, ) { - seed.session_history.commit = crate::SessionHistoryCommit::Replace; + seed.session_history = crate::SessionHistorySeed { + commit: crate::SessionHistoryCommit::Replace, + ..Default::default() + }; let current_index = seed.current_index; let Some(previous_entry) = seed .entries diff --git a/moli-page-types/src/session_history.rs b/moli-page-types/src/session_history.rs index e091d1126..e9ecb9de2 100644 --- a/moli-page-types/src/session_history.rs +++ b/moli-page-types/src/session_history.rs @@ -94,6 +94,9 @@ pub struct SessionHistorySeed { pub commit: SessionHistoryCommit, pub traversable: Option>, pub target_step: Option, + /// A cross-Document participant of an already accepted joint traversal. + /// Its load may materialize this entry, but may not move the shared cursor. + pub admitted_entry: Option, } /// One already-committed traversable mutation, published in renderer FIFO diff --git a/moli-protocol/src/conn/browser_context/target_session_owner.rs b/moli-protocol/src/conn/browser_context/target_session_owner.rs index 09b9978a8..e2573f3d1 100644 --- a/moli-protocol/src/conn/browser_context/target_session_owner.rs +++ b/moli-protocol/src/conn/browser_context/target_session_owner.rs @@ -2390,6 +2390,15 @@ impl CdpConnection { .owner_state .navigation_history_state .snapshot(); + if (index, entries.len()) != (update.position.index(), update.position.length()) { + tracing::error!( + browser_index = index, + browser_length = entries.len(), + renderer_index = update.position.index(), + renderer_length = update.position.length(), + "browser and renderer joint history positions diverged" + ); + } debug_assert_eq!( (index, entries.len()), (update.position.index(), update.position.length()), diff --git a/moli-protocol/src/domains/page/tests/navigation.rs b/moli-protocol/src/domains/page/tests/navigation.rs index b1f866782..f780fe00f 100644 --- a/moli-protocol/src/domains/page/tests/navigation.rs +++ b/moli-protocol/src/domains/page/tests/navigation.rs @@ -484,6 +484,430 @@ async fn joint_session_history_syncs_child_steps_cursor_and_reload_bootstrap() { server.abort(); } +#[tokio::test(flavor = "multi_thread")] +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 joint_history_traversal_same_document_before_cross_document() { + assert_joint_history_multi_frame_traversal("b", false, false).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn joint_history_traversal_schedules_both_cross_document_frames() { + assert_joint_history_multi_frame_traversal("a", true, false).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn joint_history_traversal_reentrant_admission_cancels_every_participant() { + for cross in ["a", "b"] { + assert_joint_history_multi_frame_traversal(cross, false, true).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn joint_history_traversal_precommit_holds_every_participant_and_browser_cursor() { + assert_joint_history_precommit("resolve").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn joint_history_traversal_precommit_rejection_aborts_the_whole_step() { + assert_joint_history_precommit("reject").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn joint_history_traversal_precommit_cannot_restore_a_pruned_step() { + assert_joint_history_precommit("prune").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn joint_history_traversal_late_response_cannot_overwrite_a_successor_entry() { + assert_joint_history_late_response(false).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn joint_history_traversal_outgoing_state_update_keeps_the_accepted_destination() { + assert_joint_history_late_response(true).await; +} + +async fn assert_joint_history_late_response(update_state: bool) { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let requests = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(tokio::sync::Notify::new()); + let handler_requests = requests.clone(); + let handler_release = release.clone(); + let app = axum::Router::new().route( + "/{page}", + axum::routing::get( + move |axum::extract::Path(name): axum::extract::Path| { + let requests = handler_requests.clone(); + let release = handler_release.clone(); + async move { + if name == "a0" && requests.fetch_add(1, Ordering::SeqCst) > 0 { + release.notified().await; + } + ( + [("content-type", "text/html"), ("cache-control", "no-store")], + format!("{name}"), + ) + } + }, + ), + ); + 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":9343,"method":"Page.navigate","sessionId":"SID-1","params":{"url":format!("http://{addr}/top")}})).await; + assert!(take_response_by_id(&mut ctx, 9343)["error"].is_null()); + wait_until_message( + &mut ctx, + Some("SID-1"), + "late traversal fixture load", + |message| message["method"] == "Page.domContentEventFired", + ) + .await; + joint_history_test_evaluate(&mut ctx, r#"(async()=>{ + history.replaceState('top0',''); + globalThis.frame=document.createElement('iframe');frame.src='/a0'; + await new Promise(resolve=>{frame.onload=resolve;document.body.append(frame)}); + await new Promise(resolve=>setTimeout(resolve,0)); + frame.contentWindow.history.replaceState('a0',''); + await new Promise(resolve=>{frame.onload=resolve;frame.contentWindow.location.assign('/a1')}); + await new Promise(resolve=>setTimeout(resolve,0)); + frame.contentWindow.history.replaceState('a1','');history.pushState('top1',''); + globalThis.loads=0; + globalThis.frameLoaded=new Promise(resolve=>frame.onload=()=>{loads++;resolve(true)}); + globalThis.snapshot=()=>[history.state,frame.contentWindow.history.state, + frame.contentWindow.location.pathname,frame.contentDocument.body.textContent, + history.length,frame.contentWindow.history.length,loads]; + })()"#).await; + ctx.process_async(json!({"id":9344,"method":"Network.enable","sessionId":"SID-1"})) + .await; + assert!(take_response_by_id(&mut ctx, 9344)["error"].is_null()); + ctx.sent.clear(); + joint_history_test_evaluate( + &mut ctx, + "new Promise(resolve=>{onpopstate=()=>resolve(true);history.go(-2)})", + ) + .await; + wait_until_messages( + &mut ctx, + Some("SID-1"), + "accepted child traversal reaches held response", + |_| requests.load(Ordering::SeqCst) == 2, + ) + .await; + let mutation = if update_state { + "frame.contentWindow.navigation.updateCurrentEntry({state:'updated'});snapshot()" + } else { + "frame.contentWindow.history.pushState('successor','');snapshot()" + }; + let successor = joint_history_test_evaluate(&mut ctx, mutation).await; + let expected = if update_state { + json!(["top0", "a1", "/a1", "a1", 4, 4, 0]) + } else { + json!(["top0", "successor", "/a1", "a1", 3, 3, 0]) + }; + assert_eq!(successor, expected); + release.notify_one(); + wait_until_messages( + &mut ctx, + Some("SID-1"), + "held child response completes", + |messages| { + messages.iter().any(|request| { + request["method"] == "Network.requestWillBeSent" + && request["params"]["request"]["url"] == format!("http://{addr}/a0") + && messages.iter().any(|done| { + done["method"] == "Network.loadingFinished" + && done["params"]["requestId"] == request["params"]["requestId"] + }) + }) + }, + ) + .await; + if update_state { + let after = joint_history_test_evaluate(&mut ctx, "frameLoaded.then(()=>snapshot())").await; + assert_eq!(after, json!(["top0", "a0", "/a0", "a0", 4, 4, 1])); + assert_eq!( + joint_history_test_evaluate( + &mut ctx, + "frame.contentWindow.navigation.entries()[1].getState()" + ) + .await, + json!("updated") + ); + } else { + assert_eq!( + joint_history_test_evaluate(&mut ctx, "snapshot()").await, + successor + ); + } + ctx.process_async(json!({"id":9345,"method":"Page.getNavigationHistory","sessionId":"SID-1"})) + .await; + let browser = take_response_by_id(&mut ctx, 9345); + assert_eq!( + browser["result"]["currentIndex"], + json!(if update_state { 1 } else { 2 }), + "{browser}" + ); + assert_eq!( + browser["result"]["entries"].as_array().unwrap().len(), + if update_state { 4 } else { 3 } + ); + server.abort(); +} + +async fn joint_history_test_evaluate(ctx: &mut TestContext, expression: &str) -> serde_json::Value { + ctx.process_async(json!({"id":9340,"method":"Runtime.evaluate","sessionId":"SID-1","params":{"expression":expression,"awaitPromise":true,"returnByValue":true}})).await; + wait_until_message( + ctx, + Some("SID-1"), + "joint traversal script completes", + |message| message["id"] == json!(9340), + ) + .await; + let response = take_response_by_id(ctx, 9340); + assert!( + response["error"].is_null() && response["result"]["exceptionDetails"].is_null(), + "{response}" + ); + response["result"]["result"]["value"].clone() +} + +async fn assert_joint_history_precommit(mode: &str) { + async fn page( + axum::extract::Path(name): axum::extract::Path, + ) -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + format!("{name}"), + ) + } + 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, + axum::Router::new().route("/{page}", axum::routing::get(page)), + ) + .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":9339,"method":"Page.navigate","sessionId":"SID-1","params":{"url":format!("http://{addr}/top")}})).await; + assert!(take_response_by_id(&mut ctx, 9339)["error"].is_null()); + wait_until_message( + &mut ctx, + Some("SID-1"), + "precommit test page load", + |message| message["method"] == json!("Page.domContentEventFired"), + ) + .await; + let pending=joint_history_test_evaluate(&mut ctx,r#"(async()=>{ + history.replaceState('top0',''); + globalThis.frame=document.createElement('iframe');frame.src='/a0'; + await new Promise(resolve=>{frame.onload=resolve;document.body.append(frame)}); + await new Promise(resolve=>setTimeout(resolve,0)); + frame.contentWindow.history.replaceState('a0',''); + await new Promise(resolve=>{frame.onload=resolve;frame.contentWindow.location.assign('/a1')}); + await new Promise(resolve=>setTimeout(resolve,0)); + frame.contentWindow.history.replaceState('a1',''); + history.pushState('top1',''); + globalThis.pagehides=0;frame.contentWindow.onpagehide=()=>pagehides++; + globalThis.snapshot=()=>[history.state,frame.contentWindow.history.state, + frame.contentWindow.location.pathname,history.length,frame.contentWindow.history.length,pagehides]; + let admitted;const started=new Promise(resolve=>admitted=resolve); + const gate=new Promise((resolve,reject)=>{globalThis.release=resolve;globalThis.block=reject}); + globalThis.failed=new Promise(resolve=>navigation.onnavigateerror=resolve); + navigation.onnavigate=event=>{ + if(event.navigationType==='traverse')event.intercept({precommitHandler:()=>{admitted();return gate}}); + }; + history.go(-2);await started;return snapshot(); + })()"#).await; + assert_eq!(pending, json!(["top1", "a1", "/a1", 4, 4, 0]), "{mode}"); + ctx.process_async(json!({"id":9341,"method":"Page.getNavigationHistory","sessionId":"SID-1"})) + .await; + let browser = take_response_by_id(&mut ctx, 9341); + assert_eq!( + browser["result"]["currentIndex"], + json!(3), + "{mode}: {browser}" + ); + assert_eq!(browser["result"]["entries"].as_array().unwrap().len(), 4); + if mode == "prune" { + ctx.process_async( + json!({"id":9342,"method":"Page.resetNavigationHistory","sessionId":"SID-1"}), + ) + .await; + assert!(take_response_by_id(&mut ctx, 9342)["error"].is_null()); + } + let expression = match mode { + "resolve" => { + "new Promise(resolve=>{frame.onload=()=>setTimeout(()=>resolve(snapshot()),0);release()})" + } + "reject" => "block(new Error('blocked'));failed.then(()=>snapshot())", + _ => "release();failed.then(()=>snapshot())", + }; + let after = joint_history_test_evaluate(&mut ctx, expression).await; + let (expected, index, length) = match mode { + "resolve" => (json!(["top0", "a0", "/a0", 4, 4, 1]), 1, 4), + "reject" => (json!(["top1", "a1", "/a1", 4, 4, 0]), 3, 4), + _ => (json!(["top1", "a1", "/a1", 1, 1, 0]), 0, 1), + }; + assert_eq!(after, expected, "{mode}"); + ctx.process_async(json!({"id":9341,"method":"Page.getNavigationHistory","sessionId":"SID-1"})) + .await; + let browser = take_response_by_id(&mut ctx, 9341); + assert_eq!( + browser["result"]["currentIndex"], + json!(index), + "{mode}: {browser}" + ); + assert_eq!( + browser["result"]["entries"].as_array().unwrap().len(), + length + ); + server.abort(); +} + +async fn assert_joint_history_multi_frame_traversal(cross: &str, both_cross: bool, reenter: bool) { + async fn page( + axum::extract::Path(name): axum::extract::Path, + ) -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + format!("{name}"), + ) + } + 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, + axum::Router::new().route("/{page}", axum::routing::get(page)), + ) + .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":9290,"method":"Page.navigate","sessionId":"SID-1","params":{"url":format!("http://{addr}/top")}})).await; + assert!(take_response_by_id(&mut ctx, 9290)["error"].is_null()); + wait_until_message( + &mut ctx, + Some("SID-1"), + "mixed traversal page load", + |message| message["method"] == json!("Page.domContentEventFired"), + ) + .await; + let expression = r#"(async () => { + const cross = CROSS, bothCross = BOTH_CROSS, reenter = REENTER; + const other = cross === 'a' ? 'b' : 'a'; + const task = () => new Promise(resolve => setTimeout(resolve, 0)); + for (const id of ['a', 'b']) { + const frame = document.createElement('iframe'); frame.id = id; frame.src = '/'+id+'0'; + await new Promise(resolve => {frame.onload=resolve;document.body.append(frame)}); + await task(); + frame.contentWindow.history.replaceState(id+'0', ''); + } + const frame = id => document.getElementById(id); + const navigate = async id => { + await new Promise(resolve => {frame(id).onload=resolve;frame(id).contentWindow.location.assign('/'+id+'1')}); + await task(); frame(id).contentWindow.history.replaceState(id+'1', ''); + }; + await navigate(cross); + if (bothCross) await navigate(other); + else frame(other).contentWindow.history.pushState(other+'1', ''); + let admitted; + const admittedPromise = new Promise(resolve => admitted=resolve); + if (reenter) frame(cross).contentWindow.navigation.addEventListener('navigate', event => { + if (event.navigationType==='traverse') { history.pushState('from-admission',''); admitted(); } + }, {once:true}); + globalThis.jointMove = delta => new Promise(resolve => { + const completed = new Set(); + const done = id => { completed.add(id); if(completed.size===2) {clearTimeout(timer);resolve()} }; + const timer = setTimeout(()=>resolve(), 3000); + frame(cross).onload=()=>done(cross); + if (bothCross) frame(other).onload=()=>done(other); + else frame(other).contentWindow.addEventListener('popstate',()=>done(other),{once:true}); + history.go(delta); + }); + globalThis.jointSnapshot = () => ({ + pages: ['a','b'].map(id=>frame(id).contentDocument.body.dataset.page), + states: ['a','b'].map(id=>frame(id).contentWindow.history.state), + lengths: [history.length,...['a','b'].map(id=>frame(id).contentWindow.history.length)], + indices: ['a','b'].map(id=>frame(id).contentWindow.navigation.currentEntry.index) + }); + if (reenter) { history.go(-2); await admittedPromise; await task(); } + else await jointMove(-2); + await task(); + return jointSnapshot(); + })()"#.replace("BOTH_CROSS", if both_cross {"true"} else {"false"}) + .replace("REENTER", if reenter {"true"} else {"false"}) + .replace("CROSS", &format!("'{cross}'")); + ctx.process_async(json!({"id":9291,"method":"Runtime.evaluate","sessionId":"SID-1","params":{"expression":expression,"awaitPromise":true,"returnByValue":true}})).await; + wait_until_message( + &mut ctx, + Some("SID-1"), + "mixed traversal settles", + |message| message["id"] == json!(9291), + ) + .await; + let response = take_response_by_id(&mut ctx, 9291); + let expected = if reenter { + json!({"pages":if cross=="a" {vec!["a1","b0"]} else {vec!["a0","b1"]},"states":["a1","b1"],"lengths":[5,5,5],"indices":[1,1]}) + } else { + json!({"pages":["a0","b0"],"states":["a0","b0"],"lengths":[4,4,4],"indices":[0,0]}) + }; + assert_eq!( + response["result"]["result"]["value"], expected, + "cross={cross}, both_cross={both_cross}, reenter={reenter}: {response}" + ); + ctx.process_async(json!({"id":9292,"method":"Page.getNavigationHistory","sessionId":"SID-1"})) + .await; + let browser = take_response_by_id(&mut ctx, 9292); + assert_eq!( + browser["result"]["currentIndex"], + json!(if reenter { 4 } else { 1 }), + "{browser}" + ); + assert_eq!( + browser["result"]["entries"].as_array().unwrap().len(), + if reenter { 5 } else { 4 } + ); + if !reenter { + let forward = joint_history_test_evaluate(&mut ctx, "jointMove(2).then(()=>new Promise(resolve=>setTimeout(()=>resolve(jointSnapshot()),0)))").await; + let pages = if both_cross { + vec!["a1", "b1"] + } else if cross == "a" { + vec!["a1", "b0"] + } else { + vec!["a0", "b1"] + }; + assert_eq!( + forward, + json!({"pages":pages,"states":["a1","b1"],"lengths":[4,4,4],"indices":[1,1]}), + "cross={cross}, both_cross={both_cross}" + ); + ctx.process_async( + json!({"id":9292,"method":"Page.getNavigationHistory","sessionId":"SID-1"}), + ) + .await; + let browser = take_response_by_id(&mut ctx, 9292); + assert_eq!(browser["result"]["currentIndex"], json!(3), "{browser}"); + assert_eq!(browser["result"]["entries"].as_array().unwrap().len(), 4); + } + server.abort(); +} + #[tokio::test(flavor = "multi_thread")] async fn navigation_bootstraps_browser_history_length_before_author_scripts() { async fn page() -> impl axum::response::IntoResponse { diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 215aaafe3..ae790e890 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -56,6 +56,7 @@ mod navigation_entry_state; mod navigation_events; mod navigation_handler_callbacks; mod navigation_history_pruning; +mod navigation_joint_traversal; mod navigation_lifecycle; mod navigation_mutation; mod navigation_projection; @@ -324,8 +325,8 @@ pub(crate) use self::navigation_events::dispatch_cross_document_navigation_navig pub(crate) use self::navigation_events::dispatch_srcdoc_navigation_navigate_event_for_window; pub(crate) use self::navigation_mutation::apply_local_window_location_navigation; pub(crate) use self::navigation_restore::{ - install_navigation_bootstrap_entry, install_navigation_bootstrap_entry_for_holder, - install_navigation_entry_view_for_holder, + commit_navigation_history_for_document, install_navigation_bootstrap_entry, + install_navigation_bootstrap_entry_for_holder, install_navigation_entry_view_for_holder, }; pub(crate) use self::navigation_traversal::queue_top_level_history_traversal_by_delta; pub(crate) use self::navigator_runtime::install_worker_navigator_runtime_state; diff --git a/moli-renderer-v8/src/context_bootstrap/history_runtime.rs b/moli-renderer-v8/src/context_bootstrap/history_runtime.rs index cd5fb52ee..744475ca0 100644 --- a/moli-renderer-v8/src/context_bootstrap/history_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/history_runtime.rs @@ -1,7 +1,7 @@ mod admission; -mod apply; +pub(super) mod apply; mod results; -mod traversal; +pub(super) mod traversal; pub(super) use self::admission::require_fully_active_history_owner; pub(super) use self::apply::apply_history_entry; diff --git a/moli-renderer-v8/src/context_bootstrap/history_runtime/apply.rs b/moli-renderer-v8/src/context_bootstrap/history_runtime/apply.rs index a3243314e..1a98ec14f 100644 --- a/moli-renderer-v8/src/context_bootstrap/history_runtime/apply.rs +++ b/moli-renderer-v8/src/context_bootstrap/history_runtime/apply.rs @@ -33,7 +33,12 @@ pub(in crate::context_bootstrap) struct AppliedHistoryEntry<'s> { history_index: u32, entry: v8::Local<'s, v8::Object>, previous_entry: Option>, - additional: Vec>, +} + +pub(in crate::context_bootstrap) struct PreparedHistoryEntry<'s> { + history: v8::Local<'s, v8::Object>, + location: v8::Local<'s, v8::Object>, + applied: AppliedHistoryEntry<'s>, } pub(in crate::context_bootstrap) fn apply_history_entry<'s>( @@ -98,34 +103,13 @@ pub(in crate::context_bootstrap) fn apply_history_entry_commit<'s>( Some(step) => super::super::session_history::targets_at(scope, owner, step)?, None => Vec::new(), }; - let mut additional = Vec::new(); - let mut cross_document = Vec::new(); - for target in targets { - if target.history.strict_equals(history.into()) { - continue; - } - let entries = history_entries(scope, target.history)?; - let current = entries - .get_index(scope, target.current_index) - .and_then(|value| v8::Local::::try_from(value).ok())?; - let next = entries - .get_index(scope, target.target_index) - .and_then(|value| v8::Local::::try_from(value).ok())?; - if !navigation_entries_share_document(scope, current, next) { - cross_document.push(target); - continue; - } - if let Some(navigation) = window_navigation_for_holder(scope, target.owner) - && !super::super::navigation_events::dispatch_navigation_traverse_event( - scope, - navigation, - target.history, - target.target_index, - ) - { - return None; - } - additional.push(target); + // The multi-participant coordinator admits and commits complete plans. + // This path is only the specialized single same-Document executor. + if targets + .iter() + .any(|target| !target.history.strict_equals(history.into())) + { + return None; } // Admission can run script, so validate the stable step again before any // local entry is changed. Forward pruning invalidates it atomically. @@ -146,20 +130,14 @@ pub(in crate::context_bootstrap) fn apply_history_entry_commit<'s>( return None; } } + let prepared = prepare_local_history_entry_commit(scope, history, index)?; let delta = match step { Some(step) => Some(super::super::session_history::commit_traversal( scope, owner, step, )?), None => None, }; - let mut applied = apply_local_history_entry_commit(scope, history, index)?; - for target in additional { - if let Some(entry) = - apply_local_history_entry_commit(scope, target.history, target.target_index) - { - applied.additional.push(entry); - } - } + let applied = commit_prepared_history_entry(scope, prepared); if let Some(delta) = delta.filter(|delta| *delta != 0) { super::super::session_history::publish( scope, @@ -167,19 +145,15 @@ pub(in crate::context_bootstrap) fn apply_history_entry_commit<'s>( moli_page_types::SessionHistoryUpdateKind::Traverse { delta }, ); } - for target in cross_document { - super::super::navigation_traversal_execution::queue_history_traversal_without_result( - scope, target, - ); - } Some(applied) } -fn apply_local_history_entry_commit<'s>( +/// Resolve every fallible input before any participant changes its live view. +pub(in crate::context_bootstrap) fn prepare_local_history_entry_commit<'s>( scope: &mut v8::PinScope<'s, '_>, history: v8::Local<'s, v8::Object>, index: u32, -) -> Option> { +) -> Option> { let owner = runtime_window_owner(scope, history); let previous_history_index = history_index(scope, history); let previous_entry = navigation_current_entry(scope, owner); @@ -192,41 +166,51 @@ fn apply_local_history_entry_commit<'s>( let state = super::super::navigation_entry_state::clone_history_entry_state(scope, entry) .unwrap_or_else(|| v8::null(scope).into()); let url = navigation_entry_url_value(scope, entry).unwrap_or_else(|| "about:blank".to_owned()); - set_history_index(scope, history, index); - set_history_state(scope, history, state); - let location = window_location_for_holder(scope, owner)?; - sync_location_object(scope, location, &url); - let Ok(parsed_url) = url::Url::parse(&url) else { - return None; - }; - sync_navigation_current_entry_from_history_entry(scope, owner, entry); - let resolved_entry = navigation_current_entry(scope, owner) - .map(v8::Local::::from) - .unwrap_or_else(|| v8::undefined(scope).into()); - sync_child_navigation_entry_seed_from_owner(scope, owner); - Some(AppliedHistoryEntry { - owner, - state, - old_url, - url, - parsed_url, - resolved_entry, - previous_history_index, - history_index: index, - entry, - previous_entry, - additional: Vec::new(), + let parsed_url = url::Url::parse(&url).ok()?; + Some(PreparedHistoryEntry { + history, + location, + applied: AppliedHistoryEntry { + owner, + state, + old_url, + url, + parsed_url, + resolved_entry: v8::undefined(scope).into(), + previous_history_index, + history_index: index, + entry, + previous_entry, + }, }) } +/// No author callbacks or fallible lookups are allowed in the commit phase. +pub(in crate::context_bootstrap) fn commit_prepared_history_entry<'s>( + scope: &mut v8::PinScope<'s, '_>, + prepared: PreparedHistoryEntry<'s>, +) -> AppliedHistoryEntry<'s> { + let PreparedHistoryEntry { + history, + location, + mut applied, + } = prepared; + set_history_index(scope, history, applied.history_index); + set_history_state(scope, history, applied.state); + sync_location_object(scope, location, &applied.url); + sync_navigation_current_entry_from_history_entry(scope, applied.owner, applied.entry); + applied.resolved_entry = navigation_current_entry(scope, applied.owner) + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + sync_child_navigation_entry_seed_from_owner(scope, applied.owner); + applied +} + pub(in crate::context_bootstrap) fn dispatch_history_entry_currententrychange<'s>( scope: &mut v8::PinScope<'s, '_>, applied: &AppliedHistoryEntry<'s>, ) { - for other in &applied.additional { - dispatch_history_entry_currententrychange(scope, other); - } if !navigation_document_has_opaque_origin(scope, applied.owner) && let Some(navigation) = window_navigation_for_holder(scope, applied.owner) { @@ -244,9 +228,6 @@ pub(in crate::context_bootstrap) fn dispatch_history_entry_post_commit_events<'s applied: &AppliedHistoryEntry<'s>, dispatch_popstate: bool, ) { - for other in &applied.additional { - dispatch_history_entry_post_commit_events(scope, other, dispatch_popstate); - } if runtime_window_is_global(scope, applied.owner) { let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { return; 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 a086f65ec..ea91e454e 100644 --- a/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs +++ b/moli-renderer-v8/src/context_bootstrap/history_runtime/traversal.rs @@ -236,21 +236,43 @@ pub(in crate::context_bootstrap) fn apply_pending_history_traversal( host: &mut JsContextHost, mut traversal: PendingHistoryTraversal, ) { - // Earlier queued work can already have installed this frame's entry while - // other participants still need to move to the requested joint step. - if traversal.results.is_empty() - && let Some(step) = traversal.joint_step - && let Some(owner) = history_traversal_target_window(scope, host, traversal.target) - && let Some(target) = super::super::session_history::targets_at(scope, owner, step) - .and_then(|targets| targets.into_iter().next()) - && let Some(exact) = window_task_target_for_runtime_owner(scope, host, target.owner) - { - traversal.target = exact; - traversal.target_index = target.target_index; - traversal.target_key = history_entries(scope, target.history) - .and_then(|entries| entries.get_index(scope, target.target_index)) - .and_then(|entry| v8::Local::::try_from(entry).ok()) - .and_then(|entry| navigation_entry_key_value(scope, entry)); + if let Some(step) = traversal.joint_step { + let Some(owner) = history_traversal_target_window(scope, host, traversal.target) else { + let error = navigation_dom_exception(scope, "Navigation was canceled", "AbortError"); + reject_pending_navigation_results(scope, &traversal.results, error); + return; + }; + let Some(plan) = super::super::navigation_traversal_plan::JointTraversalPlan::resolve( + scope, owner, step, + ) else { + let error = navigation_dom_exception(scope, "Navigation was canceled", "AbortError"); + reject_pending_navigation_results(scope, &traversal.results, error); + return; + }; + if super::super::navigation_joint_traversal::requires_joint_execution(scope, &plan) { + let info = traversal + .info + .as_ref() + .map(|info| v8::Local::new(scope, info)); + super::super::navigation_joint_traversal::execute( + scope, + plan, + info, + &traversal.results, + ); + return; + } + if traversal.results.is_empty() + && let [target] = plan.targets.as_slice() + && let Some(exact) = window_task_target_for_runtime_owner(scope, host, target.owner) + { + traversal.target = exact; + traversal.target_index = target.target_index; + traversal.target_key = history_entries(scope, target.history) + .and_then(|entries| entries.get_index(scope, target.target_index)) + .and_then(|entry| v8::Local::::try_from(entry).ok()) + .and_then(|entry| navigation_entry_key_value(scope, entry)); + } } let results = traversal.results; let history = history_traversal_target_window(scope, host, traversal.target) @@ -466,6 +488,86 @@ pub(in crate::context_bootstrap) fn apply_pending_history_traversal( resolve_pending_navigation_results(scope, results, resolved_entry); } +pub(in crate::context_bootstrap) fn prepare_joint_history_participant<'s>( + scope: &mut v8::PinScope<'s, '_>, + applied: &super::apply::AppliedHistoryEntry<'s>, + outcome: &NavigationDispatchOutcome<'s>, + finished_resolvers: v8::Local<'s, v8::Array>, +) -> Option> { + if !outcome.intercepted { + return None; + } + let navigation = window_navigation_for_holder(scope, applied.owner)?; + Some(set_active_traversal_intercept_settlement( + scope, + navigation, + outcome.signal, + finished_resolvers, + applied.resolved_entry, + &applied.url, + )) +} + +pub(in crate::context_bootstrap) fn finish_joint_history_participant<'s>( + scope: &mut v8::PinScope<'s, '_>, + applied: &super::apply::AppliedHistoryEntry<'s>, + outcome: NavigationDispatchOutcome<'s>, + finished_resolvers: v8::Local<'s, v8::Array>, + settlement: Option>, +) { + if settlement.is_some_and(|data| !traversal_intercept_is_active(scope, data.into())) { + return; + } + let Some(navigation) = window_navigation_for_holder(scope, applied.owner) else { + dispatch_history_entry_post_commit_events(scope, applied, true); + resolve_resolver_array(scope, finished_resolvers, applied.resolved_entry); + return; + }; + let (error, result) = if outcome.intercepted { + if let Some(event) = outcome.precommit_event { + run_navigation_precommit_deferred_handlers(scope, event) + } else { + (outcome.intercept_error, outcome.intercept_result) + } + } else { + (None, None) + }; + suppress_intercept_result_unhandled_rejection(scope, result); + dispatch_history_entry_post_commit_events(scope, applied, true); + if let Some(data) = settlement { + if !traversal_intercept_is_active(scope, data.into()) { + return; + } + set_traversal_intercept_inactive(scope, navigation, data.into()); + } + if let Some(error) = error { + finish_navigation_error_events(scope, navigation, error, &applied.url); + reject_resolver_array(scope, finished_resolvers, error, true); + if let Some(signal) = outcome.signal + && let Some(host) = context_host_ptr_from_global_bridge(scope) + { + unsafe { &mut *host }.abort_signal(scope, signal, error); + } + return; + } + if let Some(result) = result + && queue_pending_traversal_intercept_settlement( + scope, + navigation, + outcome.signal, + finished_resolvers, + applied.resolved_entry, + &applied.url, + result, + ) + { + return; + } + perform_navigation_scroll_if_needed(scope, navigation, &applied.url, true); + dispatch_navigation_success(scope, navigation); + resolve_resolver_array(scope, finished_resolvers, applied.resolved_entry); +} + fn history_traversal_target_window<'s>( scope: &mut v8::PinScope<'s, '_>, host: &mut JsContextHost, @@ -567,7 +669,7 @@ fn queue_pending_precommit_history_traversal<'s>( .is_some() } -fn pending_result_resolver_arrays<'s>( +pub(in crate::context_bootstrap) fn pending_result_resolver_arrays<'s>( scope: &mut v8::PinScope<'s, '_>, results: &[crate::native_bridge::PendingNavigationResult], ) -> (v8::Local<'s, v8::Array>, v8::Local<'s, v8::Array>) { @@ -666,6 +768,9 @@ pub(in crate::context_bootstrap) fn cancel_pending_precommit_history_traversal<' scope: &mut v8::PinScope<'s, '_>, navigation: v8::Local<'s, v8::Object>, ) -> bool { + if super::super::navigation_joint_traversal::cancel_pending(scope, navigation) { + return true; + } let Some(data) = navigation_pending_traversal_precommit(scope, navigation) .filter(|data| traversal_precommit_is_active(scope, (*data).into())) else { @@ -1047,7 +1152,7 @@ fn traversal_intercept_rejected_callback<'s>( reject_resolver_array(scope, finished_resolvers, error, true); } -fn resolve_resolver_array<'s>( +pub(in crate::context_bootstrap) fn resolve_resolver_array<'s>( scope: &mut v8::PinScope<'s, '_>, resolvers: v8::Local<'s, v8::Array>, value: v8::Local<'s, v8::Value>, @@ -1064,7 +1169,7 @@ fn resolve_resolver_array<'s>( } } -fn reject_resolver_array<'s>( +pub(in crate::context_bootstrap) fn reject_resolver_array<'s>( scope: &mut v8::PinScope<'s, '_>, resolvers: v8::Local<'s, v8::Array>, error: v8::Local<'s, v8::Value>, diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap/navigation_bootstrap.rs index 6ed86d9c8..d94b604f2 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_bootstrap.rs @@ -110,7 +110,6 @@ pub(crate) fn reset_window_location_history_navigation_runtime_state<'s>( }); set_history_entries(scope, history, entries); set_history_index(scope, history, initial_seed.current_index); - super::session_history::initialize(scope, window, &initial_seed); set_private_value(scope, window, WINDOW_HISTORY_SLOT, history.into()); let navigation = match window_runtime_object(scope, window, WINDOW_NAVIGATION_SLOT) { diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_joint_traversal.rs b/moli-renderer-v8/src/context_bootstrap/navigation_joint_traversal.rs new file mode 100644 index 000000000..411157cbd --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigation_joint_traversal.rs @@ -0,0 +1,589 @@ +//! A joint step is admitted as a unit. No participant may independently move +//! the shared cursor while its siblings are still running navigation script. + +use super::history_runtime::{apply, traversal}; +use super::navigation_entry::{history_entries, history_index, navigation_current_entry}; +use super::navigation_events::{ + NavigationDispatchOutcome, dispatch_beforeunload_for_runtime_owner, + dispatch_navigation_traverse_event_with_outcome, dispatch_pagehide_for_runtime_owner, + dispatch_unload_for_runtime_owner, +}; +use super::navigation_result::navigation_dom_exception; +use super::navigation_seed::history_entry_seed_for_traversal; +use super::navigation_traversal_execution::{ + TraversalTarget, queue_history_traversal_without_result, +}; +use super::navigation_traversal_plan::JointTraversalPlan; +use super::navigation_window::{ + child_browsing_context_handle_for_runtime_owner, navigation_document_has_opaque_origin, + navigation_document_is_active, window_history_for_holder, window_navigation_for_holder, +}; +use crate::native_bridge::PendingNavigationResult; +use crate::util::{context_host_ptr_from_global_bridge, get_private_value, set_private_value}; +use moli_page_types::SessionHistoryStepId; + +const PENDING: &str = "__lmPendingJointTraversal"; +const ACTIVE: &str = "__lmJointActive"; +const OWNER: &str = "__lmJointOwner"; +const STEP: &str = "__lmJointStep"; +const PARTICIPANTS: &str = "__lmJointParticipants"; +const REMAINING: &str = "__lmJointRemaining"; +const COMMITTED: &str = "__lmJointCommitted"; +const FINISHED: &str = "__lmJointFinished"; +const HISTORY: &str = "__lmJointHistory"; +const SOURCE: &str = "__lmJointSource"; +const DESTINATION: &str = "__lmJointDestination"; +const NAVIGATION: &str = "__lmJointNavigation"; +const SIGNAL: &str = "__lmJointSignal"; +const EVENT: &str = "__lmJointEvent"; +const INTERCEPTED: &str = "__lmJointIntercepted"; +const INTERCEPT_RESULT: &str = "__lmJointInterceptResult"; +const INTERCEPT_ERROR: &str = "__lmJointInterceptError"; + +pub(super) fn queue_plan<'s>(scope: &mut v8::PinScope<'s, '_>, mut plan: JointTraversalPlan<'s>) { + // A root Document replacement owns the whole descendant tree and must be + // performed by its browser/popup loader. Otherwise queue the step on its caller, never + // select one changing frame as a substitute for the joint operation. + if let Some(index) = plan.cross_document_root_index(scope) { + queue_history_traversal_without_result(scope, plan.targets.remove(index)); + return; + } + let Some(history) = window_history_for_holder(scope, plan.owner) else { + return; + }; + let index = history_index(scope, history); + queue_history_traversal_without_result( + scope, + TraversalTarget { + owner: plan.owner, + history, + current_index: index, + target_index: index, + joint_step: Some(plan.step), + }, + ); +} + +pub(super) fn requires_joint_execution<'s>( + scope: &mut v8::PinScope<'s, '_>, + plan: &JointTraversalPlan<'s>, +) -> bool { + match plan.targets.as_slice() { + [target] => history_entry_seed_for_traversal( + scope, + target.owner, + target.current_index, + target.target_index, + ) + .is_some(), + _ => true, + } +} + +fn object<'s>( + scope: &mut v8::PinScope<'s, '_>, + data: v8::Local<'s, v8::Object>, + key: &str, +) -> Option> { + get_private_value(scope, data, key).and_then(|value| value.try_into().ok()) +} + +fn array<'s>( + scope: &mut v8::PinScope<'s, '_>, + data: v8::Local<'s, v8::Object>, + key: &str, +) -> Option> { + get_private_value(scope, data, key).and_then(|value| value.try_into().ok()) +} + +fn active<'s>(scope: &mut v8::PinScope<'s, '_>, data: v8::Local<'s, v8::Object>) -> bool { + get_private_value(scope, data, ACTIVE).is_some_and(|value| value.is_true()) +} + +fn entry_signature<'s>( + scope: &mut v8::PinScope<'s, '_>, + history: v8::Local<'s, v8::Object>, + index: u32, +) -> Option { + let entry = history_entries(scope, history)? + .get_index(scope, index)? + .try_into() + .ok()?; + let entry = super::session_history::entry_reference(scope, entry)?; + serde_json::to_string(&(entry.key.as_str(), entry.document.as_str())).ok() +} + +fn signature_matches<'s>( + scope: &mut v8::PinScope<'s, '_>, + data: v8::Local<'s, v8::Object>, + key: &str, + signature: Option, +) -> bool { + let stored = get_private_value(scope, data, key) + .and_then(|value| value.to_string(scope)) + .map(|value| value.to_rust_string_lossy(scope)); + stored.is_some() && stored == signature +} + +/// All participants and their source/destination identities are captured +/// before the first callback. Precommit promises delay the whole operation. +pub(super) fn execute<'s>( + scope: &mut v8::PinScope<'s, '_>, + plan: JointTraversalPlan<'s>, + info: Option>, + results: &[PendingNavigationResult], +) { + let data = v8::Object::new(scope); + set_private_value(scope, data, ACTIVE, v8::Boolean::new(scope, true).into()); + set_private_value(scope, data, OWNER, plan.owner.into()); + set_private_value( + scope, + data, + STEP, + v8::BigInt::new_from_u64(scope, plan.step.raw()).into(), + ); + let participants = v8::Array::new(scope, plan.targets.len() as i32); + set_private_value(scope, data, PARTICIPANTS, participants.into()); + let (committed, finished) = traversal::pending_result_resolver_arrays(scope, results); + set_private_value(scope, data, COMMITTED, committed.into()); + set_private_value(scope, data, FINISHED, finished.into()); + for (index, target) in plan.targets.iter().enumerate() { + let participant = v8::Object::new(scope); + let _ = participants.set_index(scope, index as u32, participant.into()); + set_private_value(scope, participant, HISTORY, target.history.into()); + if !navigation_document_has_opaque_origin(scope, target.owner) + && let Some(navigation) = window_navigation_for_holder(scope, target.owner) + { + set_private_value(scope, participant, NAVIGATION, navigation.into()); + } + for (slot, index) in [ + (SOURCE, target.current_index), + (DESTINATION, target.target_index), + ] { + let Some(signature) = entry_signature(scope, target.history, index) else { + abort(scope, data, None); + return; + }; + set_private_value( + scope, + participant, + slot, + v8::String::new(scope, &signature).unwrap().into(), + ); + } + } + let mut precommit = Vec::new(); + for (index, target) in plan.targets.iter().enumerate() { + let participant = participants + .get_index(scope, index as u32) + .unwrap() + .try_into() + .unwrap(); + if history_entry_seed_for_traversal( + scope, + target.owner, + target.current_index, + target.target_index, + ) + .is_some() + { + dispatch_beforeunload_for_runtime_owner(scope, target.owner); + } + if validate(scope, data).is_none() { + abort(scope, data, None); + return; + } + let outcome = object(scope, participant, NAVIGATION) + .map(|navigation| { + dispatch_navigation_traverse_event_with_outcome( + scope, + navigation, + target.history, + target.target_index, + info, + ) + }) + .unwrap_or_else(NavigationDispatchOutcome::proceed); + for value in [outcome.precommit_result, outcome.intercept_result] + .into_iter() + .flatten() + { + if let Ok(promise) = v8::Local::::try_from(value) { + super::navigation_result::suppress_unhandled_rejection(scope, promise); + } + } + for (slot, value) in [ + (SIGNAL, outcome.signal.map(Into::into)), + (EVENT, outcome.precommit_event.map(Into::into)), + (INTERCEPT_RESULT, outcome.intercept_result), + (INTERCEPT_ERROR, outcome.intercept_error), + ] { + if let Some(value) = value { + set_private_value(scope, participant, slot, value); + } + } + set_private_value( + scope, + participant, + INTERCEPTED, + v8::Boolean::new(scope, outcome.intercepted).into(), + ); + if !outcome.proceed + || outcome.abort_error.is_some() + || outcome.precommit_error.is_some() + || validate(scope, data).is_none() + { + super::navigation_events::mark_navigation_outcome_default_prevented(scope, &outcome); + abort(scope, data, outcome.abort_error.or(outcome.precommit_error)); + return; + } + if let Some(promise) = outcome.precommit_result { + precommit.push(promise); + } + } + if precommit.is_empty() { + commit(scope, data); + return; + } + set_private_value( + scope, + data, + REMAINING, + v8::Integer::new_from_unsigned(scope, precommit.len() as u32).into(), + ); + for index in 0..participants.length() { + let participant = participants + .get_index(scope, index) + .unwrap() + .try_into() + .unwrap(); + if let Some(navigation) = object(scope, participant, NAVIGATION) { + cancel_pending(scope, navigation); + set_private_value(scope, navigation, PENDING, data.into()); + } + } + for value in precommit { + let Some(resolver) = v8::PromiseResolver::new(scope) else { + abort(scope, data, None); + return; + }; + let _ = resolver.resolve(scope, value); + let promise = resolver.get_promise(scope); + let fulfilled = v8::Function::builder(precommit_fulfilled) + .data(data.into()) + .build(scope) + .unwrap(); + let rejected = v8::Function::builder(precommit_rejected) + .data(data.into()) + .build(scope) + .unwrap(); + let _ = promise.then2(scope, fulfilled, rejected); + } +} + +fn validate<'s>( + scope: &mut v8::PinScope<'s, '_>, + data: v8::Local<'s, v8::Object>, +) -> Option> { + if !active(scope, data) { + return None; + } + let owner = object(scope, data, OWNER)?; + if !navigation_document_is_active(scope, owner) { + return None; + } + let step = get_private_value(scope, data, STEP) + .and_then(|value| v8::Local::::try_from(value).ok())?; + let plan = JointTraversalPlan::resolve( + scope, + owner, + SessionHistoryStepId::from_raw(step.u64_value().0), + )?; + let participants = array(scope, data, PARTICIPANTS)?; + if participants.length() as usize != plan.targets.len() { + return None; + } + for (index, target) in plan.targets.iter().enumerate() { + let participant = participants + .get_index(scope, index as u32)? + .try_into() + .ok()?; + if !navigation_document_is_active(scope, target.owner) + || !object(scope, participant, HISTORY)?.strict_equals(target.history.into()) + { + return None; + } + let source = entry_signature(scope, target.history, target.current_index); + let destination = entry_signature(scope, target.history, target.target_index); + if !signature_matches(scope, participant, SOURCE, source) + || !signature_matches(scope, participant, DESTINATION, destination) + { + return None; + } + if let Some(signal) = object(scope, participant, SIGNAL) + && let Some(host) = context_host_ptr_from_global_bridge(scope) + && unsafe { &mut *host }.abort_signal_aborted(scope, signal) + { + return None; + } + } + Some(plan) +} + +fn deactivate<'s>(scope: &mut v8::PinScope<'s, '_>, data: v8::Local<'s, v8::Object>) { + set_private_value(scope, data, ACTIVE, v8::Boolean::new(scope, false).into()); + if let Some(participants) = array(scope, data, PARTICIPANTS) { + for index in 0..participants.length() { + let Some(participant) = participants + .get_index(scope, index) + .and_then(|value| value.try_into().ok()) + else { + continue; + }; + if let Some(navigation) = object(scope, participant, NAVIGATION) + && object(scope, navigation, PENDING) + .is_some_and(|pending| pending.strict_equals(data.into())) + { + set_private_value(scope, navigation, PENDING, v8::undefined(scope).into()); + } + } + } +} + +fn abort<'s>( + scope: &mut v8::PinScope<'s, '_>, + data: v8::Local<'s, v8::Object>, + error: Option>, +) { + if !active(scope, data) { + return; + } + deactivate(scope, data); + let error = error.unwrap_or_else(|| { + navigation_dom_exception(scope, "Joint history traversal was canceled", "AbortError") + }); + for slot in [COMMITTED, FINISHED] { + if let Some(resolvers) = array(scope, data, slot) { + traversal::reject_resolver_array(scope, resolvers, error, slot == FINISHED); + } + } + if let Some(participants) = array(scope, data, PARTICIPANTS) { + for index in 0..participants.length() { + let Some(participant) = participants + .get_index(scope, index) + .and_then(|value| value.try_into().ok()) + else { + continue; + }; + if let Some(signal) = object(scope, participant, SIGNAL) + && let Some(host) = context_host_ptr_from_global_bridge(scope) + { + unsafe { &mut *host }.abort_signal(scope, signal, error); + } + if let Some(navigation) = object(scope, participant, NAVIGATION) { + super::navigation_lifecycle::finish_navigation_error_events( + scope, navigation, error, "", + ); + } + } + } +} + +pub(super) fn cancel_pending<'s>( + scope: &mut v8::PinScope<'s, '_>, + navigation: v8::Local<'s, v8::Object>, +) -> bool { + let Some(data) = object(scope, navigation, PENDING) else { + return false; + }; + if !active(scope, data) { + return false; + } + abort(scope, data, None); + true +} + +fn precommit_fulfilled<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _: v8::ReturnValue<'_, v8::Value>, +) { + let Ok(data) = v8::Local::::try_from(args.data()) else { + return; + }; + if !active(scope, data) { + return; + } + let Some(remaining) = + get_private_value(scope, data, REMAINING).and_then(|value| value.uint32_value(scope)) + else { + return; + }; + if remaining <= 1 { + commit(scope, data); + } else { + set_private_value( + scope, + data, + REMAINING, + v8::Integer::new_from_unsigned(scope, remaining - 1).into(), + ); + } +} + +fn precommit_rejected<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _: v8::ReturnValue<'_, v8::Value>, +) { + if let Ok(data) = v8::Local::::try_from(args.data()) { + abort(scope, data, Some(args.get(0))); + } +} + +fn commit<'s>(scope: &mut v8::PinScope<'s, '_>, data: v8::Local<'s, v8::Object>) { + let Some(plan) = validate(scope, data) else { + abort(scope, data, None); + return; + }; + let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { + abort(scope, data, None); + return; + }; + let mut cross_document = Vec::new(); + for target in &plan.targets { + if let Some((url, mut seed)) = history_entry_seed_for_traversal( + scope, + target.owner, + target.current_index, + target.target_index, + ) { + let Some(handle) = child_browsing_context_handle_for_runtime_owner(scope, target.owner) + else { + abort(scope, data, None); + return; + }; + seed.session_history.target_step = Some(plan.step); + seed.session_history.admitted_entry = history_entries(scope, target.history) + .and_then(|entries| entries.get_index(scope, target.target_index)) + .and_then(|entry| entry.try_into().ok()) + .and_then(|entry| super::session_history::entry_reference(scope, entry)); + cross_document.push((target.owner, handle, url, seed)); + } + } + // Unload is script too. Finish it for every participant and validate the + // entire plan again before scheduling any replacement or changing a view. + for (owner, _, _, _) in &cross_document { + dispatch_pagehide_for_runtime_owner(scope, *owner); + if validate(scope, data).is_none() { + abort(scope, data, None); + return; + } + dispatch_unload_for_runtime_owner(scope, *owner); + if validate(scope, data).is_none() { + abort(scope, data, None); + return; + } + } + let mut prepared = Vec::new(); + for (index, target) in plan.targets.iter().enumerate() { + if cross_document + .iter() + .any(|(owner, _, _, _)| owner.strict_equals(target.owner.into())) + { + continue; + } + let Some(entry) = + apply::prepare_local_history_entry_commit(scope, target.history, target.target_index) + else { + abort(scope, data, None); + return; + }; + prepared.push((index as u32, entry)); + } + for (_, handle, url, seed) in &cross_document { + let host = unsafe { &mut *host_ptr }; + let _ = + host.mark_current_child_document_unload_dispatched_after_navigation_traversal(*handle); + if !host.queue_deferred_child_browsing_context_navigation_from_entry_seed( + *handle, + url.as_str(), + seed.clone(), + ) { + for (_, queued, _, _) in &cross_document { + host.cancel_joint_child_history_navigation(*queued, plan.step); + } + abort(scope, data, None); + return; + } + } + // Acceptance point: all admissions passed, all child navigations have + // reservations, and same-Document views can commit without script or failure. + let Some(delta) = super::session_history::commit_traversal(scope, plan.owner, plan.step) else { + for (_, handle, _, _) in &cross_document { + unsafe { &mut *host_ptr }.cancel_joint_child_history_navigation(*handle, plan.step); + } + abort(scope, data, None); + return; + }; + let applied = prepared + .into_iter() + .map(|(index, entry)| (index, apply::commit_prepared_history_entry(scope, entry))) + .collect::>(); + deactivate(scope, data); + if delta != 0 { + super::session_history::publish( + scope, + plan.owner, + moli_page_types::SessionHistoryUpdateKind::Traverse { delta }, + ); + } + let participants = array(scope, data, PARTICIPANTS).unwrap(); + let committed = array(scope, data, COMMITTED).unwrap(); + let finished = array(scope, data, FINISHED).unwrap(); + let resolved_entry = navigation_current_entry(scope, plan.owner) + .map(Into::into) + .unwrap_or_else(|| v8::undefined(scope).into()); + let requester_replaces_document = cross_document + .iter() + .any(|(owner, _, _, _)| owner.strict_equals(plan.owner.into())); + if !requester_replaces_document { + traversal::resolve_resolver_array(scope, committed, resolved_entry); + } + let mut completions = Vec::new(); + for (index, entry) in &applied { + let participant = participants + .get_index(scope, *index) + .unwrap() + .try_into() + .unwrap(); + let mut outcome = NavigationDispatchOutcome::proceed(); + outcome.intercepted = + get_private_value(scope, participant, INTERCEPTED).is_some_and(|value| value.is_true()); + outcome.signal = object(scope, participant, SIGNAL); + outcome.precommit_event = object(scope, participant, EVENT); + outcome.intercept_result = get_private_value(scope, participant, INTERCEPT_RESULT); + outcome.intercept_error = get_private_value(scope, participant, INTERCEPT_ERROR); + let resolvers = if entry.owner.strict_equals(plan.owner.into()) { + finished + } else { + v8::Array::new(scope, 0) + }; + let settlement = + traversal::prepare_joint_history_participant(scope, entry, &outcome, resolvers); + completions.push((outcome, resolvers, settlement)); + } + for (_, entry) in &applied { + apply::dispatch_history_entry_currententrychange(scope, entry); + } + for ((_, entry), (outcome, resolvers, settlement)) in applied.iter().zip(completions) { + traversal::finish_joint_history_participant(scope, entry, outcome, resolvers, settlement); + } + if !requester_replaces_document + && !applied + .iter() + .any(|(_, entry)| entry.owner.strict_equals(plan.owner.into())) + { + traversal::resolve_resolver_array(scope, finished, resolved_entry); + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_restore.rs b/moli-renderer-v8/src/context_bootstrap/navigation_restore.rs index 8088243f4..42935bb07 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_restore.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_restore.rs @@ -27,16 +27,29 @@ pub(crate) fn install_navigation_bootstrap_entry_for_holder<'s>( owner: v8::Local<'s, v8::Object>, entry_seed: &NavigationHistoryEntrySeed, ) { - install_navigation_entry_view_for_holder(scope, owner, entry_seed, true); + install_navigation_entry_view_for_holder(scope, owner, entry_seed); + commit_navigation_history_for_document(scope, owner, entry_seed); } -/// A pending child navigation may expose its entry view before replacing the -/// live Document. Only a committed seed advances the shared traversable. +/// Document installation is the authoritative boundary, including commits +/// that reuse an initial Window and therefore do not initialize a new realm. +pub(crate) fn commit_navigation_history_for_document<'s>( + scope: &mut v8::PinScope<'s, '_>, + owner: v8::Local<'s, v8::Object>, + entry_seed: &NavigationHistoryEntrySeed, +) { + super::session_history::initialize(scope, owner, entry_seed); + super::session_history::restore(scope, owner, entry_seed); +} + +/// Refresh a Document's projection without mutating the shared traversable. +/// During an accepted cross-Document traversal this can still be the outgoing +/// Document's view. Only document bootstrap or an explicit history operation +/// may commit a session history transition. pub(crate) fn install_navigation_entry_view_for_holder<'s>( scope: &mut v8::PinScope<'s, '_>, owner: v8::Local<'s, v8::Object>, entry_seed: &NavigationHistoryEntrySeed, - committed: bool, ) { let Some(history) = window_history_for_holder(scope, owner) else { return; @@ -89,9 +102,6 @@ pub(crate) fn install_navigation_entry_view_for_holder<'s>( }); set_history_entries(scope, history, entries); set_history_index(scope, history, entry_seed.current_index); - if committed { - super::session_history::restore(scope, owner, entry_seed); - } set_history_state( scope, history, diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_traversal.rs b/moli-renderer-v8/src/context_bootstrap/navigation_traversal.rs index a77d527a3..00487c482 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_traversal.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_traversal.rs @@ -12,9 +12,7 @@ use super::navigation_callbacks::{ cancel_pending_precommit_same_document_navigation, queue_pending_precommit_same_document_navigation, settle_intercepted_same_document_navigation, }; -use super::navigation_entry::{ - history_entries, history_index, navigation_current_entry, navigation_entries_share_document, -}; +use super::navigation_entry::{history_entries, history_index, navigation_current_entry}; use super::navigation_entry_state::{ clone_navigation_entry_state, clone_navigation_state_arg_for_result, }; @@ -31,17 +29,15 @@ use super::navigation_result::{ navigation_rejected_dom_exception_result, navigation_rejected_invalid_state_result, navigation_rejected_value_result, navigation_result_with_pending_commit, }; -use super::navigation_traversal_execution::{ - queue_history_traversal_without_result, queue_navigation_traversal_with_result, -}; +use super::navigation_traversal_execution::queue_navigation_traversal_with_result; use super::navigation_traversal_plan::{ - NavigationTraversalPlan, history_delta_traversal_target, navigation_delta_traversal_plan, + NavigationTraversalPlan, history_delta_traversal_plan, navigation_delta_traversal_plan, navigation_index_traversal_plan, }; use super::navigation_window::{ navigation_document_can_update_current_entry, navigation_document_is_active, - navigation_unload_event_active, runtime_window_is_global, runtime_window_owner, - window_history_for_holder, window_location_for_holder, window_navigation_for_holder, + navigation_unload_event_active, runtime_window_owner, window_history_for_holder, + window_location_for_holder, window_navigation_for_holder, }; use super::*; use crate::webidl; @@ -530,11 +526,11 @@ fn history_traverse<'s>( history: v8::Local<'s, v8::Object>, delta: i64, ) { - let Some(target) = history_delta_traversal_target(scope, history, delta) else { + let Some(plan) = history_delta_traversal_plan(scope, history, delta) else { queue_browser_owned_top_level_history_traversal(scope, history, delta); return; }; - queue_history_traversal_without_result(scope, target); + super::navigation_joint_traversal::queue_plan(scope, plan); } fn queue_browser_owned_top_level_history_traversal<'s>( @@ -563,30 +559,13 @@ pub(crate) fn queue_top_level_history_traversal_by_delta( let Some(history) = window_history_for_holder(scope, global) else { return false; }; - let Some(target) = history_delta_traversal_target(scope, history, delta) else { + let Some(plan) = history_delta_traversal_plan(scope, history, delta) else { return false; }; - let Some(entries) = history_entries(scope, target.history) else { - return false; - }; - let Some(current_entry) = entries - .get_index(scope, target.current_index) - .and_then(|entry| v8::Local::::try_from(entry).ok()) - else { - return false; - }; - let Some(target_entry) = entries - .get_index(scope, target.target_index) - .and_then(|entry| v8::Local::::try_from(entry).ok()) - else { - return false; - }; - if runtime_window_is_global(scope, target.owner) - && !navigation_entries_share_document(scope, current_entry, target_entry) - { + if plan.has_cross_document_root(scope) { return false; } - queue_history_traversal_without_result(scope, target); + super::navigation_joint_traversal::queue_plan(scope, plan); true } diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_traversal_execution.rs b/moli-renderer-v8/src/context_bootstrap/navigation_traversal_execution.rs index b5a2e53c8..6b27ed39b 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_traversal_execution.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_traversal_execution.rs @@ -355,6 +355,16 @@ pub(in crate::context_bootstrap) fn apply_pending_child_cross_document_traversal .info .as_ref() .map(|info| v8::Local::new(scope, info)); + if let Some(step) = traversal.seed.session_history.target_step { + let Some(plan) = + super::navigation_traversal_plan::JointTraversalPlan::resolve(scope, owner, step) + else { + reject_child_cross_document_traversal(scope, &traversal); + return; + }; + super::navigation_joint_traversal::execute(scope, plan, info, &traversal.results); + return; + } if !dispatch_child_cross_document_traverse_event(scope, &target, info) { reject_child_cross_document_traversal(scope, &traversal); return; diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_traversal_plan.rs b/moli-renderer-v8/src/context_bootstrap/navigation_traversal_plan.rs index 06f36e004..83446ad21 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_traversal_plan.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_traversal_plan.rs @@ -11,6 +11,48 @@ use super::navigation_window::{ window_history_for_holder, }; +pub(super) struct JointTraversalPlan<'s> { + pub(super) step: moli_page_types::SessionHistoryStepId, + pub(super) owner: v8::Local<'s, v8::Object>, + pub(super) targets: Vec>, +} + +impl<'s> JointTraversalPlan<'s> { + pub(super) fn resolve( + scope: &mut v8::PinScope<'s, '_>, + owner: v8::Local<'s, v8::Object>, + step: moli_page_types::SessionHistoryStepId, + ) -> Option { + Some(Self { + step, + owner, + targets: super::session_history::targets_at(scope, owner, step)?, + }) + } + + pub(super) fn has_cross_document_root(&self, scope: &mut v8::PinScope<'s, '_>) -> bool { + self.cross_document_root_index(scope).is_some() + } + + pub(super) fn cross_document_root_index( + &self, + scope: &mut v8::PinScope<'s, '_>, + ) -> Option { + self.targets.iter().position(|target| { + (super::navigation_window::runtime_window_is_global(scope, target.owner) + || crate::native_bridge::lightweight_popup_id_from_window(scope, target.owner) + .is_some()) + && super::navigation_seed::history_entry_seed_for_traversal( + scope, + target.owner, + target.current_index, + target.target_index, + ) + .is_some() + }) + } +} + pub(super) enum NavigationTraversalPlan<'s> { RejectInvalidState(&'static str), ResolveCurrentEntry(v8::Local<'s, v8::Object>), @@ -115,11 +157,11 @@ pub(super) fn navigation_index_traversal_plan<'s>( })) } -pub(super) fn history_delta_traversal_target<'s>( +pub(super) fn history_delta_traversal_plan<'s>( scope: &mut v8::PinScope<'s, '_>, history: v8::Local<'s, v8::Object>, delta: i64, -) -> Option> { +) -> Option> { let owner = runtime_window_owner(scope, history); let host = unsafe { &mut *crate::util::context_host_ptr_from_global_bridge(scope)? }; let binding = super::session_history::binding(scope, host, owner); @@ -128,16 +170,5 @@ pub(super) fn history_delta_traversal_target<'s>( model.traverse(step); } let step = model.step_by_delta(delta)?; - let target = super::session_history::targets_at(scope, owner, step)? - .into_iter() - .next(); - // Steps whose only changed frame has been detached still advance the - // traversable asynchronously, without a synthetic popstate or reload. - Some(target.unwrap_or_else(|| TraversalTarget { - owner, - history, - current_index: history_index(scope, history), - target_index: history_index(scope, history), - joint_step: Some(step), - })) + JointTraversalPlan::resolve(scope, owner, step) } diff --git a/moli-renderer-v8/src/context_bootstrap/session_history.rs b/moli-renderer-v8/src/context_bootstrap/session_history.rs index a20cfa1ec..ac08d15c8 100644 --- a/moli-renderer-v8/src/context_bootstrap/session_history.rs +++ b/moli-renderer-v8/src/context_bootstrap/session_history.rs @@ -116,7 +116,9 @@ pub(super) fn restore<'s>( }; // The seed may be installed repeatedly while a child navigation loads. // Identity, rather than URL or list shape, makes the commit idempotent. - if history.entry(binding.context) == Some(&entry) { + if history.entry(binding.context) == Some(&entry) + || seed.session_history.admitted_entry.is_some() + { return; } let update = match seed.session_history.commit { diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents.rs index b5077d647..fd33127ae 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents.rs @@ -42,6 +42,7 @@ fn configure_child_document_navigation_request( #[derive(Debug, Clone)] pub(super) struct PendingChildDocumentNavigation { + pub(super) admitted_history_entry: Option, pub(super) target: ChildDocumentNavigationFetchTarget, pub(super) target_url: Url, pub(super) resource_loader: crate::network::navigation::NavigationResourceLoader, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/bootstrap_commit.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/bootstrap_commit.rs index 48dce47a6..352c364fb 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/bootstrap_commit.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/bootstrap_commit.rs @@ -18,6 +18,24 @@ impl JsContextHost { if !self.child_browsing_contexts.contains_key(&handle) { return None; } + if self + .child_browsing_contexts + .get(&handle) + .and_then(|entry| entry.navigation_entry_seed().session_history.admitted_entry) + .is_some_and(|entry| { + !self + .session_histories + .entry_is_current(super::super::OwnerDispatchScope::Child(handle), &entry) + }) + { + self.clear_child_browsing_context_pending_navigation(handle); + if let Some(entry) = self.child_browsing_contexts.get_mut(&handle) { + entry.restore_navigation_entry_seed_from_committed(); + } + let _ = + self.finish_child_frame_navigation_without_load_dispatch(handle, navigation_load); + return Some(ChildDocumentCommitResult::ready(None)); + } let creation_kind = child_document_creation_kind_for_bootstrap(&bootstrap); if let Some(request_url) = child_document_frame_csp_request_url(&bootstrap) { if let Some(violation) = diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs index 67f2387ab..ee356deeb 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs @@ -381,6 +381,15 @@ impl JsContextHost { ), resource_authority, ); + // A preserved initial LocalWindow may already have a history surface + // without running realm bootstrap again. Commit its Document seed here; + // subsequent view refreshes must never replay this history transition. + if let Some(window) = self.existing_child_browsing_context_window_wrapper(scope, handle) + && let Some(entry) = self.child_browsing_contexts.get(&handle) + { + let seed = entry.committed_navigation_entry_seed(); + crate::context_bootstrap::commit_navigation_history_for_document(scope, window, &seed); + } Some(owner_transition) } diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs index 3d591f4d2..ebcbbeb13 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs @@ -181,6 +181,10 @@ impl JsContextHost { self.pending_child_document_navigations.insert( load_id, PendingChildDocumentNavigation { + admitted_history_entry: self + .child_browsing_contexts + .get(&handle) + .and_then(|entry| entry.navigation_entry_seed().session_history.admitted_entry), target, target_url: target_url.clone(), resource_loader: resource_loader.clone(), @@ -401,6 +405,37 @@ impl JsContextHost { handle, target.load_id(), ); + if pending + .admitted_history_entry + .as_ref() + .is_some_and(|entry| { + !self + .session_histories + .entry_is_current(super::super::OwnerDispatchScope::Child(handle), entry) + }) + { + // Author navigation while the response was in flight superseded + // this participant. It cannot restore an old view or joint cursor. + self.clear_child_browsing_context_pending_navigation(handle); + if let Some(entry) = self.child_browsing_contexts.get_mut(&handle) + && entry.navigation_entry_seed().session_history.admitted_entry + == pending.admitted_history_entry + { + entry.restore_navigation_entry_seed_from_committed(); + } + self.clear_pending_service_worker_child_client_if_matches( + handle, + pending.reserved_service_worker_client_id, + ); + let _ = self.finish_child_frame_navigation_without_load_dispatch( + handle, + target.navigation_load(), + ); + return ChildDocumentLoadApplication::SupersededDuringApplication { + completion: ChildDocumentLoadCompletion::new(target, network_attribution, result), + body_activity: ChildDocumentLoadBodyActivity::NoPageCodeOrEventDispatch, + }; + } let result = match result { Ok(ChildDocumentLoadOutcome::Loaded(mut loaded)) => { if self.bypass_content_security_policy() { diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/commands.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/commands.rs index 058280f38..9707a0c2b 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/commands.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/commands.rs @@ -119,6 +119,25 @@ impl JsContextHost { self.queue_child_browsing_context_navigation_commit(handle) } + pub(crate) fn cancel_joint_child_history_navigation( + &mut self, + handle: DomHandle, + step: moli_page_types::SessionHistoryStepId, + ) { + if self + .child_browsing_contexts + .get(&handle) + .is_some_and(|entry| { + entry.navigation_entry_seed().session_history.target_step == Some(step) + }) + { + self.clear_child_browsing_context_pending_navigation(handle); + if let Some(entry) = self.child_browsing_contexts.get_mut(&handle) { + entry.restore_navigation_entry_seed_from_committed(); + } + } + } + pub(crate) fn queue_deferred_child_browsing_context_navigation_to_url( &mut self, handle: DomHandle, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/sync.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/sync.rs index daca4b5a3..05d64c2d7 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/sync.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/sync.rs @@ -94,12 +94,7 @@ impl JsContextHost { } else { sync_window_location_history_navigation_runtime_surface(scope, wrapper); } - install_navigation_entry_view_for_holder( - scope, - wrapper, - &visible_state.entry_seed, - visible_state.seed_is_committed, - ); + install_navigation_entry_view_for_holder(scope, wrapper, &visible_state.entry_seed); let performance_navigation_type = self.child_performance_navigation_type(handle); let _ = bind_window_performance_seed( scope, @@ -164,7 +159,7 @@ impl JsContextHost { wrapper, ¤t_entry.url, ); - install_navigation_entry_view_for_holder(scope, wrapper, &entry_seed, false); + install_navigation_entry_view_for_holder(scope, wrapper, &entry_seed); let performance_navigation_type = self.child_performance_navigation_type(handle); let _ = bind_window_performance_seed( scope, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frames.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frames.rs index dc87a33dd..c76b31b8c 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frames.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frames.rs @@ -693,7 +693,42 @@ impl ChildBrowsingContextEntry { let same_document_update = committed_current_document_id.is_none() || committed_current_document_id == next_current_document_id || self.pending_attribute_bootstrap_commit; - self.navigation_entry_seed = entry_seed.clone(); + let updates_outgoing_document = same_document_update + && entry_seed.session_history.commit == moli_page_types::SessionHistoryCommit::Attach + && self + .navigation_entry_seed + .session_history + .admitted_entry + .is_some() + && committed_current_document_id + != child_navigation_current_document_id(&self.navigation_entry_seed); + if updates_outgoing_document { + // An accepted traversal owns the destination and activation. State + // updates in the outgoing Document only refresh its retained entry; + // they cannot replace the pending destination with the live cursor. + if let Some(updated) = entry_seed + .entries + .iter() + .find(|entry| entry.history_index == entry_seed.current_index) + { + let refresh = |entry: &mut NavigationHistorySerializedEntry| { + if entry.key == updated.key && entry.document_id == updated.document_id { + *entry = updated.clone(); + } + }; + for entry in &mut self.navigation_entry_seed.entries { + refresh(entry); + } + if let Some(activation) = &mut self.navigation_entry_seed.activation { + refresh(&mut activation.entry); + if let Some(from) = &mut activation.from { + refresh(from); + } + } + } + } else { + self.navigation_entry_seed = entry_seed.clone(); + } if same_document_update { self.committed_navigation_entry_seed = entry_seed; self.pending_attribute_bootstrap_commit = false; diff --git a/moli-renderer-v8/src/native_bridge/context_host/session_history.rs b/moli-renderer-v8/src/native_bridge/context_host/session_history.rs index 7a1c77a78..91be59e20 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/session_history.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/session_history.rs @@ -12,6 +12,21 @@ pub(crate) struct RendererSessionHistories { } impl RendererSessionHistories { + pub(crate) fn entry_is_current( + &self, + owner: OwnerDispatchScope, + entry: &moli_page_types::SessionHistoryEntry, + ) -> bool { + let Some(context) = self.contexts.get(&owner).copied() else { + return false; + }; + self.main.entry(context) == Some(entry) + || self + .popups + .values() + .any(|history| history.entry(context) == Some(entry)) + } + pub(crate) fn detach(&mut self, owner: OwnerDispatchScope) { if let Some(context) = self.contexts.remove(&owner) { self.main.detach(context);