From 5e045a3c19c54c6d5aef7b33058628772285a024 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 22 Sep 2026 19:30:56 +0800 Subject: [PATCH] fix(history): restore nested histories across document replacement Keep child entries and srcdoc resources with their parent Document identity when top-level and popup Documents are replaced. Restore their original joint history steps, avoid traversing descendants of a retiring parent, and prune forward entries when navigation branches from an earlier step. Preserve parser-created frame identities in navigation projections. Cover named and unnamed frames, script-created parents, nested Documents, entry state and identity, and interleaved multi-step traversals through CDP. --- .../tests/tests_cdp_chromium_imports/mod.rs | 1 + .../nested_history.rs | 410 ++++++ .../target/tests/tests_cdp_smoke_fixture.rs | 1 + moli-renderer-v8/src/context_bootstrap.rs | 2 + .../src/context_bootstrap/navigation_entry.rs | 2 +- .../context_bootstrap/navigation_serialize.rs | 10 +- .../src/context_bootstrap/session_history.rs | 4 + moli-renderer-v8/src/dom_parser.rs | 8 +- .../context_host/child_documents/commit.rs | 1 + .../child_frame_navigation/snapshots.rs | 6 +- .../context_host/child_frames.rs | 6 + .../child_frames/nested_history.rs | 292 ++++ .../context_host/child_frames/registry.rs | 24 + .../src/native_bridge/context_host/core.rs | 2 + .../src/native_bridge/context_host/mod.rs | 3 + .../native_bridge/context_host/navigation.rs | 10 + .../src/native_bridge/context_host/popups.rs | 3 + .../context_host/session_history.rs | 7 + .../detached_objects/builders/documents.rs | 45 +- .../src/runtime/navigation_history.rs | 91 +- .../src/runtime/owner/document_response.rs | 2 + .../src/runtime/owner/page_creation.rs | 3 + .../runtime/page_vm/followed_navigation.rs | 1 + moli-renderer-v8/src/runtime/page_vm/mod.rs | 6 +- .../src/runtime/page_vm/test_support.rs | 1 + .../tests/extracted/page_task_dispatch.rs | 1 + .../src/runtime/page_vm/tests/mod.rs | 1 + moli-renderer-v8/src/runtime/phase_one/mod.rs | 1 + .../src/runtime/phase_one/streaming.rs | 1 + .../extracted/parser_blocking_scripts.rs | 737 +++++----- .../tests/extracted/parser_script_handoffs.rs | 1216 +++++++++-------- .../tree_mutations_and_document_write.rs | 357 ++--- .../src/script_vm/child_frame_runtime.rs | 15 +- moli-session-history/src/joint.rs | 13 + 34 files changed, 2121 insertions(+), 1162 deletions(-) create mode 100644 moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/nested_history.rs create mode 100644 moli-renderer-v8/src/native_bridge/context_host/child_frames/nested_history.rs 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 e1b104ca0c..7f1ef33619 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -81,6 +81,8 @@ mod navigation_result; mod navigation_seed; mod navigation_serialize; pub(crate) use navigation_serialize::current_document_referrer_policy; +pub(crate) use navigation_entry::navigation_entry_public_token; +pub(crate) use navigation_serialize::history_document_id_for_holder; mod navigation_surface; mod navigation_transition_worlds; mod navigation_traversal; diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_entry.rs b/moli-renderer-v8/src/context_bootstrap/navigation_entry.rs index 728459efb6..6b5e5270aa 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_entry.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_entry.rs @@ -186,7 +186,7 @@ pub(super) fn navigation_entry_referrer_policy_value<'s>( navigation_entry_stored_string(scope, entry, EntryStringField::ReferrerPolicy) } -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 c52ef15294..5cc2abef59 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_serialize.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_serialize.rs @@ -16,7 +16,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 { @@ -30,6 +30,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/context_bootstrap/session_history.rs b/moli-renderer-v8/src/context_bootstrap/session_history.rs index 3a06c904a3..2f156630d8 100644 --- a/moli-renderer-v8/src/context_bootstrap/session_history.rs +++ b/moli-renderer-v8/src/context_bootstrap/session_history.rs @@ -147,6 +147,8 @@ pub(super) fn restore<'s>( if let Some(update) = update { publish(scope, owner, update); } + let root = binding.popup.map_or(OwnerDispatchScope::Top, OwnerDispatchScope::LightweightPopup); + unsafe { &mut *host_ptr }.remember_nested_histories(root); } pub(crate) fn install_session_history_position( @@ -248,6 +250,8 @@ pub(super) fn commit<'s>( if let Some(update) = update { publish(scope, owner, update); } + let root = binding.popup.map_or(OwnerDispatchScope::Top, OwnerDispatchScope::LightweightPopup); + unsafe { &mut *host_ptr }.remember_nested_histories(root); } /// Capture the next top-level Document's history without committing a pending diff --git a/moli-renderer-v8/src/dom_parser.rs b/moli-renderer-v8/src/dom_parser.rs index b66cec2507..f0e28ca1be 100644 --- a/moli-renderer-v8/src/dom_parser.rs +++ b/moli-renderer-v8/src/dom_parser.rs @@ -387,13 +387,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, ) } @@ -460,6 +460,7 @@ fn build_detached_document_with_content_type<'s>( DomHost::from_dom(parsed), content_type, None, + false, ) } @@ -488,6 +489,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 98b8ab00ab..5ef1286cfe 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 @@ -229,6 +229,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/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 832dd69245..d8b1bec097 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}; 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, @@ -43,6 +46,9 @@ pub(in crate::native_bridge::context_host) use request_scope::{ pub(super) struct ChildBrowsingContextEntry { frame_id: String, retiring: bool, + 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..9d3593ace5 --- /dev/null +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frames/nested_history.rs @@ -0,0 +1,292 @@ +use super::*; +use crate::context_bootstrap::navigation_entry_public_token; +use crate::native_bridge::OwnerDispatchScope; +use moli_page_types::{JointSessionHistory, SessionHistoryContextId, SessionHistoryEntry, SessionHistoryStepId}; + +#[derive(Clone, Debug)] +pub(crate) struct NavigableHistory { + context: SessionHistoryContextId, + entries: Vec<(SessionHistoryStepId, SessionHistoryEntry)>, +} + +impl NavigableHistory { + fn contains(&self, key: &str) -> bool { self.entries.iter().any(|(_, entry)| entry.key.as_str() == key) } + fn entry_at(&self, step: SessionHistoryStepId) -> Option<&SessionHistoryEntry> { self.entries.iter().find(|(id, _)| *id == step).map(|(_, entry)| entry) } + fn retain_in(&mut self, history: &JointSessionHistory) { self.entries.retain(|(id, _)| history.entries_at(*id).is_some()); } +} + +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, history: &JointSessionHistory, roots: &[NavigationHistoryDocumentId]) { + let mut records = self.records.lock(); + for record in records.values_mut() { + record.positions.retain_in(history); + 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() + }); + } +} + +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 popup = match root { OwnerDispatchScope::LightweightPopup(id) => Some(id), _ => None }; + let step = self.session_histories.get_mut(popup).current_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.as_str())? + .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(); + let root = self.nested_history_root(handle).unwrap_or(OwnerDispatchScope::Top); + let popup = match root { OwnerDispatchScope::LightweightPopup(id) => Some(id), _ => None }; + let parent = self.owner_dispatch_scope_for_node(handle).unwrap_or(root); + let parent = self.session_histories.context(parent); + self.session_histories.bind_context(OwnerDispatchScope::Child(handle), record.positions.context); + self.session_histories.get_mut(popup).restore_context(record.positions.context, parent, &record.positions.entries); + 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, + None, + ); + } + + 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 context = self.session_histories.context(OwnerDispatchScope::Child(handle)); + let popup = match root { OwnerDispatchScope::LightweightPopup(id) => Some(id), _ => None }; + let live_positions = self.session_histories.get_mut(popup).context_entries(context); + 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 positions = (!live_positions.is_empty()).then_some(NavigableHistory { context, entries: live_positions }) + .or_else(|| entry.restored_history_positions.clone()) + .or_else(|| store.records.lock().get(&identity).map(|record| record.positions.clone())); + 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(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( + Some(self.session_histories.get_mut(None).clone()), + ); + } + } +} 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 17a473421b..b603c9d3db 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 @@ -103,6 +103,7 @@ impl JsContextHost { self.retire_web_mcp_document(crate::native_bridge::WindowDocumentOwner::Frame( retired_owner, )); + self.claimed_child_histories.remove(&document_handle); let _ = self.retire_document_resource_loader( crate::native_bridge::WindowDocumentOwner::Frame(retired_owner), ); @@ -170,6 +171,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)); @@ -375,10 +387,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, ); @@ -513,6 +527,13 @@ impl JsContextHost { ChildBrowsingContextEntry { frame_id, retiring: false, + 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) }), @@ -653,6 +674,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 216d264d0f..9191e29151 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/core.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/core.rs @@ -483,6 +483,8 @@ impl JsContextHost { pending_webcrypto_tasks: HashMap::new(), opfs_owner_state: None, history_queue: HistoryQueueState::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 f36e657230..f117a43ffc 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/mod.rs @@ -197,6 +197,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}; pub(crate) use event_callbacks::{EventCallbackId, PreparedEventCallback}; pub(crate) use host_loads::ChildFrameAttachmentSnapshot; #[cfg(test)] @@ -979,6 +980,8 @@ pub(crate) struct JsContextHost { >, opfs_owner_state: Option, pub(super) history_queue: HistoryQueueState, + 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 eb2ef4638e..ea0e57ec40 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/navigation.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/navigation.rs @@ -122,6 +122,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(self.session_histories.get_mut(None)); + } + 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 08883fd942..27b012583c 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/popups.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/popups.rs @@ -743,6 +743,8 @@ impl JsContextHost { }; let window_handle = self.lightweight_popup_record(popup_id).expect("closed popup record retained").auxiliary_window.clone(); self.request_auxiliary_window_close(window_handle); + 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); @@ -3148,6 +3150,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/context_host/session_history.rs b/moli-renderer-v8/src/native_bridge/context_host/session_history.rs index c353d8168f..eb8193c692 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/session_history.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/session_history.rs @@ -42,6 +42,13 @@ impl RendererSessionHistories { } } + pub(crate) fn bind_context(&mut self, owner: OwnerDispatchScope, context: SessionHistoryContextId) { + if let Some(previous) = self.contexts.insert(owner, context) && previous != context { + self.main.detach(previous); + for history in self.popups.values_mut() { history.detach(previous); } + } + } + pub(crate) fn context(&mut self, owner: OwnerDispatchScope) -> SessionHistoryContextId { match owner { OwnerDispatchScope::Top | OwnerDispatchScope::LightweightPopup(_) => { 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 6acfb37889..e99e0be448 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 @@ -206,7 +206,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>( @@ -214,6 +216,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)?; @@ -222,9 +225,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, @@ -265,7 +293,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>( @@ -274,6 +304,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(); @@ -289,7 +320,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/runtime/navigation_history.rs b/moli-renderer-v8/src/runtime/navigation_history.rs index d557cf308b..fabc747353 100644 --- a/moli-renderer-v8/src/runtime/navigation_history.rs +++ b/moli-renderer-v8/src/runtime/navigation_history.rs @@ -6,9 +6,14 @@ use moli_page_types::{ traversal_navigation_seed_candidate, }; use parking_lot::Mutex; +use moli_page_types::{JointSessionHistory, SessionHistoryStepId, SessionHistoryContextId, SessionHistoryEntry}; use url::Url; -use crate::native_bridge::{NavigationHistoryEntrySeed, NavigationHistorySerializedEntry}; +use crate::native_bridge::{ + NavigationHistoryDocumentId, NavigationHistoryEntrySeed, NavigationHistorySerializedEntry, + NestedHistoryStore, + +}; /// 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,80 @@ 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: SessionHistoryStepId) { + *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, + &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(|mut joint| { + let current = current_entry(seed)?; + let entry = SessionHistoryEntry { + key: moli_page_types::NavigationHistoryEntryKey::from_serialized(crate::context_bootstrap::navigation_entry_public_token(current.key.as_str())), + document: current.document_id.clone(), + }; + match seed.session_history.commit { + moli_page_types::SessionHistoryCommit::Push => joint.push(SessionHistoryContextId::ROOT, entry), + moli_page_types::SessionHistoryCommit::Traverse => { + let step = target_step.or(seed.session_history.target_step).or_else(|| joint.step_for_entry(SessionHistoryContextId::ROOT, &entry.key))?; + joint.traverse(step)?; + } + _ => joint.replace(SessionHistoryContextId::ROOT, entry), + } + Some(joint) + }); + 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, + history: &mut JointSessionHistory, + ) { + if let Some(joint) = self.joint.lock().clone() { + *history = joint; + } + } pub(crate) fn publish(&self, seed: NavigationHistoryEntrySeed) { *self.snapshot.lock() = Some(Arc::new(seed)); } @@ -95,6 +177,7 @@ impl RendererNavigationHistory { requested: Arc::new(seed), source_index, initial_empty_source: false, + selected_step: self.selected_step.lock().clone(), } } } @@ -109,6 +192,7 @@ pub struct RendererNavigationHistoryRequest { requested: Arc, source_index: Option, initial_empty_source: bool, + selected_step: Option<(String, SessionHistoryStepId)>, } impl PartialEq for RendererNavigationHistoryRequest { @@ -122,6 +206,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/document_response.rs b/moli-renderer-v8/src/runtime/owner/document_response.rs index d1caf35ec2..351069f589 100644 --- a/moli-renderer-v8/src/runtime/owner/document_response.rs +++ b/moli-renderer-v8/src/runtime/owner/document_response.rs @@ -44,6 +44,7 @@ impl RendererCreateStreamingRawPageRequest { document_replacement: _document_replacement, root_frame_id, main_document_commit, + navigation_history, final_url, response_status, response_headers, @@ -112,6 +113,7 @@ impl RendererCreateStreamingRawPageRequest { main_document_commit, top_level_storage_key: None, navigation_bootstrap_entry, + navigation_history_source: navigation_history.as_ref().map(|history| history.source_history()), reserved_service_worker_client_id: reserved_service_worker_client .map(RendererReservedServiceWorkerClient::release), }; diff --git a/moli-renderer-v8/src/runtime/owner/page_creation.rs b/moli-renderer-v8/src/runtime/owner/page_creation.rs index cd3833d5f4..539596dc3e 100644 --- a/moli-renderer-v8/src/runtime/owner/page_creation.rs +++ b/moli-renderer-v8/src/runtime/owner/page_creation.rs @@ -935,6 +935,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, }; env.apply_main_document_commit_referrer(); 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 6aa11b7479..e3d7f3102c 100644 --- a/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs +++ b/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs @@ -1440,6 +1440,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 5554b1e321..f12ad25195 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_configuration: moli_page_types::LayoutConfiguration, 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, } @@ -4636,7 +4637,10 @@ impl PageVm { AuxiliaryEnvironmentApply::Navigation | AuxiliaryEnvironmentApply::InheritedNavigation ) { self.vm_mut() - .install_navigation_bootstrap_entry(env.navigation_bootstrap_entry.clone()); + .install_navigation_bootstrap_from_history( + env.navigation_bootstrap_entry.clone(), + env.navigation_history_source.clone(), + ); if env.navigation_bootstrap_entry.is_none() && let Some(position) = env .main_document_commit 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 6eefc84b31..d1bd0a5ef2 100644 --- a/moli-renderer-v8/src/runtime/page_vm/test_support.rs +++ b/moli-renderer-v8/src/runtime/page_vm/test_support.rs @@ -696,6 +696,7 @@ fn minimal_test_page_vm_env_config() -> PageVmEnvConfig { }, 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/extracted/page_task_dispatch.rs b/moli-renderer-v8/src/runtime/page_vm/tests/extracted/page_task_dispatch.rs index df44006e35..8e821ed69d 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/extracted/page_task_dispatch.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/extracted/page_task_dispatch.rs @@ -38,6 +38,7 @@ fn default_runtime_hooks_reject_direct_no_owner_page_vm_construction() { }, 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/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index 164eedfeac..553a9ecafe 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -695,6 +695,7 @@ fn test_page_vm_with_loader_dom_host_hooks_and_response_referrer_policy( }, wpt_extensions_enabled: false, navigation_bootstrap_entry: None, + navigation_history_source: None, reserved_service_worker_client_id: None, }, runtime_hooks, diff --git a/moli-renderer-v8/src/runtime/phase_one/mod.rs b/moli-renderer-v8/src/runtime/phase_one/mod.rs index f39990f613..5594cb864c 100644 --- a/moli-renderer-v8/src/runtime/phase_one/mod.rs +++ b/moli-renderer-v8/src/runtime/phase_one/mod.rs @@ -704,6 +704,7 @@ mod tests { }, 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/phase_one/streaming.rs b/moli-renderer-v8/src/runtime/phase_one/streaming.rs index 94e0e745a3..644e061dc9 100644 --- a/moli-renderer-v8/src/runtime/phase_one/streaming.rs +++ b/moli-renderer-v8/src/runtime/phase_one/streaming.rs @@ -1082,6 +1082,7 @@ mod tests { }, 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/phase_one/tests/extracted/parser_blocking_scripts.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_blocking_scripts.rs index aac11caf20..53f9e713c8 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_blocking_scripts.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_blocking_scripts.rs @@ -664,6 +664,7 @@ fn parser_owner_boundary_with_live_backend_queues_document_turn_before_runtime_w }, 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(), @@ -891,158 +892,159 @@ fn parser_connected_head_script_does_not_push_later_head_tokens_into_body() { .expect("current-thread runtime should build"); runtime.block_on(tokio::task::LocalSet::new().run_until(async move { - let _js_runtime = crate::JsRuntime::initialize(); - let final_url = Url::parse("https://example.test/").expect("test url"); - let loader = - Box::leak(Box::new(ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"))); - let state = Box::leak(Box::new(ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url.clone()))); - let mut driver = ParserDriver { - loader, - final_url: &state.final_url, - parser_session: &mut state.parser_session, - scheduler: &mut state.scheduler, - buffered_document_preloads: &mut state.buffered_document_preloads, - service_worker_preload_context: state.service_worker_preload_context.as_ref(), + let _js_runtime = crate::JsRuntime::initialize(); + let final_url = Url::parse("https://example.test/").expect("test url"); + let loader = + Box::leak(Box::new(ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"))); + let state = Box::leak(Box::new(ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url.clone()))); + let mut driver = ParserDriver { + loader, + final_url: &state.final_url, + parser_session: &mut state.parser_session, + scheduler: &mut state.scheduler, + buffered_document_preloads: &mut state.buffered_document_preloads, + service_worker_preload_context: state.service_worker_preload_context.as_ref(), - input_closed: &state.input_closed, - }; + input_closed: &state.input_closed, + }; - let html = "x
late
"; - let crate::parser::ParserPumpOutcome { - result, - discovered_async_prefetch_scripts: _, - discovered_modulepreload_link_candidates: _, - discovered_blocking_stylesheet_inputs: _, - } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step(html); - let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { - panic!("expected parser step to stop at inline script handoff"); - }; + let html = "x
late
"; + let crate::parser::ParserPumpOutcome { + result, + discovered_async_prefetch_scripts: _, + discovered_modulepreload_link_candidates: _, + discovered_blocking_stylesheet_inputs: _, + } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step(html); + let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { + panic!("expected parser step to stop at inline script handoff"); + }; - let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); - let local_executor = JsLocalExecutor::new(); - let mut page_vm = PageVm::new( - PageId::new_for_testing(1), - local_executor, - loader, - &PageVmEnvConfig { - web_storage: crate::RendererWebStorageHandles::ephemeral(), - root_frame_id: None, - main_document_commit: None, - top_level_storage_key: None, - document_start_scripts: vec![], - runtime_bindings: vec![], - runtime_inspector_session_restore_snapshots: vec![], - runtime_isolated_worlds: vec![], - permission_overrides: vec![], - extra_http_headers: Default::default(), - navigator_identity: Default::default(), - document_policy_container: Default::default(), - document_default_language: None, - document_last_modified: None, - document_settings: Default::default(), - network_offline: false, - blocked_url_patterns: Vec::new(), - indexed_db_manager: None, - storage_bucket_store: None, - fetch_subresource_interception_enabled: false, - fetch_subresource_interception_resource_type: None, - layout_configuration: moli_page_types::LayoutConfiguration { - policy: moli_page_types::LayoutPolicy::default(), - scrollbars_hidden: false, - }, - wpt_extensions_enabled: false, - navigation_bootstrap_entry: None, - reserved_service_worker_client_id: None, -}, - PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), - parser_dom_host, - Instant::now(), - ) - .expect("page vm"); - - let local_executor = page_vm.local_executor.clone(); - let page_vm_ptr: *mut PageVm = &mut page_vm; - let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; - let handoff = handoff.clone(); - let outcome = super::access::run_named_owner_local_task( - local_executor, - "phase-one inline script handoff local task channel closed", - async move { - let page_vm = unsafe { &mut *page_vm_ptr }; - let driver = unsafe { &mut *driver_ptr }; - driver - .handle_parse_time_script_handoff(page_vm, *handoff, None) - .await + let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); + let local_executor = JsLocalExecutor::new(); + let mut page_vm = PageVm::new( + PageId::new_for_testing(1), + local_executor, + loader, + &PageVmEnvConfig { + web_storage: crate::RendererWebStorageHandles::ephemeral(), + root_frame_id: None, + main_document_commit: None, + top_level_storage_key: None, + document_start_scripts: vec![], + runtime_bindings: vec![], + runtime_inspector_session_restore_snapshots: vec![], + runtime_isolated_worlds: vec![], + permission_overrides: vec![], + extra_http_headers: Default::default(), + navigator_identity: Default::default(), + document_policy_container: Default::default(), + document_default_language: None, + document_last_modified: None, + document_settings: Default::default(), + network_offline: false, + blocked_url_patterns: Vec::new(), + indexed_db_manager: None, + storage_bucket_store: None, + fetch_subresource_interception_enabled: false, + fetch_subresource_interception_resource_type: None, + layout_configuration: moli_page_types::LayoutConfiguration { + policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, }, - ) - .await - .expect("inline script handoff should execute"); - assert!( - matches!(outcome, ScriptHandoffOutcome::NoNavigation), - "inline parser-connected script should not navigate in this fixture" - ); + 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(), + parser_dom_host, + Instant::now(), + ) + .expect("page vm"); - let local_executor = page_vm.local_executor.clone(); - let page_vm_ptr: *mut PageVm = &mut page_vm; - let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; - let outcome = super::access::run_named_owner_local_task( - local_executor, - "phase-one inline script continuation local task channel closed", - async move { - let page_vm = unsafe { &mut *page_vm_ptr }; - let driver = unsafe { &mut *driver_ptr }; - driver.advance_parser_step(page_vm, "", None).await - }, - ) - .await - .expect("parser should continue after the inline script"); - assert!( - matches!(outcome, ParserStepAdvanceOutcome::Continue), - "parser should finish the remaining buffered html after the script" - ); + let local_executor = page_vm.local_executor.clone(); + let page_vm_ptr: *mut PageVm = &mut page_vm; + let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; + let handoff = handoff.clone(); + let outcome = super::access::run_named_owner_local_task( + local_executor, + "phase-one inline script handoff local task channel closed", + async move { + let page_vm = unsafe { &mut *page_vm_ptr }; + let driver = unsafe { &mut *driver_ptr }; + driver + .handle_parse_time_script_handoff(page_vm, *handoff, None) + .await + }, + ) + .await + .expect("inline script handoff should execute"); + assert!( + matches!(outcome, ScriptHandoffOutcome::NoNavigation), + "inline parser-connected script should not navigate in this fixture" + ); - let snapshot = page_vm.vm().snapshot_live_document(); - let serialized = snapshot.serialize_document(); - assert!( - serialized.to_ascii_lowercase().contains(""), - "doctype should survive parser-connected script execution: {serialized}" - ); + let local_executor = page_vm.local_executor.clone(); + let page_vm_ptr: *mut PageVm = &mut page_vm; + let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; + let outcome = super::access::run_named_owner_local_task( + local_executor, + "phase-one inline script continuation local task channel closed", + async move { + let page_vm = unsafe { &mut *page_vm_ptr }; + let driver = unsafe { &mut *driver_ptr }; + driver.advance_parser_step(page_vm, "", None).await + }, + ) + .await + .expect("parser should continue after the inline script"); + assert!( + matches!(outcome, ParserStepAdvanceOutcome::Continue), + "parser should finish the remaining buffered html after the script" + ); - let head = snapshot.document_head_handle().expect("head should exist"); - let body = snapshot.document_body_handle().expect("body should exist"); - let head_children = snapshot.child_ids(head).collect::>(); - let body_children = snapshot.child_ids(body).collect::>(); + let snapshot = page_vm.vm().snapshot_live_document(); + let serialized = snapshot.serialize_document(); + assert!( + serialized.to_ascii_lowercase().contains(""), + "doctype should survive parser-connected script execution: {serialized}" + ); - assert!( - head_children.iter().any(|handle| { - snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.is_html_element("meta")) - }), - "later should stay under : {serialized}" - ); - assert!( - head_children.iter().any(|handle| { - snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.is_html_element("title")) - }), - "later should stay under <head>: {serialized}" - ); - assert!( - body_children.iter().all(|handle| { - !snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| { - element.is_html_element("meta") || element.is_html_element("title") - }) - }), - "<body> should not receive later head-only tokens: {serialized}" - ); - })); + let head = snapshot.document_head_handle().expect("head should exist"); + let body = snapshot.document_body_handle().expect("body should exist"); + let head_children = snapshot.child_ids(head).collect::<Vec<_>>(); + let body_children = snapshot.child_ids(body).collect::<Vec<_>>(); + + assert!( + head_children.iter().any(|handle| { + snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("meta")) + }), + "later <meta> should stay under <head>: {serialized}" + ); + assert!( + head_children.iter().any(|handle| { + snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("title")) + }), + "later <title> should stay under <head>: {serialized}" + ); + assert!( + body_children.iter().all(|handle| { + !snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| { + element.is_html_element("meta") || element.is_html_element("title") + }) + }), + "<body> should not receive later head-only tokens: {serialized}" + ); + })); } #[test] fn parser_connected_external_head_script_with_live_head_and_body_mutation_keeps_later_head_tokens_in_head() @@ -1053,235 +1055,236 @@ fn parser_connected_external_head_script_with_live_head_and_body_mutation_keeps_ .expect("current-thread runtime should build"); runtime.block_on(tokio::task::LocalSet::new().run_until(async move { - let _js_runtime = crate::JsRuntime::initialize(); - let final_url = Url::parse("https://example.test/").expect("test url"); - let loader = Box::leak(Box::new( - ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"), - )); - let state = Box::leak(Box::new(ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url.clone()))); - let mut driver = ParserDriver { - loader, - final_url: &state.final_url, - parser_session: &mut state.parser_session, - scheduler: &mut state.scheduler, - buffered_document_preloads: &mut state.buffered_document_preloads, - service_worker_preload_context: state.service_worker_preload_context.as_ref(), + let _js_runtime = crate::JsRuntime::initialize(); + let final_url = Url::parse("https://example.test/").expect("test url"); + let loader = Box::leak(Box::new( + ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"), + )); + let state = Box::leak(Box::new(ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url.clone()))); + let mut driver = ParserDriver { + loader, + final_url: &state.final_url, + parser_session: &mut state.parser_session, + scheduler: &mut state.scheduler, + buffered_document_preloads: &mut state.buffered_document_preloads, + service_worker_preload_context: state.service_worker_preload_context.as_ref(), - input_closed: &state.input_closed, - }; + input_closed: &state.input_closed, + }; - let external_script = "globalThis.__runtimeHeadMutationStage='start';const body = document.createElement('body');globalThis.__runtimeHeadMutationStage='body-created';document.documentElement.appendChild(body);globalThis.__runtimeHeadMutationStage='body-appended';const early = document.createElement('div');early.id = 'early';body.appendChild(early);const style = document.createElement('style');style.textContent = '.runtime-style { color: red; }';document.head.appendChild(style);const script = document.createElement('script');script.textContent = 'window.__runtimeHeadMutation = true;';document.head.appendChild(script);globalThis.__runtimeHeadMutationStage='complete';"; - let encoded_script = url::form_urlencoded::byte_serialize(external_script.as_bytes()) - .collect::<String>() - .replace('+', "%20"); - let html = format!( - "<!doctype html><html><head><script src=\"data:text/javascript,{encoded_script}\"></script><meta charset='utf-8'><title>x
late
" - ); - let crate::parser::ParserPumpOutcome { - result, - discovered_async_prefetch_scripts: _, - discovered_modulepreload_link_candidates: _, - discovered_blocking_stylesheet_inputs: _, - } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step(&html); - let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { - panic!("expected parser step to stop at external script handoff"); - }; - let ParserScriptHandoff::BlockingClassic { script, .. } = handoff.as_ref() else { - panic!("expected external parser-blocking classic handoff"); - }; - driver.buffered_document_preloads.entries.insert( - BufferedScriptPreloadKey::from_script(script).expect("preload key"), - ready_preload_entry_for_script(script, external_script), - ); + let external_script = "globalThis.__runtimeHeadMutationStage='start';const body = document.createElement('body');globalThis.__runtimeHeadMutationStage='body-created';document.documentElement.appendChild(body);globalThis.__runtimeHeadMutationStage='body-appended';const early = document.createElement('div');early.id = 'early';body.appendChild(early);const style = document.createElement('style');style.textContent = '.runtime-style { color: red; }';document.head.appendChild(style);const script = document.createElement('script');script.textContent = 'window.__runtimeHeadMutation = true;';document.head.appendChild(script);globalThis.__runtimeHeadMutationStage='complete';"; + let encoded_script = url::form_urlencoded::byte_serialize(external_script.as_bytes()) + .collect::() + .replace('+', "%20"); + let html = format!( + "x
late
" + ); + let crate::parser::ParserPumpOutcome { + result, + discovered_async_prefetch_scripts: _, + discovered_modulepreload_link_candidates: _, + discovered_blocking_stylesheet_inputs: _, + } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step(&html); + let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { + panic!("expected parser step to stop at external script handoff"); + }; + let ParserScriptHandoff::BlockingClassic { script, .. } = handoff.as_ref() else { + panic!("expected external parser-blocking classic handoff"); + }; + driver.buffered_document_preloads.entries.insert( + BufferedScriptPreloadKey::from_script(script).expect("preload key"), + ready_preload_entry_for_script(script, external_script), + ); - let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); - let local_executor = JsLocalExecutor::new(); - let mut page_vm = PageVm::new( - PageId::new_for_testing(1), - local_executor, - loader, - &PageVmEnvConfig { - web_storage: crate::RendererWebStorageHandles::ephemeral(), - root_frame_id: None, - main_document_commit: None, - top_level_storage_key: None, - document_start_scripts: vec![], - runtime_bindings: vec![], - runtime_inspector_session_restore_snapshots: vec![], - runtime_isolated_worlds: vec![], - permission_overrides: vec![], - extra_http_headers: Default::default(), - navigator_identity: Default::default(), - document_policy_container: Default::default(), - document_default_language: None, - document_last_modified: None, - document_settings: Default::default(), - network_offline: false, - blocked_url_patterns: Vec::new(), - indexed_db_manager: None, - storage_bucket_store: None, - fetch_subresource_interception_enabled: false, - fetch_subresource_interception_resource_type: None, - layout_configuration: moli_page_types::LayoutConfiguration { - policy: moli_page_types::LayoutPolicy::default(), - scrollbars_hidden: false, - }, - wpt_extensions_enabled: false, - navigation_bootstrap_entry: None, - reserved_service_worker_client_id: None, -}, - PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), - parser_dom_host, - Instant::now(), - ) - .expect("page vm"); - - let local_executor = page_vm.local_executor.clone(); - let page_vm_ptr: *mut PageVm = &mut page_vm; - let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; - let handoff = handoff.clone(); - let outcome = super::access::run_named_owner_local_task( - local_executor, - "phase-one external script handoff local task channel closed", - async move { - let page_vm = unsafe { &mut *page_vm_ptr }; - let driver = unsafe { &mut *driver_ptr }; - driver - .handle_parse_time_script_handoff(page_vm, *handoff, None) - .await + let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); + let local_executor = JsLocalExecutor::new(); + let mut page_vm = PageVm::new( + PageId::new_for_testing(1), + local_executor, + loader, + &PageVmEnvConfig { + web_storage: crate::RendererWebStorageHandles::ephemeral(), + root_frame_id: None, + main_document_commit: None, + top_level_storage_key: None, + document_start_scripts: vec![], + runtime_bindings: vec![], + runtime_inspector_session_restore_snapshots: vec![], + runtime_isolated_worlds: vec![], + permission_overrides: vec![], + extra_http_headers: Default::default(), + navigator_identity: Default::default(), + document_policy_container: Default::default(), + document_default_language: None, + document_last_modified: None, + document_settings: Default::default(), + network_offline: false, + blocked_url_patterns: Vec::new(), + indexed_db_manager: None, + storage_bucket_store: None, + fetch_subresource_interception_enabled: false, + fetch_subresource_interception_resource_type: None, + layout_configuration: moli_page_types::LayoutConfiguration { + policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, }, - ) - .await - .expect("external parser-connected script should execute"); - assert!( - matches!(outcome, ScriptHandoffOutcome::NoNavigation), - "external parser-connected script should not navigate in this fixture" - ); - let before_resume = page_vm.vm().snapshot_live_document(); - assert_eq!( - page_vm - .vm_mut() - .eval("String(globalThis.__runtimeHeadMutationStage)") - .expect("runtime head mutation stage should evaluate"), - "complete", - "external parser-blocking script should complete before parser resume" - ); - let before_resume_head = before_resume - .document_head_handle() - .expect("head should exist before parser resume"); - let before_resume_body = before_resume - .document_body_handle() - .expect("runtime body should exist before parser resume"); - assert!( - before_resume - .child_ids(before_resume_head) - .any(|handle| before_resume - .node(handle) - .and_then(Node::as_element) - .is_some_and(|element| element.is_html_element("style"))), - "runtime style should exist before parser resume: {}", - before_resume.serialize_document() - ); - assert!( - before_resume - .child_ids(before_resume_body) - .any(|handle| before_resume - .node(handle) - .and_then(Node::as_element) - .and_then(Element::id) - .is_some_and(|id| id == "early")), - "runtime body node should exist before parser resume: {}", - before_resume.serialize_document() - ); - assert_eq!( - page_vm - .vm_mut() - .eval("String(globalThis.__runtimeHeadMutation === true)") - .expect("runtime head script marker should evaluate"), - "true", - "runtime-inserted script should execute before parser resume" - ); + 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(), + parser_dom_host, + Instant::now(), + ) + .expect("page vm"); - let local_executor = page_vm.local_executor.clone(); - let page_vm_ptr: *mut PageVm = &mut page_vm; - let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; - let outcome = super::access::run_named_owner_local_task( - local_executor, - "phase-one external script continuation local task channel closed", - async move { - let page_vm = unsafe { &mut *page_vm_ptr }; - let driver = unsafe { &mut *driver_ptr }; - driver.advance_parser_step(page_vm, "", None).await - }, - ) - .await - .expect("parser should continue after the external script"); - assert!( - matches!(outcome, ParserStepAdvanceOutcome::Continue), - "parser should finish the remaining buffered html after the script" - ); + let local_executor = page_vm.local_executor.clone(); + let page_vm_ptr: *mut PageVm = &mut page_vm; + let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; + let handoff = handoff.clone(); + let outcome = super::access::run_named_owner_local_task( + local_executor, + "phase-one external script handoff local task channel closed", + async move { + let page_vm = unsafe { &mut *page_vm_ptr }; + let driver = unsafe { &mut *driver_ptr }; + driver + .handle_parse_time_script_handoff(page_vm, *handoff, None) + .await + }, + ) + .await + .expect("external parser-connected script should execute"); + assert!( + matches!(outcome, ScriptHandoffOutcome::NoNavigation), + "external parser-connected script should not navigate in this fixture" + ); + let before_resume = page_vm.vm().snapshot_live_document(); + assert_eq!( + page_vm + .vm_mut() + .eval("String(globalThis.__runtimeHeadMutationStage)") + .expect("runtime head mutation stage should evaluate"), + "complete", + "external parser-blocking script should complete before parser resume" + ); + let before_resume_head = before_resume + .document_head_handle() + .expect("head should exist before parser resume"); + let before_resume_body = before_resume + .document_body_handle() + .expect("runtime body should exist before parser resume"); + assert!( + before_resume + .child_ids(before_resume_head) + .any(|handle| before_resume + .node(handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("style"))), + "runtime style should exist before parser resume: {}", + before_resume.serialize_document() + ); + assert!( + before_resume + .child_ids(before_resume_body) + .any(|handle| before_resume + .node(handle) + .and_then(Node::as_element) + .and_then(Element::id) + .is_some_and(|id| id == "early")), + "runtime body node should exist before parser resume: {}", + before_resume.serialize_document() + ); + assert_eq!( + page_vm + .vm_mut() + .eval("String(globalThis.__runtimeHeadMutation === true)") + .expect("runtime head script marker should evaluate"), + "true", + "runtime-inserted script should execute before parser resume" + ); - let snapshot = page_vm.vm().snapshot_live_document(); - let serialized = snapshot.serialize_document(); - assert!( - serialized.to_ascii_lowercase().contains(""), - "doctype should survive external parser-connected script execution: {serialized}" - ); + let local_executor = page_vm.local_executor.clone(); + let page_vm_ptr: *mut PageVm = &mut page_vm; + let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; + let outcome = super::access::run_named_owner_local_task( + local_executor, + "phase-one external script continuation local task channel closed", + async move { + let page_vm = unsafe { &mut *page_vm_ptr }; + let driver = unsafe { &mut *driver_ptr }; + driver.advance_parser_step(page_vm, "", None).await + }, + ) + .await + .expect("parser should continue after the external script"); + assert!( + matches!(outcome, ParserStepAdvanceOutcome::Continue), + "parser should finish the remaining buffered html after the script" + ); - let head = snapshot.document_head_handle().expect("head should exist"); - let body = snapshot.document_body_handle().expect("body should exist"); - let head_children = snapshot.child_ids(head).collect::>(); - let body_children = snapshot.child_ids(body).collect::>(); + let snapshot = page_vm.vm().snapshot_live_document(); + let serialized = snapshot.serialize_document(); + assert!( + serialized.to_ascii_lowercase().contains(""), + "doctype should survive external parser-connected script execution: {serialized}" + ); - assert!( - head_children.iter().any(|handle| { - snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.is_html_element("meta")) - }), - "later should stay under : {serialized}" - ); - assert!( - head_children.iter().any(|handle| { - snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.is_html_element("title")) - }), - "later should stay under <head>: {serialized}" - ); - assert!( - head_children.iter().any(|handle| { - snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.is_html_element("style")) - }), - "runtime-inserted <style> should stay under <head>: {serialized}" - ); - assert!( - body_children.iter().any(|handle| { - snapshot - .node(*handle) - .and_then(Node::as_element) - .and_then(Element::id) - .is_some_and(|id| id == "early") - }), - "runtime-inserted body node should stay under <body>: {serialized}" - ); - assert!( - body_children.iter().all(|handle| { - !snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| { - element.is_html_element("meta") || element.is_html_element("title") - }) - }), - "<body> should not receive later head-only tokens: {serialized}" - ); - })); + let head = snapshot.document_head_handle().expect("head should exist"); + let body = snapshot.document_body_handle().expect("body should exist"); + let head_children = snapshot.child_ids(head).collect::<Vec<_>>(); + let body_children = snapshot.child_ids(body).collect::<Vec<_>>(); + + assert!( + head_children.iter().any(|handle| { + snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("meta")) + }), + "later <meta> should stay under <head>: {serialized}" + ); + assert!( + head_children.iter().any(|handle| { + snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("title")) + }), + "later <title> should stay under <head>: {serialized}" + ); + assert!( + head_children.iter().any(|handle| { + snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("style")) + }), + "runtime-inserted <style> should stay under <head>: {serialized}" + ); + assert!( + body_children.iter().any(|handle| { + snapshot + .node(*handle) + .and_then(Node::as_element) + .and_then(Element::id) + .is_some_and(|id| id == "early") + }), + "runtime-inserted body node should stay under <body>: {serialized}" + ); + assert!( + body_children.iter().all(|handle| { + !snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| { + element.is_html_element("meta") || element.is_html_element("title") + }) + }), + "<body> should not receive later head-only tokens: {serialized}" + ); + })); } #[test] fn empty_parser_blocking_script_drains_observers_before_declarative_shadow() { @@ -1686,7 +1689,8 @@ fn parser_owner_body_stylesheet_pause_preserves_unconsumed_tail_on_live_page_vm( runtime.block_on(tokio::task::LocalSet::new().run_until(async move { let _js_runtime = crate::JsRuntime::initialize(); let final_url = Url::parse("https://example.test/").expect("test url"); - let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); + let loader = + ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); let mut state = ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url); let parser_dom_host = state .parser_session @@ -1726,6 +1730,7 @@ fn parser_owner_body_stylesheet_pause_preserves_unconsumed_tail_on_live_page_vm( }, 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/tests/extracted/parser_script_handoffs.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_script_handoffs.rs index 361722a5c1..f1fe987a92 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_script_handoffs.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/parser_script_handoffs.rs @@ -286,126 +286,127 @@ fn external_async_handoff_marks_parser_stream_already_started_on_live_backend() .expect("current-thread runtime should build"); runtime.block_on(async move { - let _js_runtime = crate::JsRuntime::initialize(); - let final_url = Url::parse("https://example.test/").expect("test url"); - let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); - let mut state = - ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url); - let mut driver = ParserDriver { - loader: &loader, - final_url: &state.final_url, - parser_session: &mut state.parser_session, - scheduler: &mut state.scheduler, - buffered_document_preloads: &mut state.buffered_document_preloads, - service_worker_preload_context: state.service_worker_preload_context.as_ref(), - input_closed: &state.input_closed, - }; + let _js_runtime = crate::JsRuntime::initialize(); + let final_url = Url::parse("https://example.test/").expect("test url"); + let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); + let mut state = + ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url); + let mut driver = ParserDriver { + loader: &loader, + final_url: &state.final_url, + parser_session: &mut state.parser_session, + scheduler: &mut state.scheduler, + buffered_document_preloads: &mut state.buffered_document_preloads, + service_worker_preload_context: state.service_worker_preload_context.as_ref(), + input_closed: &state.input_closed, + }; - let crate::parser::ParserPumpOutcome { - result, - discovered_async_prefetch_scripts, - discovered_modulepreload_link_candidates: _, - discovered_blocking_stylesheet_inputs: _, - } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( - "<!doctype html><html><head><script async src=\"/async.js\"></script></head></html>", - ); - let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { - panic!("expected parser step to stop at external async script handoff"); - }; - let handle = match handoff.as_ref() { - ParserScriptHandoff::AsyncPostParse { node_id, .. } - | ParserScriptHandoff::NonAsyncPostParse { node_id, .. } - | ParserScriptHandoff::ImportMap { node_id, .. } - | ParserScriptHandoff::NoExecution { node_id, .. } - | ParserScriptHandoff::PreparationFailure { node_id, .. } => *node_id, - ParserScriptHandoff::BlockingClassic { .. } => { - panic!("non-blocking async handoff should not carry a blocking-classic payload") - } - }; - - // In the single-DOM model, the PageVm owns the parser's DomHost. - // Simulate bootstrap by giving the parser's DomHost to the PageVm. - let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); - let local_executor = JsLocalExecutor::new(); - let mut page_vm = PageVm::new( - PageId::new_for_testing(1), - local_executor, - &loader, - &PageVmEnvConfig { - web_storage: crate::RendererWebStorageHandles::ephemeral(), - root_frame_id: None, - main_document_commit: None, - top_level_storage_key: None, - document_start_scripts: vec![], - runtime_bindings: vec![], - runtime_inspector_session_restore_snapshots: vec![], - runtime_isolated_worlds: vec![], - permission_overrides: vec![], - extra_http_headers: Default::default(), - navigator_identity: Default::default(), - document_policy_container: Default::default(), - document_default_language: None, - document_last_modified: None, - document_settings: Default::default(), - network_offline: false, - blocked_url_patterns: Vec::new(), - indexed_db_manager: None, - storage_bucket_store: None, - fetch_subresource_interception_enabled: false, - fetch_subresource_interception_resource_type: None, - layout_configuration: moli_page_types::LayoutConfiguration { - policy: moli_page_types::LayoutPolicy::default(), - scrollbars_hidden: false, - }, - wpt_extensions_enabled: false, - navigation_bootstrap_entry: None, - reserved_service_worker_client_id: None, -}, - PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), - parser_dom_host, - Instant::now(), - ) - .expect("page vm"); - for mut script in discovered_async_prefetch_scripts { - bind_parser_owned_script_handle(&mut page_vm, &mut script); - let _ = driver - .scheduler - .on_parser_discovered_async_candidate_with_shared_load( - script, - Some(SharedScriptSourceLoad::ready_err( - "synthetic async source terminal", - )), - ); + let crate::parser::ParserPumpOutcome { + result, + discovered_async_prefetch_scripts, + discovered_modulepreload_link_candidates: _, + discovered_blocking_stylesheet_inputs: _, + } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( + "<!doctype html><html><head><script async src=\"/async.js\"></script></head></html>", + ); + let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { + panic!("expected parser step to stop at external async script handoff"); + }; + let handle = match handoff.as_ref() { + ParserScriptHandoff::AsyncPostParse { node_id, .. } + | ParserScriptHandoff::NonAsyncPostParse { node_id, .. } + | ParserScriptHandoff::ImportMap { node_id, .. } + | ParserScriptHandoff::NoExecution { node_id, .. } + | ParserScriptHandoff::PreparationFailure { node_id, .. } => *node_id, + ParserScriptHandoff::BlockingClassic { .. } => { + panic!("non-blocking async handoff should not carry a blocking-classic payload") } - let outcome = driver - .handle_parse_time_script_handoff(&mut page_vm, *handoff.clone(), None) - .await - .expect("external async handoff should resolve without executing V8"); + }; - assert!( - matches!(outcome, ScriptHandoffOutcome::NoNavigation), - "external async handoff should resolve without navigation (credit granted internally)" - ); + // In the single-DOM model, the PageVm owns the parser's DomHost. + // Simulate bootstrap by giving the parser's DomHost to the PageVm. + let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); + let local_executor = JsLocalExecutor::new(); + let mut page_vm = PageVm::new( + PageId::new_for_testing(1), + local_executor, + &loader, + &PageVmEnvConfig { + web_storage: crate::RendererWebStorageHandles::ephemeral(), + root_frame_id: None, + main_document_commit: None, + top_level_storage_key: None, + document_start_scripts: vec![], + runtime_bindings: vec![], + runtime_inspector_session_restore_snapshots: vec![], + runtime_isolated_worlds: vec![], + permission_overrides: vec![], + extra_http_headers: Default::default(), + navigator_identity: Default::default(), + document_policy_container: Default::default(), + document_default_language: None, + document_last_modified: None, + document_settings: Default::default(), + network_offline: false, + blocked_url_patterns: Vec::new(), + indexed_db_manager: None, + storage_bucket_store: None, + fetch_subresource_interception_enabled: false, + fetch_subresource_interception_resource_type: None, + layout_configuration: moli_page_types::LayoutConfiguration { + policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, + }, + 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(), + parser_dom_host, + Instant::now(), + ) + .expect("page vm"); + for mut script in discovered_async_prefetch_scripts { + bind_parser_owned_script_handle(&mut page_vm, &mut script); + let _ = driver + .scheduler + .on_parser_discovered_async_candidate_with_shared_load( + script, + Some(SharedScriptSourceLoad::ready_err( + "synthetic async source terminal", + )), + ); + } + let outcome = driver + .handle_parse_time_script_handoff(&mut page_vm, *handoff.clone(), None) + .await + .expect("external async handoff should resolve without executing V8"); - // In the single-DOM model, the already-started bit is on the runtime's DomHost. - let runtime_snapshot = page_vm.vm().snapshot_live_document(); - assert!( - runtime_snapshot - .node(handle) - .and_then(|node| node.as_element()) - .is_some_and(|element| element.script_already_started()), - "runtime live document should record async handoff ownership after mutation" - ); - let expected_handle = format!("parser-script-native-{}", handle.index()); - assert_eq!( - page_vm - .vm() - .document_runtime - .resolve_host_script_handle(&expected_handle), - Some(handle), - "async parser-owned handoff should register a parser-owned host script handle" - ); - }); + assert!( + matches!(outcome, ScriptHandoffOutcome::NoNavigation), + "external async handoff should resolve without navigation (credit granted internally)" + ); + + // In the single-DOM model, the already-started bit is on the runtime's DomHost. + let runtime_snapshot = page_vm.vm().snapshot_live_document(); + assert!( + runtime_snapshot + .node(handle) + .and_then(|node| node.as_element()) + .is_some_and(|element| element.script_already_started()), + "runtime live document should record async handoff ownership after mutation" + ); + let expected_handle = format!("parser-script-native-{}", handle.index()); + assert_eq!( + page_vm + .vm() + .document_runtime + .resolve_host_script_handle(&expected_handle), + Some(handle), + "async parser-owned handoff should register a parser-owned host script handle" + ); + }); } #[test] fn blocking_classic_handoff_registers_parser_owned_handle_on_live_backend() { @@ -415,114 +416,115 @@ fn blocking_classic_handoff_registers_parser_owned_handle_on_live_backend() { .expect("current-thread runtime should build"); runtime.block_on(async move { - let _js_runtime = crate::JsRuntime::initialize(); - let final_url = Url::parse("https://example.test/").expect("test url"); - let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); - let mut state = ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url); - let mut driver = ParserDriver { - loader: &loader, - final_url: &state.final_url, - parser_session: &mut state.parser_session, - scheduler: &mut state.scheduler, - buffered_document_preloads: &mut state.buffered_document_preloads, - service_worker_preload_context: state.service_worker_preload_context.as_ref(), - input_closed: &state.input_closed, - }; + let _js_runtime = crate::JsRuntime::initialize(); + let final_url = Url::parse("https://example.test/").expect("test url"); + let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); + let mut state = ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url); + let mut driver = ParserDriver { + loader: &loader, + final_url: &state.final_url, + parser_session: &mut state.parser_session, + scheduler: &mut state.scheduler, + buffered_document_preloads: &mut state.buffered_document_preloads, + service_worker_preload_context: state.service_worker_preload_context.as_ref(), + input_closed: &state.input_closed, + }; - let crate::parser::ParserPumpOutcome { - result, - discovered_async_prefetch_scripts: _, - discovered_modulepreload_link_candidates: _, - discovered_blocking_stylesheet_inputs, - } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( - "<!doctype html><html><head><link rel=\"stylesheet\" href=\"/app.css\"><script src=\"/app.js\"></script></head></html>", + let crate::parser::ParserPumpOutcome { + result, + discovered_async_prefetch_scripts: _, + discovered_modulepreload_link_candidates: _, + discovered_blocking_stylesheet_inputs, + } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( + "<!doctype html><html><head><link rel=\"stylesheet\" href=\"/app.css\"><script src=\"/app.js\"></script></head></html>", + ); + let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { + panic!("expected parser step to stop at blocking classic handoff"); + }; + let ParserScriptHandoff::BlockingClassic { + node_id: handle, .. + } = handoff.as_ref() + else { + panic!("expected parser-blocking classic to classify as blocking classic"); + }; + let handle = *handle; + + let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); + let local_executor = JsLocalExecutor::new(); + let mut page_vm = PageVm::new( + PageId::new_for_testing(1), + local_executor, + &loader, + &PageVmEnvConfig { + web_storage: crate::RendererWebStorageHandles::ephemeral(), + root_frame_id: None, + main_document_commit: None, + top_level_storage_key: None, + document_start_scripts: vec![], + runtime_bindings: vec![], + runtime_inspector_session_restore_snapshots: vec![], + runtime_isolated_worlds: vec![], + permission_overrides: vec![], + extra_http_headers: Default::default(), + navigator_identity: Default::default(), + document_policy_container: Default::default(), + document_default_language: None, + document_last_modified: None, + document_settings: Default::default(), + network_offline: false, + blocked_url_patterns: Vec::new(), + indexed_db_manager: None, + storage_bucket_store: None, + fetch_subresource_interception_enabled: false, + fetch_subresource_interception_resource_type: None, + layout_configuration: moli_page_types::LayoutConfiguration { + policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, + }, + 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(), + parser_dom_host, + Instant::now(), + ) + .expect("page vm"); + activate_standalone_main_parser_continuation_for_test(&mut page_vm); + page_vm + .vm_mut() + .document_runtime + .note_discovered_document_owned_blocking_stylesheet_inputs( + discovered_blocking_stylesheet_inputs.iter(), ); - let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { - panic!("expected parser step to stop at blocking classic handoff"); - }; - let ParserScriptHandoff::BlockingClassic { - node_id: handle, .. - } = handoff.as_ref() - else { - panic!("expected parser-blocking classic to classify as blocking classic"); - }; - let handle = *handle; - let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); - let local_executor = JsLocalExecutor::new(); - let mut page_vm = PageVm::new( - PageId::new_for_testing(1), - local_executor, - &loader, - &PageVmEnvConfig { - web_storage: crate::RendererWebStorageHandles::ephemeral(), - root_frame_id: None, - main_document_commit: None, - top_level_storage_key: None, - document_start_scripts: vec![], - runtime_bindings: vec![], - runtime_inspector_session_restore_snapshots: vec![], - runtime_isolated_worlds: vec![], - permission_overrides: vec![], - extra_http_headers: Default::default(), - navigator_identity: Default::default(), - document_policy_container: Default::default(), - document_default_language: None, - document_last_modified: None, - document_settings: Default::default(), - network_offline: false, - blocked_url_patterns: Vec::new(), - indexed_db_manager: None, - storage_bucket_store: None, - fetch_subresource_interception_enabled: false, - fetch_subresource_interception_resource_type: None, - layout_configuration: moli_page_types::LayoutConfiguration { - policy: moli_page_types::LayoutPolicy::default(), - scrollbars_hidden: false, - }, - wpt_extensions_enabled: false, - navigation_bootstrap_entry: None, - reserved_service_worker_client_id: None, -}, - PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), - parser_dom_host, - Instant::now(), - ) - .expect("page vm"); - activate_standalone_main_parser_continuation_for_test(&mut page_vm); + let outcome = driver + .handle_parse_time_script_handoff(&mut page_vm, *handoff, None) + .await + .expect("blocking classic handoff should resolve on the live backend"); + assert!( + matches!(outcome, ScriptHandoffOutcome::BlockedOnStylesheet(_)), + "blocking classic handoff should stay stylesheet-gated in this fixture" + ); + let expected_handle = format!("parser-script-native-{}", handle.index()); + assert_eq!( page_vm - .vm_mut() + .vm() .document_runtime - .note_discovered_document_owned_blocking_stylesheet_inputs( - discovered_blocking_stylesheet_inputs.iter(), - ); - - let outcome = driver - .handle_parse_time_script_handoff(&mut page_vm, *handoff, None) - .await - .expect("blocking classic handoff should resolve on the live backend"); - assert!( - matches!(outcome, ScriptHandoffOutcome::BlockedOnStylesheet(_)), - "blocking classic handoff should stay stylesheet-gated in this fixture" - ); - let expected_handle = format!("parser-script-native-{}", handle.index()); - assert_eq!( - page_vm - .vm() - .document_runtime - .resolve_host_script_handle(&expected_handle), - Some(handle), - "blocking classic handoff should register a parser-owned host script handle" - ); - let runtime_snapshot = page_vm.vm().snapshot_live_document(); - assert!( - runtime_snapshot - .node(handle) - .and_then(|node| node.as_element()) - .is_some_and(|element| element.script_already_started()), - "blocking classic handoff should mark already-started on the live runtime DOM" - ); - }); + .resolve_host_script_handle(&expected_handle), + Some(handle), + "blocking classic handoff should register a parser-owned host script handle" + ); + let runtime_snapshot = page_vm.vm().snapshot_live_document(); + assert!( + runtime_snapshot + .node(handle) + .and_then(|node| node.as_element()) + .is_some_and(|element| element.script_already_started()), + "blocking classic handoff should mark already-started on the live runtime DOM" + ); + }); } #[test] fn non_async_post_parse_handoff_registers_pending_before_source_and_seals_without_waiting() { @@ -532,180 +534,181 @@ fn non_async_post_parse_handoff_registers_pending_before_source_and_seals_withou .expect("current-thread runtime should build"); runtime.block_on(async move { - let _js_runtime = crate::JsRuntime::initialize(); - let final_url = Url::parse("https://example.test/").expect("test url"); - let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); - let mut state = ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url); - let mut driver = ParserDriver { - loader: &loader, - final_url: &state.final_url, - parser_session: &mut state.parser_session, - scheduler: &mut state.scheduler, - buffered_document_preloads: &mut state.buffered_document_preloads, - service_worker_preload_context: state.service_worker_preload_context.as_ref(), - input_closed: &state.input_closed, - }; + let _js_runtime = crate::JsRuntime::initialize(); + let final_url = Url::parse("https://example.test/").expect("test url"); + let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); + let mut state = ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url); + let mut driver = ParserDriver { + loader: &loader, + final_url: &state.final_url, + parser_session: &mut state.parser_session, + scheduler: &mut state.scheduler, + buffered_document_preloads: &mut state.buffered_document_preloads, + service_worker_preload_context: state.service_worker_preload_context.as_ref(), + input_closed: &state.input_closed, + }; - let crate::parser::ParserPumpOutcome { - result, - discovered_async_prefetch_scripts: _, - discovered_modulepreload_link_candidates: _, - discovered_blocking_stylesheet_inputs: _, - } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( - "<!doctype html><html><head><script defer src=\"/defer.js\"></script></head></html>", - ); - let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { - panic!("expected parser step to stop at defer handoff"); - }; - let ParserScriptHandoff::NonAsyncPostParse { - node_id: handle, - script, - .. - } = handoff.as_ref() - else { - panic!("expected defer script to classify as non-async post-parse"); - }; - let handle = *handle; - let script = script.clone(); - let (source_tx, source_rx) = tokio::sync::oneshot::channel(); - driver.buffered_document_preloads.entries.insert( - BufferedScriptPreloadKey::from_script(&script).expect("defer preload key"), - BufferedScriptPreloadEntry { - request: preload_request_for_script( - &script, - moli_fetch::RequestResourceType::ParserBlockingScript, - ), - load: SharedScriptSourceLoad::spawn_for_test(async move { - source_rx.await.expect("defer source result") - }), + let crate::parser::ParserPumpOutcome { + result, + discovered_async_prefetch_scripts: _, + discovered_modulepreload_link_candidates: _, + discovered_blocking_stylesheet_inputs: _, + } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( + "<!doctype html><html><head><script defer src=\"/defer.js\"></script></head></html>", + ); + let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { + panic!("expected parser step to stop at defer handoff"); + }; + let ParserScriptHandoff::NonAsyncPostParse { + node_id: handle, + script, + .. + } = handoff.as_ref() + else { + panic!("expected defer script to classify as non-async post-parse"); + }; + let handle = *handle; + let script = script.clone(); + let (source_tx, source_rx) = tokio::sync::oneshot::channel(); + driver.buffered_document_preloads.entries.insert( + BufferedScriptPreloadKey::from_script(&script).expect("defer preload key"), + BufferedScriptPreloadEntry { + request: preload_request_for_script( + &script, + moli_fetch::RequestResourceType::ParserBlockingScript, + ), + load: SharedScriptSourceLoad::spawn_for_test(async move { + source_rx.await.expect("defer source result") + }), + }, + ); + + let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); + let local_executor = JsLocalExecutor::new(); + let mut page_vm = PageVm::new( + PageId::new_for_testing(1), + local_executor, + &loader, + &PageVmEnvConfig { + web_storage: crate::RendererWebStorageHandles::ephemeral(), + root_frame_id: None, + main_document_commit: None, + top_level_storage_key: None, + document_start_scripts: vec![], + runtime_bindings: vec![], + runtime_inspector_session_restore_snapshots: vec![], + runtime_isolated_worlds: vec![], + permission_overrides: vec![], + extra_http_headers: Default::default(), + navigator_identity: Default::default(), + document_policy_container: Default::default(), + document_default_language: None, + document_last_modified: None, + document_settings: Default::default(), + network_offline: false, + blocked_url_patterns: Vec::new(), + indexed_db_manager: None, + storage_bucket_store: None, + fetch_subresource_interception_enabled: false, + fetch_subresource_interception_resource_type: None, + layout_configuration: moli_page_types::LayoutConfiguration { + policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, }, - ); + 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(), + parser_dom_host, + Instant::now(), + ) + .expect("page vm"); - let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); - let local_executor = JsLocalExecutor::new(); - let mut page_vm = PageVm::new( - PageId::new_for_testing(1), - local_executor, - &loader, - &PageVmEnvConfig { - web_storage: crate::RendererWebStorageHandles::ephemeral(), - root_frame_id: None, - main_document_commit: None, - top_level_storage_key: None, - document_start_scripts: vec![], - runtime_bindings: vec![], - runtime_inspector_session_restore_snapshots: vec![], - runtime_isolated_worlds: vec![], - permission_overrides: vec![], - extra_http_headers: Default::default(), - navigator_identity: Default::default(), - document_policy_container: Default::default(), - document_default_language: None, - document_last_modified: None, - document_settings: Default::default(), - network_offline: false, - blocked_url_patterns: Vec::new(), - indexed_db_manager: None, - storage_bucket_store: None, - fetch_subresource_interception_enabled: false, - fetch_subresource_interception_resource_type: None, - layout_configuration: moli_page_types::LayoutConfiguration { - policy: moli_page_types::LayoutPolicy::default(), - scrollbars_hidden: false, - }, - wpt_extensions_enabled: false, - navigation_bootstrap_entry: None, - reserved_service_worker_client_id: None, -}, - PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), - parser_dom_host, - Instant::now(), - ) - .expect("page vm"); - - let outcome = driver - .handle_parse_time_script_handoff(&mut page_vm, *handoff, None) - .await - .expect("defer handoff should resolve without executing V8"); - assert!( - matches!(outcome, ScriptHandoffOutcome::NoNavigation), - "defer handoff should remain a scheduling-only step" - ); - let expected_handle = format!("parser-script-native-{}", handle.index()); - assert_eq!( - page_vm - .vm() - .document_runtime - .resolve_host_script_handle(&expected_handle), - Some(handle), - "non-async parser-owned handoff should register a parser-owned host script handle" - ); - let task_owner = page_vm + let outcome = driver + .handle_parse_time_script_handoff(&mut page_vm, *handoff, None) + .await + .expect("defer handoff should resolve without executing V8"); + assert!( + matches!(outcome, ScriptHandoffOutcome::NoNavigation), + "defer handoff should remain a scheduling-only step" + ); + let expected_handle = format!("parser-script-native-{}", handle.index()); + assert_eq!( + page_vm .vm() - .current_main_document_task_owner() - .expect("main parser-deferred document owner"); - let parser_owner = - crate::module_script_continuation::MainParserDocumentOwner::new(task_owner); - assert!( - page_vm - .vm() - .document_runtime - .parser_module_document_scripts() - .has_load_blocking_document_script_work(parser_owner), - "handoff must install the PendingScript before its pending source finishes" - ); - assert!( - !page_vm - .vm() - .document_runtime - .parser_module_document_scripts() - .has_after_parsing_script(parser_owner), - "parser-deferred work cannot execute before EOF" - ); - assert!( - page_vm - .seal_main_parser_deferred_scripts(task_owner) - .is_some(), - "EOF seal should synchronously arm parser-deferred work" - ); - assert!( - !page_vm - .vm() - .document_runtime - .parser_module_document_scripts() - .next_after_parsing_script_is_ready(parser_owner), - "EOF must not wait for or inline-apply the source result" - ); + .document_runtime + .resolve_host_script_handle(&expected_handle), + Some(handle), + "non-async parser-owned handoff should register a parser-owned host script handle" + ); + let task_owner = page_vm + .vm() + .current_main_document_task_owner() + .expect("main parser-deferred document owner"); + let parser_owner = + crate::module_script_continuation::MainParserDocumentOwner::new(task_owner); + assert!( + page_vm + .vm() + .document_runtime + .parser_module_document_scripts() + .has_load_blocking_document_script_work(parser_owner), + "handoff must install the PendingScript before its pending source finishes" + ); + assert!( + !page_vm + .vm() + .document_runtime + .parser_module_document_scripts() + .has_after_parsing_script(parser_owner), + "parser-deferred work cannot execute before EOF" + ); + assert!( + page_vm + .seal_main_parser_deferred_scripts(task_owner) + .is_some(), + "EOF seal should synchronously arm parser-deferred work" + ); + assert!( + !page_vm + .vm() + .document_runtime + .parser_module_document_scripts() + .next_after_parsing_script_is_ready(parser_owner), + "EOF must not wait for or inline-apply the source result" + ); - source_tx - .send(Ok("globalThis.__mainDeferred = 1;".to_owned())) - .expect("defer source receiver should remain alive"); - if !page_vm.page_resource_completion_queue().has_ready_completion() { - tokio::time::timeout( - std::time::Duration::from_secs(2), - page_vm.wait_for_page_resource_completion_for_test(), - ) - .await - .expect("defer source completion should arrive"); - } - let mut source = page_vm.page_resource_completion_queue(); - let completion = page_vm - .apply_one_page_resource_terminal_owner_admission_for_test(&mut source) - .expect("defer source completion should apply") - .expect("defer source completion should make progress"); - assert_eq!( - completion.action.source(), - RendererOwnerResourceActivitySource::MainParserDeferredClassicSource - ); - assert!( - page_vm - .vm() - .document_runtime - .parser_module_document_scripts() - .next_after_parsing_script_is_ready(parser_owner), - "typed source completion should release the original PendingScript" - ); - }); + source_tx + .send(Ok("globalThis.__mainDeferred = 1;".to_owned())) + .expect("defer source receiver should remain alive"); + if !page_vm.page_resource_completion_queue().has_ready_completion() { + tokio::time::timeout( + std::time::Duration::from_secs(2), + page_vm.wait_for_page_resource_completion_for_test(), + ) + .await + .expect("defer source completion should arrive"); + } + let mut source = page_vm.page_resource_completion_queue(); + let completion = page_vm + .apply_one_page_resource_terminal_owner_admission_for_test(&mut source) + .expect("defer source completion should apply") + .expect("defer source completion should make progress"); + assert_eq!( + completion.action.source(), + RendererOwnerResourceActivitySource::MainParserDeferredClassicSource + ); + assert!( + page_vm + .vm() + .document_runtime + .parser_module_document_scripts() + .next_after_parsing_script_is_ready(parser_owner), + "typed source completion should release the original PendingScript" + ); + }); } #[test] fn parser_owned_external_module_handoff_starts_pending_script_tree_root_fetch() { @@ -715,115 +718,116 @@ fn parser_owned_external_module_handoff_starts_pending_script_tree_root_fetch() .expect("current-thread runtime should build"); runtime.block_on(async move { - let _js_runtime = crate::JsRuntime::initialize(); - let final_url = Url::parse("https://example.test/").expect("test url"); - let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); - let mut state = ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url); - let mut driver = ParserDriver { - loader: &loader, - final_url: &state.final_url, - parser_session: &mut state.parser_session, - scheduler: &mut state.scheduler, - buffered_document_preloads: &mut state.buffered_document_preloads, - service_worker_preload_context: state.service_worker_preload_context.as_ref(), - input_closed: &state.input_closed, - }; + let _js_runtime = crate::JsRuntime::initialize(); + let final_url = Url::parse("https://example.test/").expect("test url"); + let loader = ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"); + let mut state = ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url); + let mut driver = ParserDriver { + loader: &loader, + final_url: &state.final_url, + parser_session: &mut state.parser_session, + scheduler: &mut state.scheduler, + buffered_document_preloads: &mut state.buffered_document_preloads, + service_worker_preload_context: state.service_worker_preload_context.as_ref(), + input_closed: &state.input_closed, + }; - let crate::parser::ParserPumpOutcome { - result, - discovered_async_prefetch_scripts: _, - discovered_modulepreload_link_candidates: _, - discovered_blocking_stylesheet_inputs: _, - } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( - "<!doctype html><html><head><script type=\"module\" src=\"/module.mjs\"></script></head></html>", - ); - let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { - panic!("expected parser step to stop at module handoff"); - }; - let ParserScriptHandoff::NonAsyncPostParse { - node_id: handle, - .. - } = handoff.as_ref() - else { - panic!("expected parser-owned external module to classify as non-async post-parse"); - }; - let handle = *handle; + let crate::parser::ParserPumpOutcome { + result, + discovered_async_prefetch_scripts: _, + discovered_modulepreload_link_candidates: _, + discovered_blocking_stylesheet_inputs: _, + } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( + "<!doctype html><html><head><script type=\"module\" src=\"/module.mjs\"></script></head></html>", + ); + let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { + panic!("expected parser step to stop at module handoff"); + }; + let ParserScriptHandoff::NonAsyncPostParse { + node_id: handle, + .. + } = handoff.as_ref() + else { + panic!("expected parser-owned external module to classify as non-async post-parse"); + }; + let handle = *handle; - let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); - let local_executor = JsLocalExecutor::new(); - let mut page_vm = PageVm::new( - PageId::new_for_testing(1), - local_executor, - &loader, - &PageVmEnvConfig { - web_storage: crate::RendererWebStorageHandles::ephemeral(), - root_frame_id: None, - main_document_commit: None, - top_level_storage_key: None, - document_start_scripts: vec![], - runtime_bindings: vec![], - runtime_inspector_session_restore_snapshots: vec![], - runtime_isolated_worlds: vec![], - permission_overrides: vec![], - extra_http_headers: Default::default(), - navigator_identity: Default::default(), - document_policy_container: Default::default(), - document_default_language: None, - document_last_modified: None, - document_settings: Default::default(), - network_offline: false, - blocked_url_patterns: Vec::new(), - indexed_db_manager: None, - storage_bucket_store: None, - fetch_subresource_interception_enabled: false, - fetch_subresource_interception_resource_type: None, - layout_configuration: moli_page_types::LayoutConfiguration { - policy: moli_page_types::LayoutPolicy::default(), - scrollbars_hidden: false, - }, - wpt_extensions_enabled: false, - navigation_bootstrap_entry: None, - reserved_service_worker_client_id: None, -}, - PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), - parser_dom_host, - Instant::now(), - ) - .expect("page vm"); + let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); + let local_executor = JsLocalExecutor::new(); + let mut page_vm = PageVm::new( + PageId::new_for_testing(1), + local_executor, + &loader, + &PageVmEnvConfig { + web_storage: crate::RendererWebStorageHandles::ephemeral(), + root_frame_id: None, + main_document_commit: None, + top_level_storage_key: None, + document_start_scripts: vec![], + runtime_bindings: vec![], + runtime_inspector_session_restore_snapshots: vec![], + runtime_isolated_worlds: vec![], + permission_overrides: vec![], + extra_http_headers: Default::default(), + navigator_identity: Default::default(), + document_policy_container: Default::default(), + document_default_language: None, + document_last_modified: None, + document_settings: Default::default(), + network_offline: false, + blocked_url_patterns: Vec::new(), + indexed_db_manager: None, + storage_bucket_store: None, + fetch_subresource_interception_enabled: false, + fetch_subresource_interception_resource_type: None, + layout_configuration: moli_page_types::LayoutConfiguration { + policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, + }, + 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(), + parser_dom_host, + Instant::now(), + ) + .expect("page vm"); - let outcome = driver - .handle_parse_time_script_handoff(&mut page_vm, *handoff, None) - .await - .expect("module handoff should resolve without executing V8"); - assert!( - matches!(outcome, ScriptHandoffOutcome::NoNavigation), - "module handoff should remain a scheduling-only step" - ); - let expected_handle = format!("parser-script-native-{}", handle.index()); - assert_eq!( - page_vm - .vm() - .document_runtime - .resolve_host_script_handle(&expected_handle), - Some(handle), - "parser-owned external module handoff should register a parser-owned host script handle" - ); - let root_key = crate::module_runtime::ModuleMapKey::java_script( - Url::parse("https://example.test/module.mjs").expect("root url"), - ); - let root_entry = page_vm + let outcome = driver + .handle_parse_time_script_handoff(&mut page_vm, *handoff, None) + .await + .expect("module handoff should resolve without executing V8"); + assert!( + matches!(outcome, ScriptHandoffOutcome::NoNavigation), + "module handoff should remain a scheduling-only step" + ); + let expected_handle = format!("parser-script-native-{}", handle.index()); + assert_eq!( + page_vm .vm() .document_runtime - .native_module_entry_id(&root_key) - .expect("parser handoff should eagerly start module tree root fetch"); - assert_eq!( - page_vm - .vm() - .document_runtime - .native_module_entry_state(root_entry), - crate::module_runtime::ModuleMapEntryState::Fetching - ); - }); + .resolve_host_script_handle(&expected_handle), + Some(handle), + "parser-owned external module handoff should register a parser-owned host script handle" + ); + let root_key = crate::module_runtime::ModuleMapKey::java_script( + Url::parse("https://example.test/module.mjs").expect("root url"), + ); + let root_entry = page_vm + .vm() + .document_runtime + .native_module_entry_id(&root_key) + .expect("parser handoff should eagerly start module tree root fetch"); + assert_eq!( + page_vm + .vm() + .document_runtime + .native_module_entry_state(root_entry), + crate::module_runtime::ModuleMapEntryState::Fetching + ); + }); } #[test] fn parser_owned_module_handoff_starts_external_root_pending_tree_without_source_load() { @@ -1032,122 +1036,123 @@ fn parser_owned_inline_importmap_handoff_registers_parser_owned_handle_on_live_b .expect("current-thread runtime should build"); runtime.block_on(tokio::task::LocalSet::new().run_until(async move { - let _js_runtime = crate::JsRuntime::initialize(); - let final_url = Url::parse("https://example.test/").expect("test url"); - let loader = - Box::leak(Box::new(ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"))); - let state = Box::leak(Box::new(ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url))); - let mut driver = ParserDriver { - loader, - final_url: &state.final_url, - parser_session: &mut state.parser_session, - scheduler: &mut state.scheduler, - buffered_document_preloads: &mut state.buffered_document_preloads, - service_worker_preload_context: state.service_worker_preload_context.as_ref(), - input_closed: &state.input_closed, - }; + let _js_runtime = crate::JsRuntime::initialize(); + let final_url = Url::parse("https://example.test/").expect("test url"); + let loader = + Box::leak(Box::new(ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"))); + let state = Box::leak(Box::new(ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url))); + let mut driver = ParserDriver { + loader, + final_url: &state.final_url, + parser_session: &mut state.parser_session, + scheduler: &mut state.scheduler, + buffered_document_preloads: &mut state.buffered_document_preloads, + service_worker_preload_context: state.service_worker_preload_context.as_ref(), + input_closed: &state.input_closed, + }; - let crate::parser::ParserPumpOutcome { - result, - discovered_async_prefetch_scripts: _, - discovered_modulepreload_link_candidates: _, - discovered_blocking_stylesheet_inputs: _, - } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( - "<!doctype html><html><head><script type=\"importmap\">{\"imports\":{\"fixture\":\"/module.mjs\"}}</script></head></html>", - ); - let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { - panic!("expected parser step to stop at inline importmap handoff"); - }; - let ParserScriptHandoff::ImportMap { node_id: handle, .. } = handoff.as_ref() else { - panic!("expected parser-owned inline importmap registration handoff"); - }; - let handle = *handle; + let crate::parser::ParserPumpOutcome { + result, + discovered_async_prefetch_scripts: _, + discovered_modulepreload_link_candidates: _, + discovered_blocking_stylesheet_inputs: _, + } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step( + "<!doctype html><html><head><script type=\"importmap\">{\"imports\":{\"fixture\":\"/module.mjs\"}}</script></head></html>", + ); + let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { + panic!("expected parser step to stop at inline importmap handoff"); + }; + let ParserScriptHandoff::ImportMap { node_id: handle, .. } = handoff.as_ref() else { + panic!("expected parser-owned inline importmap registration handoff"); + }; + let handle = *handle; - let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); - let local_executor = JsLocalExecutor::new(); - let mut page_vm = PageVm::new( - PageId::new_for_testing(1), - local_executor, - loader, - &PageVmEnvConfig { - web_storage: crate::RendererWebStorageHandles::ephemeral(), - root_frame_id: None, - main_document_commit: None, - top_level_storage_key: None, - document_start_scripts: vec![], - runtime_bindings: vec![], - runtime_inspector_session_restore_snapshots: vec![], - runtime_isolated_worlds: vec![], - permission_overrides: vec![], - extra_http_headers: Default::default(), - navigator_identity: Default::default(), - document_policy_container: Default::default(), - document_default_language: None, - document_last_modified: None, - document_settings: Default::default(), - network_offline: false, - blocked_url_patterns: Vec::new(), - indexed_db_manager: None, - storage_bucket_store: None, - fetch_subresource_interception_enabled: false, - fetch_subresource_interception_resource_type: None, - layout_configuration: moli_page_types::LayoutConfiguration { - policy: moli_page_types::LayoutPolicy::default(), - scrollbars_hidden: false, - }, - wpt_extensions_enabled: false, - navigation_bootstrap_entry: None, - reserved_service_worker_client_id: None, -}, - PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), - parser_dom_host, - Instant::now(), - ) - .expect("page vm"); - - let local_executor = page_vm.local_executor.clone(); - let page_vm_ptr: *mut PageVm = &mut page_vm; - let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; - let handoff = handoff.clone(); - let outcome = super::access::run_named_owner_local_task( - local_executor, - "phase-one inline importmap handoff local task channel closed", - async move { - let page_vm = unsafe { &mut *page_vm_ptr }; - let driver = unsafe { &mut *driver_ptr }; - driver - .handle_parse_time_script_handoff(page_vm, *handoff, None) - .await + let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); + let local_executor = JsLocalExecutor::new(); + let mut page_vm = PageVm::new( + PageId::new_for_testing(1), + local_executor, + loader, + &PageVmEnvConfig { + web_storage: crate::RendererWebStorageHandles::ephemeral(), + root_frame_id: None, + main_document_commit: None, + top_level_storage_key: None, + document_start_scripts: vec![], + runtime_bindings: vec![], + runtime_inspector_session_restore_snapshots: vec![], + runtime_isolated_worlds: vec![], + permission_overrides: vec![], + extra_http_headers: Default::default(), + navigator_identity: Default::default(), + document_policy_container: Default::default(), + document_default_language: None, + document_last_modified: None, + document_settings: Default::default(), + network_offline: false, + blocked_url_patterns: Vec::new(), + indexed_db_manager: None, + storage_bucket_store: None, + fetch_subresource_interception_enabled: false, + fetch_subresource_interception_resource_type: None, + layout_configuration: moli_page_types::LayoutConfiguration { + policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, }, - ) - .await - .expect("inline importmap handoff should resolve without executing external fetch"); - assert!( - matches!(outcome, ScriptHandoffOutcome::NoNavigation), - "inline importmap handoff should remain a current-turn scheduling step" - ); - let expected_handle = format!("parser-script-native-{}", handle.index()); - assert_eq!( - page_vm - .vm() - .document_runtime - .resolve_host_script_handle(&expected_handle), - Some(handle), - "parser-owned inline importmap handoff should register a parser-owned host script handle before current-turn execution" - ); - assert_eq!( - page_vm - .vm_mut() - .document_runtime - .resolve_module_specifier( - "fixture", - &Url::parse("https://example.test/").expect("base url"), - ) - .expect("registered import map should resolve fixture"), - Url::parse("https://example.test/module.mjs").expect("mapped url"), - "dedicated import-map handoff should register before later module preparation" - ); - })); + 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(), + parser_dom_host, + Instant::now(), + ) + .expect("page vm"); + + let local_executor = page_vm.local_executor.clone(); + let page_vm_ptr: *mut PageVm = &mut page_vm; + let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; + let handoff = handoff.clone(); + let outcome = super::access::run_named_owner_local_task( + local_executor, + "phase-one inline importmap handoff local task channel closed", + async move { + let page_vm = unsafe { &mut *page_vm_ptr }; + let driver = unsafe { &mut *driver_ptr }; + driver + .handle_parse_time_script_handoff(page_vm, *handoff, None) + .await + }, + ) + .await + .expect("inline importmap handoff should resolve without executing external fetch"); + assert!( + matches!(outcome, ScriptHandoffOutcome::NoNavigation), + "inline importmap handoff should remain a current-turn scheduling step" + ); + let expected_handle = format!("parser-script-native-{}", handle.index()); + assert_eq!( + page_vm + .vm() + .document_runtime + .resolve_host_script_handle(&expected_handle), + Some(handle), + "parser-owned inline importmap handoff should register a parser-owned host script handle before current-turn execution" + ); + assert_eq!( + page_vm + .vm_mut() + .document_runtime + .resolve_module_specifier( + "fixture", + &Url::parse("https://example.test/").expect("base url"), + ) + .expect("registered import map should resolve fixture"), + Url::parse("https://example.test/module.mjs").expect("mapped url"), + "dedicated import-map handoff should register before later module preparation" + ); + })); } #[test] fn external_blocking_classic_handoff_is_stylesheet_gated_on_live_backend() { @@ -1261,6 +1266,7 @@ fn parser_owner_style_import_handoff_is_stylesheet_gated_on_live_page_vm() { }, 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/tests/extracted/tree_mutations_and_document_write.rs b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/tree_mutations_and_document_write.rs index 6da6f55d18..af72f6bb1b 100644 --- a/moli-renderer-v8/src/runtime/phase_one/tests/extracted/tree_mutations_and_document_write.rs +++ b/moli-renderer-v8/src/runtime/phase_one/tests/extracted/tree_mutations_and_document_write.rs @@ -1091,193 +1091,194 @@ fn parser_connected_head_document_write_keeps_later_head_tokens_in_head() { .expect("current-thread runtime should build"); runtime.block_on(tokio::task::LocalSet::new().run_until(async move { - let _js_runtime = crate::JsRuntime::initialize(); - let final_url = Url::parse("https://example.test/").expect("test url"); - let loader = Box::leak(Box::new( - ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"), - )); - let state = Box::leak(Box::new(ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url.clone()))); - let mut driver = ParserDriver { - loader, - final_url: &state.final_url, - parser_session: &mut state.parser_session, - scheduler: &mut state.scheduler, - buffered_document_preloads: &mut state.buffered_document_preloads, - service_worker_preload_context: state.service_worker_preload_context.as_ref(), + let _js_runtime = crate::JsRuntime::initialize(); + let final_url = Url::parse("https://example.test/").expect("test url"); + let loader = Box::leak(Box::new( + ResourceRequestClient::new(&FetchConfig::default()).expect("default loader"), + )); + let state = Box::leak(Box::new(ParseTimeDriverState::new_with_scripting_enabled_for_test(final_url.clone()))); + let mut driver = ParserDriver { + loader, + final_url: &state.final_url, + parser_session: &mut state.parser_session, + scheduler: &mut state.scheduler, + buffered_document_preloads: &mut state.buffered_document_preloads, + service_worker_preload_context: state.service_worker_preload_context.as_ref(), - input_closed: &state.input_closed, - }; + input_closed: &state.input_closed, + }; - let html = "<!doctype html><html><head><script>document.write('<style>.runtime-style{color:red}</style>');document.write('<script>window.__docWriteHeadMutation=true;<\\/script>');</script><meta charset='utf-8'><title>x
late
"; - let crate::parser::ParserPumpOutcome { - result, - discovered_async_prefetch_scripts: _, - discovered_modulepreload_link_candidates: _, - discovered_blocking_stylesheet_inputs: _, - } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step(html); - let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { - panic!("expected parser step to stop at inline script handoff"); - }; + let html = "x
late
"; + let crate::parser::ParserPumpOutcome { + result, + discovered_async_prefetch_scripts: _, + discovered_modulepreload_link_candidates: _, + discovered_blocking_stylesheet_inputs: _, + } = driver.parser_session.stream_handle().borrow_mut().pump_parser_step(html); + let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else { + panic!("expected parser step to stop at inline script handoff"); + }; - let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); - let local_executor = JsLocalExecutor::new(); - let mut page_vm = PageVm::new( - PageId::new_for_testing(1), - local_executor, - loader, - &PageVmEnvConfig { - web_storage: crate::RendererWebStorageHandles::ephemeral(), - root_frame_id: None, - main_document_commit: None, - top_level_storage_key: None, - document_start_scripts: vec![], - runtime_bindings: vec![], - runtime_inspector_session_restore_snapshots: vec![], - runtime_isolated_worlds: vec![], - permission_overrides: vec![], - extra_http_headers: Default::default(), - navigator_identity: Default::default(), - document_policy_container: Default::default(), - document_default_language: None, - document_last_modified: None, - document_settings: Default::default(), - network_offline: false, - blocked_url_patterns: Vec::new(), - indexed_db_manager: None, - storage_bucket_store: None, - fetch_subresource_interception_enabled: false, - fetch_subresource_interception_resource_type: None, - layout_configuration: moli_page_types::LayoutConfiguration { - policy: moli_page_types::LayoutPolicy::default(), - scrollbars_hidden: false, - }, - wpt_extensions_enabled: false, - navigation_bootstrap_entry: None, - reserved_service_worker_client_id: None, -}, - PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), - parser_dom_host, - Instant::now(), - ) - .expect("page vm"); - - let local_executor = page_vm.local_executor.clone(); - let page_vm_ptr: *mut PageVm = &mut page_vm; - let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; - let handoff = handoff.clone(); - let outcome = super::access::run_named_owner_local_task( - local_executor, - "phase-one document.write handoff local task channel closed", - async move { - let page_vm = unsafe { &mut *page_vm_ptr }; - let driver = unsafe { &mut *driver_ptr }; - driver - .handle_parse_time_script_handoff(page_vm, *handoff, None) - .await + let parser_dom_host = driver.parser_session.stream_handle().borrow_mut().take_parser_stream_dom_host(); + let local_executor = JsLocalExecutor::new(); + let mut page_vm = PageVm::new( + PageId::new_for_testing(1), + local_executor, + loader, + &PageVmEnvConfig { + web_storage: crate::RendererWebStorageHandles::ephemeral(), + root_frame_id: None, + main_document_commit: None, + top_level_storage_key: None, + document_start_scripts: vec![], + runtime_bindings: vec![], + runtime_inspector_session_restore_snapshots: vec![], + runtime_isolated_worlds: vec![], + permission_overrides: vec![], + extra_http_headers: Default::default(), + navigator_identity: Default::default(), + document_policy_container: Default::default(), + document_default_language: None, + document_last_modified: None, + document_settings: Default::default(), + network_offline: false, + blocked_url_patterns: Vec::new(), + indexed_db_manager: None, + storage_bucket_store: None, + fetch_subresource_interception_enabled: false, + fetch_subresource_interception_resource_type: None, + layout_configuration: moli_page_types::LayoutConfiguration { + policy: moli_page_types::LayoutPolicy::default(), + scrollbars_hidden: false, }, - ) - .await - .expect("inline document.write handoff should execute"); - assert!( - matches!(outcome, ScriptHandoffOutcome::NoNavigation), - "inline document.write fixture should not navigate" - ); + 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(), + parser_dom_host, + Instant::now(), + ) + .expect("page vm"); - let local_executor = page_vm.local_executor.clone(); - let page_vm_ptr: *mut PageVm = &mut page_vm; - let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; - let outcome = super::access::run_named_owner_local_task( - local_executor, - "phase-one document.write continuation local task channel closed", - async move { - let page_vm = unsafe { &mut *page_vm_ptr }; - let driver = unsafe { &mut *driver_ptr }; - driver.advance_parser_step(page_vm, "", None).await - }, - ) - .await - .expect("parser should continue after the inline script"); - assert!( - matches!(outcome, ParserStepAdvanceOutcome::Continue), - "parser should finish the remaining buffered html after the script" - ); + let local_executor = page_vm.local_executor.clone(); + let page_vm_ptr: *mut PageVm = &mut page_vm; + let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; + let handoff = handoff.clone(); + let outcome = super::access::run_named_owner_local_task( + local_executor, + "phase-one document.write handoff local task channel closed", + async move { + let page_vm = unsafe { &mut *page_vm_ptr }; + let driver = unsafe { &mut *driver_ptr }; + driver + .handle_parse_time_script_handoff(page_vm, *handoff, None) + .await + }, + ) + .await + .expect("inline document.write handoff should execute"); + assert!( + matches!(outcome, ScriptHandoffOutcome::NoNavigation), + "inline document.write fixture should not navigate" + ); - let snapshot = page_vm.vm().snapshot_live_document(); - let serialized = snapshot.serialize_document(); - assert!( - serialized.to_ascii_lowercase().contains(""), - "doctype should survive parser-connected document.write execution: {serialized}" - ); + let local_executor = page_vm.local_executor.clone(); + let page_vm_ptr: *mut PageVm = &mut page_vm; + let driver_ptr: *mut ParserDriver<'_, '_> = &mut driver; + let outcome = super::access::run_named_owner_local_task( + local_executor, + "phase-one document.write continuation local task channel closed", + async move { + let page_vm = unsafe { &mut *page_vm_ptr }; + let driver = unsafe { &mut *driver_ptr }; + driver.advance_parser_step(page_vm, "", None).await + }, + ) + .await + .expect("parser should continue after the inline script"); + assert!( + matches!(outcome, ParserStepAdvanceOutcome::Continue), + "parser should finish the remaining buffered html after the script" + ); - let inserted_script = snapshot - .script_handles() - .into_iter() - .find(|handle| { - snapshot - .direct_text_content(*handle) - .is_some_and(|source| { - source - .trim_start() - .starts_with("window.__docWriteHeadMutation") - }) - }) - .expect("document.write-inserted script should remain in the live document"); - assert_eq!( - page_vm - .vm() - .document_runtime - .parser_script_start_position(inserted_script), - Some(crate::document_runtime::ParserScriptStartPosition { - line: 0, - column: 0, - }), - "document.write-generated script source positions are intentionally unknown" - ); + let snapshot = page_vm.vm().snapshot_live_document(); + let serialized = snapshot.serialize_document(); + assert!( + serialized.to_ascii_lowercase().contains(""), + "doctype should survive parser-connected document.write execution: {serialized}" + ); - let head = snapshot.document_head_handle().expect("head should exist"); - let body = snapshot.document_body_handle().expect("body should exist"); - let head_children = snapshot.child_ids(head).collect::>(); - let body_children = snapshot.child_ids(body).collect::>(); + let inserted_script = snapshot + .script_handles() + .into_iter() + .find(|handle| { + snapshot + .direct_text_content(*handle) + .is_some_and(|source| { + source + .trim_start() + .starts_with("window.__docWriteHeadMutation") + }) + }) + .expect("document.write-inserted script should remain in the live document"); + assert_eq!( + page_vm + .vm() + .document_runtime + .parser_script_start_position(inserted_script), + Some(crate::document_runtime::ParserScriptStartPosition { + line: 0, + column: 0, + }), + "document.write-generated script source positions are intentionally unknown" + ); - assert!( - head_children.iter().any(|handle| { - snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.is_html_element("meta")) - }), - "later should stay under : {serialized}" - ); - assert!( - head_children.iter().any(|handle| { - snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.is_html_element("title")) - }), - "later should stay under <head>: {serialized}" - ); - assert!( - head_children.iter().any(|handle| { - snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| element.is_html_element("style")) - }), - "document.write-inserted <style> should stay under <head>: {serialized}" - ); - assert!( - body_children.iter().all(|handle| { - !snapshot - .node(*handle) - .and_then(Node::as_element) - .is_some_and(|element| { - element.is_html_element("meta") || element.is_html_element("title") - }) - }), - "<body> should not receive later head-only tokens: {serialized}" - ); - })); + let head = snapshot.document_head_handle().expect("head should exist"); + let body = snapshot.document_body_handle().expect("body should exist"); + let head_children = snapshot.child_ids(head).collect::<Vec<_>>(); + let body_children = snapshot.child_ids(body).collect::<Vec<_>>(); + + assert!( + head_children.iter().any(|handle| { + snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("meta")) + }), + "later <meta> should stay under <head>: {serialized}" + ); + assert!( + head_children.iter().any(|handle| { + snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("title")) + }), + "later <title> should stay under <head>: {serialized}" + ); + assert!( + head_children.iter().any(|handle| { + snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.is_html_element("style")) + }), + "document.write-inserted <style> should stay under <head>: {serialized}" + ); + assert!( + body_children.iter().all(|handle| { + !snapshot + .node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| { + element.is_html_element("meta") || element.is_html_element("title") + }) + }), + "<body> should not receive later head-only tokens: {serialized}" + ); + })); } #[test] fn document_write_fostered_text_updates_live_range_boundaries() { diff --git a/moli-renderer-v8/src/script_vm/child_frame_runtime.rs b/moli-renderer-v8/src/script_vm/child_frame_runtime.rs index f89f22a7f1..185233e29d 100644 --- a/moli-renderer-v8/src/script_vm/child_frame_runtime.rs +++ b/moli-renderer-v8/src/script_vm/child_frame_runtime.rs @@ -722,14 +722,27 @@ impl ScriptVm { }); } + #[cfg(test)] pub(crate) fn install_navigation_bootstrap_entry( &mut self, entry_seed: Option<crate::native_bridge::NavigationHistoryEntrySeed>, + ) { + self.install_navigation_bootstrap_from_history(entry_seed, None); + } + + pub(super) fn install_navigation_bootstrap_from_history( + &mut self, + entry_seed: Option<super::native_bridge::NavigationHistoryEntrySeed>, + source: Option<crate::runtime::RendererNavigationHistory>, ) { 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); + } crate::context_bootstrap::install_navigation_bootstrap_entry(scope, &entry_seed); Ok(()) }); diff --git a/moli-session-history/src/joint.rs b/moli-session-history/src/joint.rs index b5f0c074f5..535224417e 100644 --- a/moli-session-history/src/joint.rs +++ b/moli-session-history/src/joint.rs @@ -265,6 +265,19 @@ impl JointSessionHistory { }) } + pub fn context_entries(&self, context: SessionHistoryContextId) -> Vec<(SessionHistoryStepId, SessionHistoryEntry)> { + self.steps.iter().filter_map(|step| step.entries.get(&context).cloned().map(|entry| (step.id, entry))).collect() + } + + pub fn restore_context(&mut self, context: SessionHistoryContextId, parent: SessionHistoryContextId, entries: &[(SessionHistoryStepId, SessionHistoryEntry)]) { + self.parents.insert(context, parent); + for step in &mut self.steps { + if let Some((_, entry)) = entries.iter().find(|(id, _)| *id == step.id) { + step.entries.insert(context, entry.clone()); + } + } + } + pub fn prune_all_but_current(&mut self) { if self.steps.len() == 1 { return;