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.
This commit is contained in:
ldm0
2026-10-11 18:06:41 +08:00
parent b14722519f
commit 5e045a3c19
34 changed files with 2121 additions and 1162 deletions
@@ -1,5 +1,6 @@
mod browser_target;
mod emulation_input_storage;
mod nested_history;
mod network_fetch;
mod p0_input;
mod p0_network;
@@ -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<Value> {
self.evaluate_with_await(expression, false).await
}
async fn evaluate_await(&mut self, expression: &str) -> Result<Value> {
self.evaluate_with_await(expression, true).await
}
async fn evaluate_with_await(
&mut self,
expression: &str,
await_promise: bool,
) -> Result<Value> {
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(
"<body>",
"<head><script>onload=()=>document.body.dataset.loaded='yes';</script></head><body>",
),
);
url.into()
}
fn frame(url: &str, name: &str) -> String {
format!(
"<iframe data-static name=\"{name}\" src=\"{}\"></iframe>",
url.replace('&', "&amp;").replace('"', "&quot;")
)
}
async fn value(page: &mut HistoryPage, expression: &str) -> Result<Value> {
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> {
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, "<!doctype html><body><p>initial</p>");
for popup in [false, true] {
for layout in ["named", "unnamed", "dynamic"] {
let dynamic = if layout == "dynamic" {
format!(
"<script>const f=document.createElement('iframe');f.name='a';f.src={};document.body.prepend(f);</script>",
serde_json::to_string(&initial_url)?
)
} else {
String::new()
};
let names = if layout == "unnamed" {
["", ""]
} else {
["a", "b"]
};
let source = markup_url(
&server,
&format!(
"<!doctype html><body>{dynamic}{}{}",
frame(&initial_url, names[0]),
frame(&initial_url, names[1])
),
);
let away = markup_url(&server, "<!doctype html><body><p>away from parent</p>");
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!("<p>{text}</p>"),
)
.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::<Vec<_>>();
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, "<!doctype html><body><p>initial</p>");
let parent = markup_url(
&server,
&format!("<!doctype html><body>{}", frame(&leaf, "grandchild")),
);
let source = markup_url(
&server,
&format!(
"<!doctype html><body><script>const f=document.createElement('iframe');f.src={};document.body.append(f);</script>",
serde_json::to_string(&parent)?
),
);
let away = markup_url(&server, "<!doctype html><body><p>away from child</p>");
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, "<p>script parent historical child</p>").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, "<!doctype html><body><p>initial</p>");
let parent = markup_url(
&server,
&format!("<!doctype html><body>{}", frame(&leaf, "grandchild")),
);
let source = markup_url(
&server,
&format!("<!doctype html><body>{}", frame(&parent, "child")),
);
let away = markup_url(&server, "<!doctype html><body><p>away from grandchild</p>");
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, "<p>historical grandchild</p>").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(())
}
@@ -290,6 +290,7 @@ async fn fixture_handler(
&[],
),
"/plain" => html("<!doctype html><main>plain ok</main>"),
"/history-markup" => html(query.get("markup").map(String::as_str).unwrap_or_default()),
"/iframe" => html(r#"<!doctype html><main>parent</main><iframe src="/child"></iframe>"#),
"/child" => html("<!doctype html><body>child body text</body>"),
"/wait-for-function" => html(
@@ -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;
@@ -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();
}
@@ -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<NavigationHistoryEntrySeed> {
@@ -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<NavigationHistoryDocumentId> {
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>,
@@ -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
+5 -3
View File
@@ -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,
)
}
@@ -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()
@@ -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(
@@ -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<ChildHistoryIdentity>,
restored_history_positions: Option<nested_history::NavigableHistory>,
restoring_history: bool,
current_document_loader_id: Option<String>,
name: Option<String>,
id: Option<String>,
@@ -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<NavigationHistoryDocumentId, Arc<srcdoc_history::SrcdocHistoryResource>>,
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<Mutex<HashMap<ChildHistoryIdentity, ChildHistoryRecord>>>,
}
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<OwnerDispatchScope> {
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<ChildHistoryIdentity> {
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<ChildHistoryRecord> {
// 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()),
);
}
}
}
@@ -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);
}
@@ -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(),
@@ -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<opfs_tasks::WindowOpfsOwnerState>,
pub(super) history_queue: HistoryQueueState,
popup_nested_histories: HashMap<OwnerDispatchScope, NestedHistoryStore>,
claimed_child_histories: HashMap<DomHandle, HashSet<child_frames::ChildHistoryIdentity>>,
rendering_updates: rendering_updates::RenderingUpdateState,
scroll_observable_effect_batch: interaction_batch::ScrollObservableEffectBatchState,
view_transition_updates: view_transition_updates::ViewTransitionUpdateState,
@@ -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,
@@ -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)
@@ -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(_) => {
@@ -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::<Vec<_>>();
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<v8::Local<'s, v8::Object>> {
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<v8::Local<'s, v8::Object>> {
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)
}
@@ -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<Mutex<Option<Arc<NavigationHistoryEntrySeed>>>>,
joint: Arc<Mutex<Option<JointSessionHistory>>>,
nested: NestedHistoryStore,
selected_step: Arc<Mutex<Option<(String, SessionHistoryStepId)>>>,
}
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<NavigationHistoryDocumentId> {
Some(
current_entry(self.snapshot()?.as_ref())?
.document_id
.clone(),
)
}
pub(crate) fn publish_joint_history(&self, joint: Option<JointSessionHistory>) {
*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::<Vec<_>>(),
);
}
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<NavigationHistoryEntrySeed>,
source_index: Option<u32>,
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.
@@ -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),
};
@@ -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();
@@ -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,
}
}
+5 -1
View File
@@ -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<crate::native_bridge::NavigationHistoryEntrySeed>,
pub(crate) navigation_history_source: Option<crate::runtime::RendererNavigationHistory>,
pub(crate) reserved_service_worker_client_id:
Option<crate::service_worker_runtime::ServiceWorkerClientId>,
}
@@ -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
@@ -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,
}
}
@@ -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(),
@@ -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,
@@ -704,6 +704,7 @@ mod tests {
},
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
}
}
@@ -1082,6 +1082,7 @@ mod tests {
},
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
}
}
@@ -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 = "<!doctype html><html><head><script>const body = document.createElement('body');document.documentElement.appendChild(body);const early = document.createElement('div');early.id = 'early';body.appendChild(early);</script><meta charset='utf-8'><title>x</title></head><body><main>late</main></body></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(html);
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else {
panic!("expected parser step to stop at inline script handoff");
};
let html = "<!doctype html><html><head><script>const body = document.createElement('body');document.documentElement.appendChild(body);const early = document.createElement('div');early.id = 'early';body.appendChild(early);</script><meta charset='utf-8'><title>x</title></head><body><main>late</main></body></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(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 html>"),
"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::<Vec<_>>();
let body_children = snapshot.child_ids(body).collect::<Vec<_>>();
let snapshot = page_vm.vm().snapshot_live_document();
let serialized = snapshot.serialize_document();
assert!(
serialized.to_ascii_lowercase().contains("<!doctype html>"),
"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 <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}"
);
}));
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</title></head><body><main>late</main></body></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(&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::<String>()
.replace('+', "%20");
let html = format!(
"<!doctype html><html><head><script src=\"data:text/javascript,{encoded_script}\"></script><meta charset='utf-8'><title>x</title></head><body><main>late</main></body></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(&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 html>"),
"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::<Vec<_>>();
let body_children = snapshot.child_ids(body).collect::<Vec<_>>();
let snapshot = page_vm.vm().snapshot_live_document();
let serialized = snapshot.serialize_document();
assert!(
serialized.to_ascii_lowercase().contains("<!doctype html>"),
"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 <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}"
);
}));
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(),
File diff suppressed because it is too large Load Diff
@@ -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</title></head><body><main>late</main></body></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(html);
let ParserPumpStep::Yield(ParserYield::Script(handoff)) = result else {
panic!("expected parser step to stop at inline script handoff");
};
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</title></head><body><main>late</main></body></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(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 html>"),
"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 html>"),
"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::<Vec<_>>();
let body_children = snapshot.child_ids(body).collect::<Vec<_>>();
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 <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}"
);
}));
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() {
@@ -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(())
});
+13
View File
@@ -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;