mirror of
https://github.com/lexmount/moli.git
synced 2026-11-01 00:07:03 +00:00
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:
@@ -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('&', "&").replace('"', """)
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+371
-366
@@ -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(),
|
||||
|
||||
+611
-605
File diff suppressed because it is too large
Load Diff
+179
-178
@@ -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(())
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user