diff --git a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/mod.rs b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/mod.rs index c6cf77d53f..908f4b2dd4 100644 --- a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/mod.rs +++ b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/mod.rs @@ -1,5 +1,6 @@ mod browser_target; mod emulation_input_storage; +mod nested_history; mod network_fetch; mod p0_input; mod p0_network; diff --git a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/nested_history.rs b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/nested_history.rs new file mode 100644 index 0000000000..cb6fbfed55 --- /dev/null +++ b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/nested_history.rs @@ -0,0 +1,410 @@ +use super::super::tests_cdp_smoke_fixture::SmokeFixtureServer; +use super::super::*; +use super::support::CdpPageHarness; +use anyhow::{Context, Result}; +use serde_json::{Value, json}; +use tokio::time::Duration; +use url::Url; + +struct HistoryPage { + ctx: TestContext, + cdp: CdpPageHarness, + next_id: u64, +} + +impl HistoryPage { + async fn evaluate(&mut self, expression: &str) -> Result { + self.evaluate_with_await(expression, false).await + } + + async fn evaluate_await(&mut self, expression: &str) -> Result { + self.evaluate_with_await(expression, true).await + } + + async fn evaluate_with_await( + &mut self, + expression: &str, + await_promise: bool, + ) -> Result { + let id = self.next_id; + self.next_id += 1; + let response = if await_promise { + self.cdp + .evaluate_await_value(&mut self.ctx, id, expression) + .await + } else { + self.cdp.evaluate_value(&mut self.ctx, id, expression).await + }; + anyhow::ensure!( + response.get("error").is_none() && response["result"].get("exceptionDetails").is_none(), + "evaluating {expression}: {response}" + ); + Ok(response["result"]["result"].clone()) + } +} + +fn markup_url(server: &SmokeFixtureServer, markup: &str) -> String { + let mut url = Url::parse(&server.url("/history-markup")).unwrap(); + url.query_pairs_mut().append_pair( + "markup", + &markup.replace( + "", + "", + ), + ); + url.into() +} + +fn frame(url: &str, name: &str) -> String { + format!( + "", + url.replace('&', "&").replace('"', """) + ) +} + +async fn value(page: &mut HistoryPage, expression: &str) -> Result { + let result = page + .evaluate(&format!("JSON.stringify({expression})")) + .await?; + Ok(serde_json::from_str( + result["value"] + .as_str() + .unwrap_or_else(|| panic!("{expression}: {result}")), + )?) +} + +async fn wait(page: &mut HistoryPage, expression: &str) -> Result<()> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let result = page.evaluate(expression).await; + if result.as_ref().is_ok_and(|value| value["value"] == true) { + return Ok(()); + } + anyhow::ensure!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {expression}: {result:?}" + ); + page.ctx.complete_one_ready_scheduler_input_for_test().await; + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +async fn navigate(page: &mut HistoryPage, root: &str, action: &str, url: &str) -> Result<()> { + page.evaluate(&format!("({root}).{action}; true")).await?; + wait( + page, + &format!( + "({root}).location.href === {} && ({root}).document.body?.dataset.loaded === 'yes'", + serde_json::to_string(url)? + ), + ) + .await + .with_context(|| format!("after {root}.{action}")) +} + +async fn open_root( + url: &str, + popup: bool, + opener_url: &str, +) -> Result<(HistoryPage, &'static str)> { + let mut ctx = TestContext::new_with_target_discovery(false); + let cdp = CdpPageHarness::attach(&mut ctx, 220_000).await; + let response = cdp + .navigate(&mut ctx, 220_005, if popup { opener_url } else { url }) + .await; + anyhow::ensure!(response.get("error").is_none(), "{response}"); + let mut page = HistoryPage { + ctx, + cdp, + next_id: 220_010, + }; + let root = if popup { "testPopup" } else { "window" }; + if popup { + page.evaluate(&format!( + "globalThis.testPopup = open({}); true", + serde_json::to_string(url)? + )) + .await?; + } + wait( + &mut page, + &format!( + "({root}).location.href === {} && ({root}).document.body?.dataset.loaded === 'yes'", + serde_json::to_string(url)? + ), + ) + .await + .with_context(|| format!("opening {root}"))?; + Ok((page, root)) +} + +fn frames(root: &str) -> String { + format!("Array.from(({root}).document.querySelectorAll('iframe[data-static]'))") +} + +async fn snapshot(page: &mut HistoryPage, root: &str) -> Result { + value(page, &format!("({{length:({root}).history.length, frames:{}.map(f=>({{url:f.contentWindow.location.href, text:f.contentDocument.body.textContent, state:f.contentWindow.history.state, navigationState:f.contentWindow.navigation.currentEntry.getState() ?? null, id:f.contentWindow.navigation.currentEntry.id, keys:f.contentWindow.navigation.entries().map(e=>e.key)}}))}})", frames(root))).await +} + +async fn srcdoc(page: &mut HistoryPage, frame: &str, markup: &str) -> Result<()> { + page.evaluate_await(&format!( + "(async()=>{{const f=({frame}); const loaded=new Promise(resolve=>f.addEventListener('load',()=>setTimeout(resolve,0),{{once:true}})); f.srcdoc={}; await loaded; return true;}})()", + serde_json::to_string(markup)?)).await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn nested_history_restores_interleaved_frames_after_parent_document_replacement() -> Result<()> +{ + let server = SmokeFixtureServer::start().await; + let initial_url = markup_url(&server, "

initial

"); + for popup in [false, true] { + for layout in ["named", "unnamed", "dynamic"] { + let dynamic = if layout == "dynamic" { + format!( + "", + serde_json::to_string(&initial_url)? + ) + } else { + String::new() + }; + let names = if layout == "unnamed" { + ["", ""] + } else { + ["a", "b"] + }; + let source = markup_url( + &server, + &format!( + "{dynamic}{}{}", + frame(&initial_url, names[0]), + frame(&initial_url, names[1]) + ), + ); + let away = markup_url(&server, "

away from parent

"); + let (mut page, root) = open_root(&source, popup, &server.url("/plain?opener")).await?; + let list = frames(root); + let initial = snapshot(&mut page, root).await?; + let mut stages = vec![initial.clone()]; + for (index, text) in [ + (0, "first historical source"), + (1, "second historical source"), + ] { + srcdoc( + &mut page, + &format!("({list})[{index}]"), + &format!("

{text}

"), + ) + .await?; + wait( + &mut page, + &format!("({list})[{index}].contentDocument.body.textContent === '{text}'"), + ) + .await?; + stages.push(snapshot(&mut page, root).await?); + } + page.evaluate(&format!( + "({list})[0].contentWindow.location.hash = 'fragment'; true" + )) + .await?; + wait( + &mut page, + &format!("({list})[0].contentWindow.location.hash === '#fragment'"), + ) + .await?; + page.evaluate(&format!("({list})[0].contentWindow.history.replaceState({{classic:3}}, ''); ({list})[0].contentWindow.navigation.updateCurrentEntry({{state:{{navigation:3}}}}); true")).await?; + let expected = snapshot(&mut page, root).await?; + assert_eq!( + expected["length"].as_u64(), + initial["length"].as_u64().map(|length| length + 3) + ); + + navigate( + &mut page, + root, + &format!("location.href = {}", serde_json::to_string(&away)?), + &away, + ) + .await?; + navigate(&mut page, root, "history.back()", &source).await?; + let restored = snapshot(&mut page, root).await?; + assert_eq!( + restored["frames"], expected["frames"], + "popup={popup}, {layout}: {restored}" + ); + assert_eq!( + restored["length"].as_u64(), + initial["length"].as_u64().map(|length| length + 4) + ); + for reference in stages.iter().rev() { + page.evaluate(&format!("({root}).history.back(); true")) + .await?; + let urls = reference["frames"] + .as_array() + .unwrap() + .iter() + .map(|frame| frame["url"].clone()) + .collect::>(); + wait( + &mut page, + &format!( + "JSON.stringify(({list}).map(f=>f.contentWindow.location.href)) === {}", + serde_json::to_string(&serde_json::to_string(&urls)?)? + ), + ) + .await?; + let current = snapshot(&mut page, root).await?; + for index in 0..2 { + assert_eq!( + current["frames"][index]["id"], reference["frames"][index]["id"], + "popup={popup}, {layout}: {current}" + ); + } + } + navigate(&mut page, root, "history.go(4)", &away).await?; + navigate(&mut page, root, "history.go(-3)", &source).await?; + let restored = snapshot(&mut page, root).await?; + for index in 0..2 { + assert_eq!( + restored["frames"][index]["id"], stages[1]["frames"][index]["id"], + "popup={popup}, {layout}: {restored}" + ); + } + page.evaluate(&format!( + "({list})[1].contentWindow.history.pushState({{fork:true}}, '', '#fork'); true" + )) + .await?; + assert_eq!( + value(&mut page, &format!("({root}).history.length")).await?, + json!(initial["length"].as_u64().unwrap() + 2) + ); + if layout == "dynamic" { + assert_eq!(value(&mut page, &format!("({root}).document.querySelector('iframe').contentDocument.body.textContent")).await?, json!("initial")); + } + if popup { + page.evaluate("testPopup.close(); true").await?; + } + } + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn nested_history_retains_children_of_a_script_created_parent_navigable() -> Result<()> { + let server = SmokeFixtureServer::start().await; + let leaf = markup_url(&server, "

initial

"); + let parent = markup_url( + &server, + &format!("{}", frame(&leaf, "grandchild")), + ); + let source = markup_url( + &server, + &format!( + "", + serde_json::to_string(&parent)? + ), + ); + let away = markup_url(&server, "

away from child

"); + for popup in [false, true] { + let (mut page, root) = open_root(&source, popup, &server.url("/plain?opener")).await?; + let container = format!("({root}).document.querySelector('iframe').contentWindow"); + let child = format!("({container}).document.querySelector('iframe')"); + srcdoc(&mut page, &child, "

script parent historical child

").await?; + let id = value( + &mut page, + &format!("({child}).contentWindow.navigation.currentEntry.id"), + ) + .await?; + navigate( + &mut page, + &container, + &format!("location.href = {}", serde_json::to_string(&away)?), + &away, + ) + .await?; + navigate(&mut page, &container, "history.back()", &parent).await?; + assert_eq!( + value( + &mut page, + &format!("({child}).contentWindow.navigation.currentEntry.id") + ) + .await?, + id + ); + assert_eq!( + value( + &mut page, + &format!("({child}).contentDocument.body.textContent") + ) + .await?, + json!("script parent historical child") + ); + if popup { + page.evaluate("testPopup.close(); true").await?; + } + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn nested_history_restores_grandchildren_with_their_parent_document_identity() -> Result<()> { + let server = SmokeFixtureServer::start().await; + let leaf = markup_url(&server, "

initial

"); + let parent = markup_url( + &server, + &format!("{}", frame(&leaf, "grandchild")), + ); + let source = markup_url( + &server, + &format!("{}", frame(&parent, "child")), + ); + let away = markup_url(&server, "

away from grandchild

"); + for popup in [false, true] { + let (mut page, root) = open_root(&source, popup, &server.url("/plain?opener")).await?; + let child = format!( + "({root}).document.querySelector('iframe').contentDocument.querySelector('iframe')" + ); + srcdoc(&mut page, &child, "

historical grandchild

").await?; + wait( + &mut page, + &format!("({child}).contentDocument.body.textContent === 'historical grandchild'"), + ) + .await?; + let id = value( + &mut page, + &format!("({child}).contentWindow.navigation.currentEntry.id"), + ) + .await?; + navigate( + &mut page, + root, + &format!("location.href = {}", serde_json::to_string(&away)?), + &away, + ) + .await?; + navigate(&mut page, root, "history.back()", &source).await?; + assert_eq!( + value( + &mut page, + &format!("({child}).contentWindow.navigation.currentEntry.id") + ) + .await?, + id + ); + assert_eq!( + value( + &mut page, + &format!("({child}).contentDocument.body.textContent") + ) + .await?, + json!("historical grandchild") + ); + if popup { + page.evaluate("testPopup.close(); true").await?; + } + } + + Ok(()) +} diff --git a/moli-protocol/src/domains/target/tests/tests_cdp_smoke_fixture.rs b/moli-protocol/src/domains/target/tests/tests_cdp_smoke_fixture.rs index cd7448b3b4..2e5440dd27 100644 --- a/moli-protocol/src/domains/target/tests/tests_cdp_smoke_fixture.rs +++ b/moli-protocol/src/domains/target/tests/tests_cdp_smoke_fixture.rs @@ -290,6 +290,7 @@ async fn fixture_handler( &[], ), "/plain" => html("
plain ok
"), + "/history-markup" => html(query.get("markup").map(String::as_str).unwrap_or_default()), "/iframe" => html(r#"
parent
"#), "/child" => html("child body text"), "/wait-for-function" => html( diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index aaa382c381..6173e7f626 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -73,6 +73,8 @@ mod navigation_restore; mod navigation_result; mod navigation_seed; mod navigation_serialize; +pub(crate) use navigation_entry::navigation_entry_public_token; +pub(crate) use navigation_serialize::history_document_id_for_holder; mod navigation_surface; mod navigation_traversal; mod navigation_traversal_execution; diff --git a/moli-renderer-v8/src/context_bootstrap/joint_history.rs b/moli-renderer-v8/src/context_bootstrap/joint_history.rs index 405c0a69db..62ec00f53f 100644 --- a/moli-renderer-v8/src/context_bootstrap/joint_history.rs +++ b/moli-renderer-v8/src/context_bootstrap/joint_history.rs @@ -113,6 +113,28 @@ pub(super) fn refresh<'s>( if pushing == Some(child) { child_snapshot = before_push(child_snapshot); } + if let Some(current) = child_snapshot + .entries + .iter() + .find(|entry| entry.index == child_snapshot.current_index) + && let Some(positions) = host + .take_restored_child_history_positions(handle, ¤t.key) + .or_else(|| { + if host + .joint_histories + .get(root)? + .navigable_history(child) + .is_some() + { + return None; + } + host.saved_child_history_positions(handle, ¤t.key) + }) + { + host.joint_histories + .get_mut(root)? + .restore_child(child, positions, ¤t.key); + } host.joint_histories .get_mut(root)? .ensure_child(child, child_snapshot); @@ -120,6 +142,7 @@ pub(super) fn refresh<'s>( host.joint_histories .get_mut(root)? .retain_navigables(|owner| live.contains(&owner)); + host.remember_nested_histories(root); Some(root) } @@ -261,7 +284,7 @@ fn apply_plan( host: &mut JsContextHost, source_target: WindowTaskTarget, root: OwnerDispatchScope, - plan: JointHistoryTraversal, + mut plan: JointHistoryTraversal, mut method: Option, ) { if plan.targets.iter().any(|target| { @@ -305,9 +328,47 @@ fn apply_plan( host.record_pending_top_level_history_traversal( i64::from(target.index) - i64::from(current), ); + host.top_level_navigation_history() + .select_joint_traversal(target.key.clone(), plan.step); return; } } + // A replaced parent reconstructs its descendants from the target + // Document's nested histories. Traversing the old descendants as well + // can commit into a subtree whose parent is already being retired. + let mut replacing_documents = Vec::new(); + for target in &plan.targets { + let Some(window) = window_for_owner(scope, host, target.owner) else { + continue; + }; + let Some(history) = window_history_for_holder(scope, window) else { + continue; + }; + let current = history_index(scope, history); + if super::navigation_seed::history_entry_seed_for_traversal( + scope, + window, + current, + target.index, + ) + .is_some() + { + replacing_documents.push(target.owner); + } + } + plan.targets.retain(|target| { + let mut owner = target.owner; + while let OwnerDispatchScope::Child(handle) = owner { + let Some(parent) = host.owner_dispatch_scope_for_node(handle) else { + break; + }; + if replacing_documents.contains(&parent) { + return false; + } + owner = parent; + } + true + }); let targets = plan .targets .iter() @@ -437,6 +498,22 @@ pub(crate) fn commit<'s>( let Some(snapshot) = snapshot(scope, history) else { return; }; + if pushing.is_some() { + let root_window = runtime_top_window_owner(scope, owner); + if let Some(seed) = super::navigation_serialize::capture_navigation_entry_seed_for_holder( + scope, + root_window, + ) && let Some(joint) = host.joint_histories.get(root) + { + let step = joint.source_step(); + let roots = seed + .entries + .iter() + .map(|entry| entry.document_id.clone()) + .collect::>(); + host.nested_history_store(root).prune(step, &roots); + } + } let Some(joint) = host.joint_histories.get_mut(root) else { return; }; @@ -466,6 +543,7 @@ pub(crate) fn commit<'s>( set_history_length(scope, history, length); prune_runtime_entries(scope, host, removed); finish_traversal(scope, host, root); + host.remember_nested_histories(root); } fn finish_traversal( diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_entry.rs b/moli-renderer-v8/src/context_bootstrap/navigation_entry.rs index 1c3c5e7485..2e9f955319 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_entry.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_entry.rs @@ -274,7 +274,7 @@ pub(super) fn navigation_entry_referrer_policy_value<'s>( navigation_entry_stored_string(scope, entry, NAVIGATION_ENTRY_REFERRER_POLICY_SLOT) } -pub(super) fn navigation_entry_public_token(token: &str) -> String { +pub(crate) fn navigation_entry_public_token(token: &str) -> String { if token.is_empty() || is_uuid_v4_like(token) { return token.to_owned(); } diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_serialize.rs b/moli-renderer-v8/src/context_bootstrap/navigation_serialize.rs index b56776cf66..2f03e41f3f 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_serialize.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_serialize.rs @@ -21,7 +21,7 @@ use crate::{ referrer_policy::normalize_referrer_policy, util::context_host_ptr_from_window_object, }; -fn capture_navigation_entry_seed_for_holder<'s>( +pub(crate) fn capture_navigation_entry_seed_for_holder<'s>( scope: &mut v8::PinScope<'s, '_>, owner: v8::Local<'s, v8::Object>, ) -> Option { @@ -34,6 +34,14 @@ fn capture_navigation_entry_seed_for_holder<'s>( }) } +pub(crate) fn history_document_id_for_holder<'s>( + scope: &mut v8::PinScope<'s, '_>, + owner: v8::Local<'s, v8::Object>, +) -> Option { + let entry = navigation_current_entry(scope, owner)?; + navigation_entry_document_id(scope, entry).map(NavigationHistoryDocumentId::from_serialized) +} + pub(super) fn sync_navigation_entry_seed_from_owner<'s>( scope: &mut v8::PinScope<'s, '_>, owner: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/dom_parser.rs b/moli-renderer-v8/src/dom_parser.rs index f2f8b8fd68..a945093829 100644 --- a/moli-renderer-v8/src/dom_parser.rs +++ b/moli-renderer-v8/src/dom_parser.rs @@ -388,13 +388,13 @@ pub(crate) fn parse_browsing_context_document_projection_from_source<'s>( let source = preserve_decoded_bom_only_browsing_context_body(source, content_type); let (parsed, kind) = parse_browsing_context_document_snapshot(document_url, &source, content_type, html_parser); - build_detached_document_from_dom_host_with_content_type( + build_detached_document_object_from_dom_host_with_content_type( scope, + kind.bridge_kind(), parsed, - kind, - true, content_type, character_set, + true, ) } @@ -473,6 +473,7 @@ fn build_detached_document_with_content_type<'s>( DomHost::from_dom(parsed), content_type, None, + false, ) } @@ -501,6 +502,7 @@ fn build_detached_document_from_dom_host_with_content_type<'s>( parsed, content_type, character_set, + false, ) } 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 37205a86c2..a91ba0a2cd 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 @@ -237,6 +237,7 @@ impl JsContextHost { { entry.remember_srcdoc_history_resource(snapshot, origin); } + self.remember_child_history(handle); let owner_local_window_id = current_owner.local_window_id; let owner_document_id = current_owner.document_id; self.dom_host_mut() diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/commit.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/commit.rs index 5faf85cec6..573f65cd88 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/commit.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/commit.rs @@ -476,6 +476,7 @@ impl JsContextHost { scope: &mut v8::PinScope<'_, '_>, handle: DomHandle, ) { + let restoring = self.take_child_history_restoration(handle); let increments_joint_history = self .child_browsing_contexts .get_mut(&handle) @@ -493,7 +494,7 @@ impl JsContextHost { }); let update = if increments_joint_history { moli_page_types::SameDocumentHistoryUpdate::Push - } else if traversing { + } else if traversing && !restoring { moli_page_types::SameDocumentHistoryUpdate::Traverse { delta: 0 } } else { moli_page_types::SameDocumentHistoryUpdate::Replace diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/snapshots.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/snapshots.rs index 4ae07f8d33..2a8b260d7d 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/snapshots.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_navigation/snapshots.rs @@ -75,7 +75,11 @@ impl JsContextHost { let Some(entry) = self.child_browsing_contexts.get_mut(&handle) else { return false; }; - entry.set_navigation_entry_seed(entry_seed) + let committed = entry.set_navigation_entry_seed(entry_seed); + if committed { + self.remember_child_history(handle); + } + committed } pub(crate) fn pending_child_browsing_context_navigation_position( 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 5634ed3936..836a11b67d 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 @@ -27,6 +27,7 @@ mod classic_scripts; mod discovery; mod lookup; mod module_scripts; +mod nested_history; mod registry; mod request_scope; mod srcdoc_history; @@ -34,6 +35,8 @@ mod stylesheets; pub(in crate::native_bridge::context_host) use classic_scripts::ChildParserClassicScriptCandidate; pub(in crate::native_bridge::context_host) use classic_scripts::PendingChildExternalClassicDocumentScriptLoad; +pub(super) use nested_history::ChildHistoryIdentity; +pub(crate) use nested_history::{NestedHistoryStore, joint_snapshot}; pub(crate) use request_scope::WebStorageScope; pub(in crate::native_bridge::context_host) use request_scope::{ document_sandbox_policy_from_attribute, sandbox_attribute_forces_opaque_origin, @@ -42,6 +45,9 @@ pub(in crate::native_bridge::context_host) use request_scope::{ #[derive(Debug, Clone)] pub(super) struct ChildBrowsingContextEntry { frame_id: String, + history_identity: Option, + restored_history_positions: Option, + restoring_history: bool, current_document_loader_id: Option, name: Option, id: Option, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frames/nested_history.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frames/nested_history.rs new file mode 100644 index 0000000000..8fa8e65f4b --- /dev/null +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frames/nested_history.rs @@ -0,0 +1,351 @@ +use super::*; +use crate::context_bootstrap::navigation_entry_public_token; +use crate::native_bridge::{ + OwnerDispatchScope, + joint_history::{ + JointHistoryEntry, JointHistorySnapshot, NavigableHistory, SessionHistoryStep, + }, +}; +use parking_lot::Mutex; + +/// A parser-created frame can be recreated when its parent Document is +/// repopulated. Script-created frames retain their own nested histories, but +/// a new script-created navigable must not claim an old one's history. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(in crate::native_bridge::context_host) struct ChildHistoryIdentity { + parent: NavigationHistoryDocumentId, + key: ChildHistoryKey, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum ChildHistoryKey { + Parser { name: String, ordinal: usize }, + Script(NavigationHistoryDocumentId), +} + +#[derive(Clone, Debug)] +pub(super) struct ChildHistoryRecord { + seed: NavigationHistoryEntrySeed, + resources: HashMap>, + positions: NavigableHistory, +} + +/// This store contains no live DOM handles or V8 objects. Retiring a Document +/// can release its entire subtree without discarding its nested histories. +#[derive(Clone, Debug, Default)] +pub(crate) struct NestedHistoryStore { + records: Arc>>, +} + +impl NestedHistoryStore { + pub(crate) fn prune(&self, step: SessionHistoryStep, roots: &[NavigationHistoryDocumentId]) { + let mut records = self.records.lock(); + for record in records.values_mut() { + record.positions.retain_through(step); + record.seed.entries.retain(|entry| { + record + .positions + .contains(&navigation_entry_public_token(entry.key.as_str())) + }); + record.resources.retain(|id, _| { + record + .seed + .entries + .iter() + .any(|entry| &entry.document_id == id) + }); + } + let mut reachable: HashSet<_> = roots.iter().cloned().collect(); + loop { + let count = reachable.len(); + for (identity, record) in records.iter() { + if reachable.contains(&identity.parent) { + reachable.extend( + record + .seed + .entries + .iter() + .map(|entry| entry.document_id.clone()), + ); + } + } + if reachable.len() == count { + break; + } + } + records.retain(|identity, record| { + reachable.contains(&identity.parent) && !record.seed.entries.is_empty() + }); + } +} + +pub(crate) fn joint_snapshot(seed: &NavigationHistoryEntrySeed) -> JointHistorySnapshot { + JointHistorySnapshot { + entries: seed + .entries + .iter() + .map(|entry| JointHistoryEntry { + index: entry.history_index, + navigation_index: entry.index, + key: navigation_entry_public_token(entry.key.as_str()), + }) + .collect(), + current_index: seed.current_index, + } +} + +impl JsContextHost { + fn nested_history_root(&self, handle: DomHandle) -> Option { + let mut owner = self.owner_dispatch_scope_for_node(handle)?; + while let OwnerDispatchScope::Child(parent) = owner { + owner = self.owner_dispatch_scope_for_node(parent)?; + } + Some(owner) + } + + pub(crate) fn nested_history_store(&mut self, root: OwnerDispatchScope) -> NestedHistoryStore { + if root == OwnerDispatchScope::Top { + return self.top_level_navigation_history.nested_history(); + } + self.popup_nested_histories.entry(root).or_default().clone() + } + + pub(super) fn child_history_identity( + &self, + scope: &mut v8::PinScope<'_, '_>, + handle: DomHandle, + ) -> Option { + let node = self.dom_host().node(handle)?; + let parser_created = node.flags().parser_created(); + let document = node.owner_document()?; + let owner = self.owner_dispatch_scope_for_node(handle)?; + let parent = match owner { + OwnerDispatchScope::Top => self.top_level_navigation_history.current_document_id()?, + OwnerDispatchScope::Child(parent) => { + let seed = &self + .child_browsing_contexts + .get(&parent)? + .committed_navigation_entry_seed; + seed.entries + .iter() + .find(|entry| entry.history_index == seed.current_index)? + .document_id + .clone() + } + OwnerDispatchScope::LightweightPopup(id) => { + let window = self.lightweight_popup_window(scope, id)?; + crate::context_bootstrap::history_document_id_for_holder(scope, window)? + } + }; + if !parser_created { + return Some(ChildHistoryIdentity { + parent, + key: ChildHistoryKey::Script(NavigationHistoryDocumentId::allocate()), + }); + } + let name = self + .dom_host() + .get_attribute(handle, "name") + .unwrap_or_default(); + let mut handles = Vec::new(); + self.collect_child_browsing_context_host_handles(document, &mut handles); + let ordinal = handles + .into_iter() + .filter(|candidate| { + self.dom_host() + .node(*candidate) + .is_some_and(|node| node.flags().parser_created()) + && self + .dom_host() + .get_attribute(*candidate, "name") + .unwrap_or_default() + == name + }) + .position(|candidate| candidate == handle)?; + Some(ChildHistoryIdentity { + parent, + key: ChildHistoryKey::Parser { name, ordinal }, + }) + } + + pub(super) fn child_history_for_restoration( + &mut self, + handle: DomHandle, + identity: &ChildHistoryIdentity, + ) -> Option { + // Consume each parser identity once in this native parent Document. + // Removing/reinserting a frame in a still-live Document is not history traversal. + let document = self.dom_host().node(handle)?.owner_document()?; + if !self + .claimed_child_histories + .entry(document) + .or_default() + .insert(identity.clone()) + { + return None; + } + let root = self.nested_history_root(handle)?; + let step = self.joint_histories.get(root)?.destination_step(); + let store = self.nested_history_store(root); + let mut record = store.records.lock().get(identity)?.clone(); + let key = &record.positions.entry_at(step)?.key; + let target = record + .seed + .entries + .iter() + .find(|entry| navigation_entry_public_token(entry.key.as_str()) == *key)? + .clone(); + record.seed.current_index = target.history_index; + record.seed.activation = Some(NavigationActivationSeed { + entry: target, + from: None, + navigation_type: Some("traverse".to_owned()), + }); + Some(record) + } + + pub(super) fn restore_child_history_after_initial_empty( + &mut self, + handle: DomHandle, + record: ChildHistoryRecord, + ) { + let Some(target) = record + .seed + .entries + .iter() + .find(|entry| entry.history_index == record.seed.current_index) + else { + return; + }; + let url = target.url.clone(); + if let Some(entry) = self.child_browsing_contexts.get_mut(&handle) { + entry.srcdoc_history = record.resources; + entry.restored_history_positions = Some(record.positions); + entry.restoring_history = true; + } + self.queue_deferred_child_browsing_context_navigation_from_entry_seed( + handle, + &url, + record.seed, + false, + None, + ); + } + + pub(crate) fn take_restored_child_history_positions( + &mut self, + handle: DomHandle, + key: &str, + ) -> Option { + let entry = self.child_browsing_contexts.get_mut(&handle)?; + if !entry.restored_history_positions.as_ref()?.contains(key) { + return None; + } + entry.restored_history_positions.take() + } + + pub(crate) fn saved_child_history_positions( + &mut self, + handle: DomHandle, + key: &str, + ) -> Option { + let identity = self + .child_browsing_contexts + .get(&handle)? + .history_identity + .clone()?; + let root = self.nested_history_root(handle)?; + let store = self.nested_history_store(root); + let records = store.records.lock(); + let positions = &records.get(&identity)?.positions; + positions.contains(key).then(|| positions.clone()) + } + + pub(crate) fn remember_child_history(&mut self, handle: DomHandle) { + let Some(root) = self.nested_history_root(handle) else { + return; + }; + let store = self.nested_history_store(root); + let Some(entry) = self.child_browsing_contexts.get(&handle) else { + return; + }; + let Some(identity) = entry.history_identity.clone() else { + return; + }; + let seed = entry.committed_navigation_entry_seed(); + let joint = self.joint_histories.get(root); + let positions = entry + .restored_history_positions + .clone() + .or_else(|| { + joint.and_then(|joint| joint.navigable_history(OwnerDispatchScope::Child(handle))) + }) + // Lazy or retiring realms may have no live joint-ledger entry. + // Their committed positions still belong to the navigable and + // must not be rebased to the traversable's current step. + .or_else(|| { + store + .records + .lock() + .get(&identity) + .map(|record| record.positions.clone()) + }) + .or_else(|| { + NavigableHistory::new( + joint_snapshot(&seed), + joint.map_or(SessionHistoryStep::INITIAL, |joint| { + joint.destination_step() + }), + ) + }); + let Some(positions) = positions else { + return; + }; + // A new runtime can briefly expose its initial empty Document while + // the historical target is pending. It must not overwrite that target. + if !seed + .entries + .iter() + .find(|entry| entry.history_index == seed.current_index) + .is_some_and(|entry| { + positions.contains(&navigation_entry_public_token(entry.key.as_str())) + }) + { + return; + } + let record = ChildHistoryRecord { + seed, + resources: entry.srcdoc_history.clone(), + positions, + }; + store.records.lock().insert(identity, record); + } + + pub(in crate::native_bridge::context_host) fn take_child_history_restoration( + &mut self, + handle: DomHandle, + ) -> bool { + self.child_browsing_contexts + .get_mut(&handle) + .is_some_and(|entry| std::mem::take(&mut entry.restoring_history)) + } + + pub(crate) fn remember_nested_histories(&mut self, root: OwnerDispatchScope) { + let handles: Vec<_> = self + .child_browsing_contexts + .keys() + .copied() + .filter(|handle| self.nested_history_root(*handle) == Some(root)) + .collect(); + for handle in handles { + self.remember_child_history(handle); + } + if root == OwnerDispatchScope::Top { + self.top_level_navigation_history.publish_joint_history( + self.joint_histories + .get(root) + .and_then(|joint| joint.restoration(root)), + ); + } + } +} diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs index 8e0ba99b54..f067bbfeb3 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs @@ -38,6 +38,7 @@ impl JsContextHost { retired_owner: crate::frame_owner_model::FrameDocumentTaskOwner, document_handle: DomHandle, ) { + self.claimed_child_histories.remove(&document_handle); let _ = self.retire_document_resource_loader( crate::native_bridge::WindowDocumentOwner::Frame(retired_owner), ); @@ -96,6 +97,17 @@ impl JsContextHost { self.object_fallback_bootstraps.remove(&handle); let existing = self.child_browsing_contexts.get(&handle).cloned(); let is_new = existing.is_none(); + let history_identity = existing + .as_ref() + .and_then(|entry| entry.history_identity.clone()) + .or_else(|| self.child_history_identity(scope, handle)); + let restored_history = if is_new { + history_identity + .as_ref() + .and_then(|identity| self.child_history_for_restoration(handle, identity)) + } else { + None + }; let attribute_bootstrap_changed = existing .as_ref() .is_some_and(|entry| entry.attribute_bootstrap_changed(&attribute_bootstrap)); @@ -301,10 +313,12 @@ impl JsContextHost { }); } let initial_about_blank_document_is_complete = is_new + && restored_history.is_none() && child_browsing_context_bootstrap_is_initial_about_blank( &attribute_bootstrap, ); let initial_navigation_uses_initial_empty_load = is_new + && restored_history.is_none() && child_browsing_context_bootstrap_uses_initial_empty_load( &attribute_bootstrap, ); @@ -438,6 +452,13 @@ impl JsContextHost { handle, ChildBrowsingContextEntry { frame_id, + history_identity, + restoring_history: existing + .as_ref() + .is_some_and(|entry| entry.restoring_history), + restored_history_positions: existing + .as_ref() + .and_then(|entry| entry.restored_history_positions.clone()), current_document_loader_id: existing.as_ref().and_then(|entry| { entry.current_document_loader_id().map(ToOwned::to_owned) }), @@ -587,6 +608,9 @@ impl JsContextHost { { entry.set_document_permissions_policy(policy); } + if let Some(restored) = restored_history { + self.restore_child_history_after_initial_empty(handle, restored); + } if attribute_bootstrap_changed { self.cancel_child_meta_refresh_navigation(handle); } diff --git a/moli-renderer-v8/src/native_bridge/context_host/core.rs b/moli-renderer-v8/src/native_bridge/context_host/core.rs index 4c23fa83bb..aac0297c31 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/core.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/core.rs @@ -339,6 +339,8 @@ impl JsContextHost { opfs_owner_state: None, history_queue: HistoryQueueState::default(), joint_histories: super::super::joint_history::JointSessionHistories::default(), + popup_nested_histories: HashMap::new(), + claimed_child_histories: HashMap::new(), rendering_updates: super::rendering_updates::RenderingUpdateState::default(), scroll_observable_effect_batch: super::interaction_batch::ScrollObservableEffectBatchState::default(), diff --git a/moli-renderer-v8/src/native_bridge/context_host/mod.rs b/moli-renderer-v8/src/native_bridge/context_host/mod.rs index 095d729bca..8fbb70cdec 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/mod.rs @@ -195,6 +195,7 @@ pub(crate) use child_frame_snapshots::{ }; use child_frames::ChildBrowsingContextEntry; pub(in crate::native_bridge::context_host) use child_frames::ChildParserClassicScriptCandidate; +pub(crate) use child_frames::{NestedHistoryStore, joint_snapshot}; pub(crate) use event_callbacks::{EventCallbackId, PreparedEventCallback}; pub(crate) use host_loads::ChildFrameAttachmentSnapshot; #[cfg(test)] @@ -970,6 +971,8 @@ pub(crate) struct JsContextHost { opfs_owner_state: Option, pub(super) history_queue: HistoryQueueState, pub(crate) joint_histories: super::joint_history::JointSessionHistories, + popup_nested_histories: HashMap, + claimed_child_histories: HashMap>, rendering_updates: rendering_updates::RenderingUpdateState, scroll_observable_effect_batch: interaction_batch::ScrollObservableEffectBatchState, view_transition_updates: view_transition_updates::ViewTransitionUpdateState, diff --git a/moli-renderer-v8/src/native_bridge/context_host/navigation.rs b/moli-renderer-v8/src/native_bridge/context_host/navigation.rs index 4b2c0320a7..94356d4b8a 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/navigation.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/navigation.rs @@ -98,6 +98,16 @@ impl JsContextHost { self.top_level_navigation_history.clone() } + pub(crate) fn restore_top_level_navigation_history( + &mut self, + source: &crate::runtime::RendererNavigationHistory, + seed: &NavigationHistoryEntrySeed, + ) { + self.top_level_navigation_history = source.restore_for(seed); + self.top_level_navigation_history + .install_joint_history(&mut self.joint_histories); + } + pub(in crate::native_bridge::context_host) fn child_frame_attribute_initiator_has_same_origin( &self, handle: DomHandle, diff --git a/moli-renderer-v8/src/native_bridge/context_host/popups.rs b/moli-renderer-v8/src/native_bridge/context_host/popups.rs index 909705dafc..180b389f38 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/popups.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/popups.rs @@ -746,6 +746,8 @@ impl JsContextHost { let Some(transition) = self.take_lightweight_popup_close_transition(popup_id) else { return false; }; + self.popup_nested_histories + .remove(&OwnerDispatchScope::LightweightPopup(popup_id)); let window = self.lightweight_popup_window(scope, popup_id); self.unregister_service_worker_popup_client(popup_id); self.cancel_lightweight_popup_document_loads(popup_id); @@ -3006,6 +3008,7 @@ impl JsContextHost { popup_id: u64, document_handle: DomHandle, ) { + self.claimed_child_histories.remove(&document_handle); if self .lightweight_popup_document_handles .get(&document_handle) diff --git a/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/documents.rs b/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/documents.rs index e7703f70e3..fae364f40e 100644 --- a/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/documents.rs +++ b/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/documents.rs @@ -208,7 +208,9 @@ fn import_detached_document_children_from_host<'s>( document: v8::Local<'s, v8::Object>, parsed: &DomHost, ) -> Option<()> { - import_detached_document_children_from_host_with_reaction_policy(scope, document, parsed, false) + import_detached_document_children_from_host_with_reaction_policy( + scope, document, parsed, false, false, + ) } fn import_detached_document_children_from_host_with_reaction_policy<'s>( @@ -216,6 +218,7 @@ fn import_detached_document_children_from_host_with_reaction_policy<'s>( document: v8::Local<'s, v8::Object>, parsed: &DomHost, append_to_current_reaction_queue: bool, + preserve_parser_frames: bool, ) -> Option<()> { let runtime_ptr = context_host_ptr_from_global_bridge(scope)?; let document_handle = detached_native_handle(scope, document)?; @@ -224,9 +227,34 @@ fn import_detached_document_children_from_host_with_reaction_policy<'s>( .collect::>(); let lazy_native_import = parsed.dom().len() > 1_000; for child in children { + let mut imported_handles = std::collections::HashMap::new(); let imported = unsafe { &mut *runtime_ptr } .dom_host_mut() - .import_foreign_node_with_shadow_roots(document_handle, parsed, child, true)?; + .import_foreign_node_with_shadow_roots_and_handle_map( + document_handle, + parsed, + child, + true, + &mut imported_handles, + )?; + if preserve_parser_frames { + // This is a navigation Document projection, not author importNode. + // Preserve parser frame identity before insertion can discover its + // child navigables. Other clone/import paths intentionally clear it. + let runtime = unsafe { &mut *runtime_ptr }; + for (source, destination) in imported_handles { + if parsed + .node(source) + .is_some_and(|node| node.flags().parser_created()) + && ["iframe", "frame", "object", "embed"] + .iter() + .any(|name| parsed.is_html_element_named(source, name)) + && let Some(node) = runtime.dom_host_mut().node_mut(destination) + { + node.set_parser_created(true); + } + } + } crate::native_bridge::element::queue_parser_details_toggle_events_in_subtree( scope, runtime_ptr, @@ -268,7 +296,9 @@ pub(crate) fn build_detached_document_object_from_dom_host<'s>( kind: &str, parsed: DomHost, ) -> Option> { - build_detached_document_object_from_dom_host_with_content_type(scope, kind, parsed, None, None) + build_detached_document_object_from_dom_host_with_content_type( + scope, kind, parsed, None, None, false, + ) } pub(crate) fn build_detached_document_object_from_dom_host_with_content_type<'s>( @@ -277,6 +307,7 @@ pub(crate) fn build_detached_document_object_from_dom_host_with_content_type<'s> parsed: DomHost, content_type: Option<&str>, character_set: Option<&str>, + preserve_parser_frames: bool, ) -> Option> { let url = detached_document_url(&parsed); let quirks_mode = parsed.dom().document()?.quirks_mode(); @@ -292,7 +323,13 @@ pub(crate) fn build_detached_document_object_from_dom_host_with_content_type<'s> character_set, allow_declarative_shadow_roots, )?; - import_detached_document_children_from_host(scope, document, &parsed)?; + if preserve_parser_frames { + import_detached_document_children_from_host_with_reaction_policy( + scope, document, &parsed, false, true, + )?; + } else { + import_detached_document_children_from_host(scope, document, &parsed)?; + } Some(document) } diff --git a/moli-renderer-v8/src/native_bridge/joint_history.rs b/moli-renderer-v8/src/native_bridge/joint_history.rs index 4c2716d49f..ea3eb22bae 100644 --- a/moli-renderer-v8/src/native_bridge/joint_history.rs +++ b/moli-renderer-v8/src/native_bridge/joint_history.rs @@ -7,6 +7,10 @@ use std::collections::{BTreeSet, HashMap, VecDeque}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct SessionHistoryStep(u64); +impl SessionHistoryStep { + pub(crate) const INITIAL: Self = Self(0); +} + #[derive(Clone, Debug)] pub(crate) struct JointHistoryEntry { pub(crate) index: u32, @@ -26,12 +30,103 @@ struct EntryPosition { step: SessionHistoryStep, } -#[derive(Debug)] -struct NavigableHistory { +#[derive(Clone, Debug)] +pub(crate) struct NavigableHistory { entries: Vec, current_key: String, } +impl NavigableHistory { + pub(crate) fn new( + snapshot: JointHistorySnapshot, + first_step: SessionHistoryStep, + ) -> Option { + let current_key = snapshot + .entries + .iter() + .find(|entry| entry.index == snapshot.current_index)? + .key + .clone(); + Some(Self { + entries: snapshot + .entries + .into_iter() + .map(|entry| EntryPosition { + step: SessionHistoryStep(first_step.0 + u64::from(entry.navigation_index)), + entry, + }) + .collect(), + current_key, + }) + } + pub(crate) fn entry_at(&self, step: SessionHistoryStep) -> Option<&JointHistoryEntry> { + self.entries + .iter() + .rev() + .find(|entry| entry.step <= step) + .map(|entry| &entry.entry) + } + + pub(crate) fn retain_through(&mut self, step: SessionHistoryStep) { + self.entries.retain(|entry| entry.step <= step); + } + + pub(crate) fn contains(&self, key: &str) -> bool { + self.entries.iter().any(|entry| entry.entry.key == key) + } +} + +/// Committed history positions survive the renderer and DOM handles that +/// happened to present them. Child entries are restored separately when their +/// parent Document recreates its navigables. +#[derive(Clone, Debug)] +pub(crate) struct JointHistoryRestoration { + root: NavigableHistory, + current_step: SessionHistoryStep, + steps: BTreeSet, +} + +impl JointHistoryRestoration { + pub(crate) fn current_step(&self) -> SessionHistoryStep { + self.current_step + } + pub(crate) fn for_navigation( + self, + snapshot: JointHistorySnapshot, + navigation_type: Option<&str>, + target_step: Option, + ) -> Option { + let root = OwnerDispatchScope::Top; + let mut histories = JointSessionHistories::default(); + histories.restore_root(root, self); + let joint = histories.get_mut(root)?; + match navigation_type { + Some("push") => { + joint.push(root, snapshot); + } + Some("traverse") => { + let key = &snapshot + .entries + .iter() + .find(|entry| entry.index == snapshot.current_index)? + .key; + let target = joint.plan_entry(root, key)?; + let entry_step = joint + .navigables + .get(&root)? + .entries + .iter() + .find(|entry| &entry.entry.key == key)? + .step; + joint.update_current(root, snapshot, Some(entry_step)); + joint.current_step = target_step.unwrap_or(target.step); + } + _ => joint.replace(root, snapshot), + } + joint.restoration(root) + } +} + #[derive(Clone, Debug)] pub(crate) struct JointHistoryTarget { pub(crate) owner: OwnerDispatchScope, @@ -85,6 +180,24 @@ pub(crate) struct JointSessionHistories { } impl JointSessionHistories { + pub(crate) fn restore_root( + &mut self, + owner: OwnerDispatchScope, + restoration: JointHistoryRestoration, + ) { + self.histories.insert( + owner, + JointSessionHistory { + current_step: restoration.current_step, + revision: 0, + navigables: IndexMap::from([(owner, restoration.root)]), + retired_steps: restoration.steps, + pending_traversal: None, + deferred_mutations: VecDeque::new(), + completed_traversal: None, + }, + ); + } pub(crate) fn root_for_owner(&self, owner: OwnerDispatchScope) -> Option { self.histories .iter() @@ -127,6 +240,33 @@ impl JointSessionHistories { } impl JointSessionHistory { + pub(crate) fn restoration(&self, root: OwnerDispatchScope) -> Option { + Some(JointHistoryRestoration { + root: self.navigables.get(&root)?.clone(), + current_step: self.current_step, + steps: self.used_steps().into_iter().collect(), + }) + } + + pub(crate) fn navigable_history(&self, owner: OwnerDispatchScope) -> Option { + self.navigables.get(&owner).cloned() + } + + pub(crate) fn restore_child( + &mut self, + owner: OwnerDispatchScope, + mut history: NavigableHistory, + current_key: &str, + ) { + history.current_key = current_key.to_owned(); + self.navigables.insert(owner, history); + } + + pub(crate) fn destination_step(&self) -> SessionHistoryStep { + self.pending_traversal + .as_ref() + .map_or(self.current_step, |pending| pending.step) + } pub(crate) fn reset(&mut self, snapshots: Vec<(OwnerDispatchScope, JointHistorySnapshot)>) { self.navigables.clear(); self.retired_steps.clear(); @@ -575,6 +715,63 @@ mod tests { } } + #[test] + fn restored_root_and_children_preserve_interleaved_session_history_steps() { + let root = OwnerDispatchScope::Top; + let old_a = OwnerDispatchScope::Child(DomHandle::new(1)); + let old_b = OwnerDispatchScope::Child(DomHandle::new(2)); + let mut histories = JointSessionHistories::default(); + let original = histories + .ensure_root(root, snapshot(&["parent"], 0)) + .unwrap(); + original.ensure_child(old_a, snapshot(&["a0"], 0)); + original.ensure_child(old_b, snapshot(&["b0"], 0)); + original.push(old_a, snapshot(&["a0", "a1"], 1)); + original.push(old_b, snapshot(&["b0", "b1"], 1)); + original.push(old_a, snapshot(&["a0", "a1", "a2"], 2)); + let a = original.navigable_history(old_a).unwrap(); + let b = original.navigable_history(old_b).unwrap(); + let source = original.restoration(root).unwrap(); + let away = source + .for_navigation(snapshot(&["parent", "away"], 1), Some("push"), None) + .unwrap(); + let back = away + .for_navigation( + snapshot(&["parent", "away"], 0), + Some("traverse"), + Some(SessionHistoryStep(3)), + ) + .unwrap(); + drop(histories); + + let mut histories = JointSessionHistories::default(); + histories.restore_root(root, back); + let restored = histories.get_mut(root).unwrap(); + let new_a = OwnerDispatchScope::Child(DomHandle::new(101)); + let new_b = OwnerDispatchScope::Child(DomHandle::new(102)); + restored.restore_child(new_a, a.clone(), "a2"); + restored.restore_child(new_b, b.clone(), "b1"); + assert_eq!(restored.length(), 5); + for (owner, key) in [(new_a, "a1"), (new_b, "b0"), (new_a, "a0")] { + let plan = restored.plan_delta(restored.current_step(), -1).unwrap(); + assert_eq!(plan.targets.len(), 1); + assert_eq!(plan.targets[0].owner, owner); + assert_eq!(plan.targets[0].key, key); + restored.begin_traversal(plan); + restored.commit_traversal_entry(owner, key); + } + assert_eq!(restored.current_step(), SessionHistoryStep(0)); + assert_eq!( + restored + .plan_delta(restored.current_step(), 4) + .unwrap() + .step, + SessionHistoryStep(4) + ); + assert_eq!(a.entry_at(SessionHistoryStep(1)).unwrap().key, "a1"); + assert_eq!(b.entry_at(SessionHistoryStep(1)).unwrap().key, "b0"); + } + #[test] fn interleaved_children_share_steps_and_prune_each_others_forward_entries() { let root = OwnerDispatchScope::Top; diff --git a/moli-renderer-v8/src/runtime/navigation_history.rs b/moli-renderer-v8/src/runtime/navigation_history.rs index d557cf308b..af392ee6a1 100644 --- a/moli-renderer-v8/src/runtime/navigation_history.rs +++ b/moli-renderer-v8/src/runtime/navigation_history.rs @@ -8,7 +8,12 @@ use moli_page_types::{ use parking_lot::Mutex; use url::Url; -use crate::native_bridge::{NavigationHistoryEntrySeed, NavigationHistorySerializedEntry}; +use crate::native_bridge::{ + NavigationHistoryDocumentId, NavigationHistoryEntrySeed, NavigationHistorySerializedEntry, + NestedHistoryStore, OwnerDispatchScope, + joint_history::{JointHistoryRestoration, SessionHistoryStep}, + joint_snapshot, +}; /// The committed history of a renderer's top-level Document. Publishing a /// snapshot happens at a history mutation, never while capturing Page state. @@ -17,6 +22,9 @@ use crate::native_bridge::{NavigationHistoryEntrySeed, NavigationHistorySerializ #[derive(Clone, Debug, Default)] pub struct RendererNavigationHistory { snapshot: Arc>>>, + joint: Arc>>, + nested: NestedHistoryStore, + selected_step: Arc>>, } impl PartialEq for RendererNavigationHistory { @@ -28,6 +36,73 @@ impl PartialEq for RendererNavigationHistory { impl Eq for RendererNavigationHistory {} impl RendererNavigationHistory { + pub(crate) fn nested_history(&self) -> NestedHistoryStore { + self.nested.clone() + } + + pub(crate) fn current_document_id(&self) -> Option { + Some( + current_entry(self.snapshot()?.as_ref())? + .document_id + .clone(), + ) + } + + pub(crate) fn publish_joint_history(&self, joint: Option) { + *self.joint.lock() = joint; + } + + pub(crate) fn select_joint_traversal(&self, key: String, step: SessionHistoryStep) { + *self.selected_step.lock() = Some((key, step)); + } + + pub(crate) fn restore_for(&self, seed: &NavigationHistoryEntrySeed) -> Self { + if seed + .activation + .as_ref() + .is_some_and(|activation| activation.navigation_type.as_deref() == Some("push")) + && let Some(joint) = self.joint.lock().as_ref() + { + self.nested.prune( + joint.current_step(), + &seed + .entries + .iter() + .map(|entry| entry.document_id.clone()) + .collect::>(), + ); + } + let current = current_entry(seed); + let target_step = self.selected_step.lock().as_ref().and_then(|(key, step)| { + current + .filter(|entry| entry.key.as_str() == key) + .map(|_| *step) + }); + let joint = self.joint.lock().clone().and_then(|joint| { + joint.for_navigation( + joint_snapshot(seed), + seed.activation + .as_ref() + .and_then(|activation| activation.navigation_type.as_deref()), + target_step, + ) + }); + Self { + snapshot: Arc::new(Mutex::new(Some(Arc::new(seed.clone())))), + joint: Arc::new(Mutex::new(joint)), + nested: self.nested.clone(), + selected_step: Arc::default(), + } + } + + pub(crate) fn install_joint_history( + &self, + histories: &mut crate::native_bridge::joint_history::JointSessionHistories, + ) { + if let Some(joint) = self.joint.lock().clone() { + histories.restore_root(OwnerDispatchScope::Top, joint); + } + } pub(crate) fn publish(&self, seed: NavigationHistoryEntrySeed) { *self.snapshot.lock() = Some(Arc::new(seed)); } @@ -95,6 +170,7 @@ impl RendererNavigationHistory { requested: Arc::new(seed), source_index, initial_empty_source: false, + selected_step: self.selected_step.lock().clone(), } } } @@ -109,6 +185,7 @@ pub struct RendererNavigationHistoryRequest { requested: Arc, source_index: Option, initial_empty_source: bool, + selected_step: Option<(String, SessionHistoryStep)>, } impl PartialEq for RendererNavigationHistoryRequest { @@ -122,6 +199,11 @@ impl PartialEq for RendererNavigationHistoryRequest { impl Eq for RendererNavigationHistoryRequest {} impl RendererNavigationHistoryRequest { + pub(crate) fn source_history(&self) -> RendererNavigationHistory { + let mut source = self.source.clone(); + source.selected_step = Arc::new(Mutex::new(self.selected_step.clone())); + source + } /// The browser owns the initial-empty Document lifecycle. Its first /// navigation has no activation source. Same-document URL updates do not /// change that lifecycle state. diff --git a/moli-renderer-v8/src/runtime/owner.rs b/moli-renderer-v8/src/runtime/owner.rs index fb444dd445..8f591dc653 100644 --- a/moli-renderer-v8/src/runtime/owner.rs +++ b/moli-renderer-v8/src/runtime/owner.rs @@ -6915,6 +6915,9 @@ impl RendererOwnerHandle { .as_ref() .map(|request| request.resolve(&final_url)) .transpose()?, + navigation_history_source: navigation_history + .as_ref() + .map(|request| request.source_history()), reserved_service_worker_client_id: reserved_service_worker_client .map(RendererReservedServiceWorkerClient::release), }; @@ -7215,6 +7218,7 @@ impl RendererOwnerHandle { main_document_commit, top_level_storage_key: None, navigation_bootstrap_entry: navigation_history.as_ref().map(|request| request.resolve(&final_url)).transpose()?, + navigation_history_source: navigation_history.as_ref().map(|request| request.source_history()), reserved_service_worker_client_id: reserved_service_worker_client .map(RendererReservedServiceWorkerClient::release), }; diff --git a/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs b/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs index 855d823ea3..3f507a0b3e 100644 --- a/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs +++ b/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs @@ -1211,6 +1211,7 @@ impl PageVm { root_frame_id: self.vm().root_frame_id().map(str::to_owned), top_level_storage_key: None, navigation_bootstrap_entry: None, + navigation_history_source: Some(self.vm().top_level_navigation_history()), reserved_service_worker_client_id: None, } } diff --git a/moli-renderer-v8/src/runtime/page_vm/mod.rs b/moli-renderer-v8/src/runtime/page_vm/mod.rs index abd94044d4..929805ecf4 100644 --- a/moli-renderer-v8/src/runtime/page_vm/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/mod.rs @@ -1099,6 +1099,7 @@ pub(crate) struct PageVmEnvConfig { pub(crate) layout_policy: LayoutPolicy, pub(crate) wpt_extensions_enabled: bool, pub(crate) navigation_bootstrap_entry: Option, + pub(crate) navigation_history_source: Option, pub(crate) reserved_service_worker_client_id: Option, } @@ -4466,9 +4467,10 @@ impl PageVm { env.fetch_subresource_interception_enabled, env.fetch_subresource_interception_resource_type, ); - page_vm - .vm_mut() - .install_navigation_bootstrap_entry(env.navigation_bootstrap_entry.clone()); + page_vm.vm_mut().install_navigation_bootstrap_from_history( + env.navigation_bootstrap_entry.clone(), + env.navigation_history_source.clone(), + ); Ok(page_vm) } diff --git a/moli-renderer-v8/src/runtime/page_vm/test_support.rs b/moli-renderer-v8/src/runtime/page_vm/test_support.rs index 359d7d853d..98237c4bab 100644 --- a/moli-renderer-v8/src/runtime/page_vm/test_support.rs +++ b/moli-renderer-v8/src/runtime/page_vm/test_support.rs @@ -699,6 +699,7 @@ fn minimal_test_page_vm_env_config() -> PageVmEnvConfig { layout_policy: crate::real_layout_test_policy(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, } } diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index 84a75a16f7..94948c2029 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -1828,6 +1828,7 @@ fn test_page_vm_with_loader_dom_host_hooks_and_response_referrer_policy( layout_policy: crate::real_layout_test_policy(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, runtime_hooks, @@ -3231,6 +3232,7 @@ fn default_runtime_hooks_reject_direct_no_owner_page_vm_construction() { layout_policy: crate::real_layout_test_policy(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::default(), diff --git a/moli-renderer-v8/src/runtime/phase_one/mod.rs b/moli-renderer-v8/src/runtime/phase_one/mod.rs index 4949761a3f..443ca946bb 100644 --- a/moli-renderer-v8/src/runtime/phase_one/mod.rs +++ b/moli-renderer-v8/src/runtime/phase_one/mod.rs @@ -2582,6 +2582,7 @@ document.body.setAttribute('data-error-state', [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, } } @@ -6510,6 +6511,7 @@ globalThis.__outerDocumentWriteScriptContinued = true; layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -15082,6 +15084,7 @@ document.body.setAttribute('data-result', [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -15262,6 +15265,7 @@ document.body.setAttribute('data-result', [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -15491,6 +15495,7 @@ document.body.setAttribute('data-result', [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -17490,6 +17495,7 @@ document.body.setAttribute("data-range", [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -17618,6 +17624,7 @@ document.body.setAttribute("data-range", [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -17756,6 +17763,7 @@ document.body.setAttribute("data-range", [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -17929,6 +17937,7 @@ document.body.setAttribute("data-range", [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -18252,6 +18261,7 @@ document.body.setAttribute("data-range", [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -18422,6 +18432,7 @@ document.body.setAttribute("data-range", [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), @@ -18524,6 +18535,7 @@ document.body.setAttribute("data-range", [ layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), diff --git a/moli-renderer-v8/src/runtime/phase_one/streaming.rs b/moli-renderer-v8/src/runtime/phase_one/streaming.rs index d3862df631..f8c443c2bc 100644 --- a/moli-renderer-v8/src/runtime/phase_one/streaming.rs +++ b/moli-renderer-v8/src/runtime/phase_one/streaming.rs @@ -960,6 +960,7 @@ mod tests { layout_policy: moli_page_types::LayoutPolicy::default(), wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, } } diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index bfde07c46e..6818ad73cc 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -6004,14 +6004,27 @@ impl ScriptVm { }) } + #[cfg(test)] pub(super) fn install_navigation_bootstrap_entry( &mut self, entry_seed: Option, + ) { + self.install_navigation_bootstrap_from_history(entry_seed, None); + } + + pub(super) fn install_navigation_bootstrap_from_history( + &mut self, + entry_seed: Option, + source: Option, ) { let Some(entry_seed) = entry_seed else { return; }; - let _ = self.with_default_context_scope(|scope, _runtime_ptr| { + let _ = self.with_default_context_scope(|scope, runtime_ptr| { + if let Some(source) = &source { + unsafe { &mut *runtime_ptr } + .restore_top_level_navigation_history(source, &entry_seed); + } super::context_bootstrap::install_navigation_bootstrap_entry(scope, &entry_seed); Ok(()) });