fix(history): coordinate complete joint traversal plans

This commit is contained in:
ldm0
2026-09-24 02:39:25 +08:00
committed by Donough Liu
parent 59ad7921a8
commit f8f46bf488
23 changed files with 1440 additions and 161 deletions
+12 -3
View File
@@ -310,7 +310,10 @@ pub fn apply_child_browsing_context_navigation_to_entry_seed(
history_state_json: Option<String>,
navigation_state_json: Option<String>,
) {
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<String>,
navigation_state_json: Option<String>,
) {
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
+3
View File
@@ -94,6 +94,9 @@ pub struct SessionHistorySeed {
pub commit: SessionHistoryCommit,
pub traversable: Option<Box<JointSessionHistory>>,
pub target_step: Option<SessionHistoryStepId>,
/// 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<SessionHistoryEntry>,
}
/// One already-committed traversable mutation, published in renderer FIFO
@@ -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()),
@@ -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<String>| {
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!("<!doctype html><body>{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<String>,
) -> impl axum::response::IntoResponse {
(
[(axum::http::header::CONTENT_TYPE.as_str(), "text/html")],
format!("<!doctype html><body>{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<String>,
) -> impl axum::response::IntoResponse {
(
[(axum::http::header::CONTENT_TYPE.as_str(), "text/html")],
format!("<!doctype html><body data-page='{name}'>{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 {
+3 -2
View File
@@ -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;
@@ -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;
@@ -33,7 +33,12 @@ pub(in crate::context_bootstrap) struct AppliedHistoryEntry<'s> {
history_index: u32,
entry: v8::Local<'s, v8::Object>,
previous_entry: Option<v8::Local<'s, v8::Object>>,
additional: Vec<AppliedHistoryEntry<'s>>,
}
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::<v8::Object>::try_from(value).ok())?;
let next = entries
.get_index(scope, target.target_index)
.and_then(|value| v8::Local::<v8::Object>::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<AppliedHistoryEntry<'s>> {
) -> Option<PreparedHistoryEntry<'s>> {
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::<v8::Value>::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;
@@ -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::<v8::Object>::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::<v8::Object>::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<v8::Local<'s, v8::Object>> {
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<v8::Local<'s, v8::Object>>,
) {
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>,
@@ -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) {
@@ -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<v8::Local<'s, v8::Object>> {
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<v8::Local<'s, v8::Array>> {
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<String> {
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<String>,
) -> 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<v8::Local<'s, v8::Value>>,
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::<v8::Promise>::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<JointTraversalPlan<'s>> {
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::<v8::BigInt>::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<v8::Local<'s, v8::Value>>,
) {
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::<v8::Object>::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::<v8::Object>::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::<Vec<_>>();
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);
}
}
@@ -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,
@@ -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::<v8::Object>::try_from(entry).ok())
else {
return false;
};
let Some(target_entry) = entries
.get_index(scope, target.target_index)
.and_then(|entry| v8::Local::<v8::Object>::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
}
@@ -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;
@@ -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<TraversalTarget<'s>>,
}
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<Self> {
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<usize> {
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<TraversalTarget<'s>> {
) -> Option<JointTraversalPlan<'s>> {
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)
}
@@ -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 {
@@ -42,6 +42,7 @@ fn configure_child_document_navigation_request(
#[derive(Debug, Clone)]
pub(super) struct PendingChildDocumentNavigation {
pub(super) admitted_history_entry: Option<moli_page_types::SessionHistoryEntry>,
pub(super) target: ChildDocumentNavigationFetchTarget,
pub(super) target_url: Url,
pub(super) resource_loader: crate::network::navigation::NavigationResourceLoader,
@@ -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) =
@@ -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)
}
@@ -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() {
@@ -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,
@@ -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,
&current_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,
@@ -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;
@@ -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);