fix(history): restore nested histories across document replacement

Keep child entries and srcdoc resources with their parent Document identity
when top-level and popup Documents are replaced. Restore their original joint
history steps, avoid traversing descendants of a retiring parent, and prune
forward entries when navigation branches from an earlier step.

Preserve parser-created frame identities in navigation projections. Cover
named and unnamed frames, script-created parents, nested Documents, entry
state and identity, and interleaved multi-step traversals through CDP.
This commit is contained in:
ldm0
2026-09-23 00:14:10 +08:00
parent 059639f6b7
commit 7e0cf7bcce
29 changed files with 1278 additions and 19 deletions
@@ -1,5 +1,6 @@
mod browser_target;
mod emulation_input_storage;
mod nested_history;
mod network_fetch;
mod p0_input;
mod p0_network;
@@ -0,0 +1,410 @@
use super::super::tests_cdp_smoke_fixture::SmokeFixtureServer;
use super::super::*;
use super::support::CdpPageHarness;
use anyhow::{Context, Result};
use serde_json::{Value, json};
use tokio::time::Duration;
use url::Url;
struct HistoryPage {
ctx: TestContext,
cdp: CdpPageHarness,
next_id: u64,
}
impl HistoryPage {
async fn evaluate(&mut self, expression: &str) -> Result<Value> {
self.evaluate_with_await(expression, false).await
}
async fn evaluate_await(&mut self, expression: &str) -> Result<Value> {
self.evaluate_with_await(expression, true).await
}
async fn evaluate_with_await(
&mut self,
expression: &str,
await_promise: bool,
) -> Result<Value> {
let id = self.next_id;
self.next_id += 1;
let response = if await_promise {
self.cdp
.evaluate_await_value(&mut self.ctx, id, expression)
.await
} else {
self.cdp.evaluate_value(&mut self.ctx, id, expression).await
};
anyhow::ensure!(
response.get("error").is_none() && response["result"].get("exceptionDetails").is_none(),
"evaluating {expression}: {response}"
);
Ok(response["result"]["result"].clone())
}
}
fn markup_url(server: &SmokeFixtureServer, markup: &str) -> String {
let mut url = Url::parse(&server.url("/history-markup")).unwrap();
url.query_pairs_mut().append_pair(
"markup",
&markup.replace(
"<body>",
"<head><script>onload=()=>document.body.dataset.loaded='yes';</script></head><body>",
),
);
url.into()
}
fn frame(url: &str, name: &str) -> String {
format!(
"<iframe data-static name=\"{name}\" src=\"{}\"></iframe>",
url.replace('&', "&amp;").replace('"', "&quot;")
)
}
async fn value(page: &mut HistoryPage, expression: &str) -> Result<Value> {
let result = page
.evaluate(&format!("JSON.stringify({expression})"))
.await?;
Ok(serde_json::from_str(
result["value"]
.as_str()
.unwrap_or_else(|| panic!("{expression}: {result}")),
)?)
}
async fn wait(page: &mut HistoryPage, expression: &str) -> Result<()> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let result = page.evaluate(expression).await;
if result.as_ref().is_ok_and(|value| value["value"] == true) {
return Ok(());
}
anyhow::ensure!(
tokio::time::Instant::now() < deadline,
"timed out waiting for {expression}: {result:?}"
);
page.ctx.complete_one_ready_scheduler_input_for_test().await;
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
async fn navigate(page: &mut HistoryPage, root: &str, action: &str, url: &str) -> Result<()> {
page.evaluate(&format!("({root}).{action}; true")).await?;
wait(
page,
&format!(
"({root}).location.href === {} && ({root}).document.body?.dataset.loaded === 'yes'",
serde_json::to_string(url)?
),
)
.await
.with_context(|| format!("after {root}.{action}"))
}
async fn open_root(
url: &str,
popup: bool,
opener_url: &str,
) -> Result<(HistoryPage, &'static str)> {
let mut ctx = TestContext::new_with_target_discovery(false);
let cdp = CdpPageHarness::attach(&mut ctx, 220_000).await;
let response = cdp
.navigate(&mut ctx, 220_005, if popup { opener_url } else { url })
.await;
anyhow::ensure!(response.get("error").is_none(), "{response}");
let mut page = HistoryPage {
ctx,
cdp,
next_id: 220_010,
};
let root = if popup { "testPopup" } else { "window" };
if popup {
page.evaluate(&format!(
"globalThis.testPopup = open({}); true",
serde_json::to_string(url)?
))
.await?;
}
wait(
&mut page,
&format!(
"({root}).location.href === {} && ({root}).document.body?.dataset.loaded === 'yes'",
serde_json::to_string(url)?
),
)
.await
.with_context(|| format!("opening {root}"))?;
Ok((page, root))
}
fn frames(root: &str) -> String {
format!("Array.from(({root}).document.querySelectorAll('iframe[data-static]'))")
}
async fn snapshot(page: &mut HistoryPage, root: &str) -> Result<Value> {
value(page, &format!("({{length:({root}).history.length, frames:{}.map(f=>({{url:f.contentWindow.location.href, text:f.contentDocument.body.textContent, state:f.contentWindow.history.state, navigationState:f.contentWindow.navigation.currentEntry.getState() ?? null, id:f.contentWindow.navigation.currentEntry.id, keys:f.contentWindow.navigation.entries().map(e=>e.key)}}))}})", frames(root))).await
}
async fn srcdoc(page: &mut HistoryPage, frame: &str, markup: &str) -> Result<()> {
page.evaluate_await(&format!(
"(async()=>{{const f=({frame}); const loaded=new Promise(resolve=>f.addEventListener('load',()=>setTimeout(resolve,0),{{once:true}})); f.srcdoc={}; await loaded; return true;}})()",
serde_json::to_string(markup)?)).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn nested_history_restores_interleaved_frames_after_parent_document_replacement() -> Result<()>
{
let server = SmokeFixtureServer::start().await;
let initial_url = markup_url(&server, "<!doctype html><body><p>initial</p>");
for popup in [false, true] {
for layout in ["named", "unnamed", "dynamic"] {
let dynamic = if layout == "dynamic" {
format!(
"<script>const f=document.createElement('iframe');f.name='a';f.src={};document.body.prepend(f);</script>",
serde_json::to_string(&initial_url)?
)
} else {
String::new()
};
let names = if layout == "unnamed" {
["", ""]
} else {
["a", "b"]
};
let source = markup_url(
&server,
&format!(
"<!doctype html><body>{dynamic}{}{}",
frame(&initial_url, names[0]),
frame(&initial_url, names[1])
),
);
let away = markup_url(&server, "<!doctype html><body><p>away from parent</p>");
let (mut page, root) = open_root(&source, popup, &server.url("/plain?opener")).await?;
let list = frames(root);
let initial = snapshot(&mut page, root).await?;
let mut stages = vec![initial.clone()];
for (index, text) in [
(0, "first historical source"),
(1, "second historical source"),
] {
srcdoc(
&mut page,
&format!("({list})[{index}]"),
&format!("<p>{text}</p>"),
)
.await?;
wait(
&mut page,
&format!("({list})[{index}].contentDocument.body.textContent === '{text}'"),
)
.await?;
stages.push(snapshot(&mut page, root).await?);
}
page.evaluate(&format!(
"({list})[0].contentWindow.location.hash = 'fragment'; true"
))
.await?;
wait(
&mut page,
&format!("({list})[0].contentWindow.location.hash === '#fragment'"),
)
.await?;
page.evaluate(&format!("({list})[0].contentWindow.history.replaceState({{classic:3}}, ''); ({list})[0].contentWindow.navigation.updateCurrentEntry({{state:{{navigation:3}}}}); true")).await?;
let expected = snapshot(&mut page, root).await?;
assert_eq!(
expected["length"].as_u64(),
initial["length"].as_u64().map(|length| length + 3)
);
navigate(
&mut page,
root,
&format!("location.href = {}", serde_json::to_string(&away)?),
&away,
)
.await?;
navigate(&mut page, root, "history.back()", &source).await?;
let restored = snapshot(&mut page, root).await?;
assert_eq!(
restored["frames"], expected["frames"],
"popup={popup}, {layout}: {restored}"
);
assert_eq!(
restored["length"].as_u64(),
initial["length"].as_u64().map(|length| length + 4)
);
for reference in stages.iter().rev() {
page.evaluate(&format!("({root}).history.back(); true"))
.await?;
let urls = reference["frames"]
.as_array()
.unwrap()
.iter()
.map(|frame| frame["url"].clone())
.collect::<Vec<_>>();
wait(
&mut page,
&format!(
"JSON.stringify(({list}).map(f=>f.contentWindow.location.href)) === {}",
serde_json::to_string(&serde_json::to_string(&urls)?)?
),
)
.await?;
let current = snapshot(&mut page, root).await?;
for index in 0..2 {
assert_eq!(
current["frames"][index]["id"], reference["frames"][index]["id"],
"popup={popup}, {layout}: {current}"
);
}
}
navigate(&mut page, root, "history.go(4)", &away).await?;
navigate(&mut page, root, "history.go(-3)", &source).await?;
let restored = snapshot(&mut page, root).await?;
for index in 0..2 {
assert_eq!(
restored["frames"][index]["id"], stages[1]["frames"][index]["id"],
"popup={popup}, {layout}: {restored}"
);
}
page.evaluate(&format!(
"({list})[1].contentWindow.history.pushState({{fork:true}}, '', '#fork'); true"
))
.await?;
assert_eq!(
value(&mut page, &format!("({root}).history.length")).await?,
json!(initial["length"].as_u64().unwrap() + 2)
);
if layout == "dynamic" {
assert_eq!(value(&mut page, &format!("({root}).document.querySelector('iframe').contentDocument.body.textContent")).await?, json!("initial"));
}
if popup {
page.evaluate("testPopup.close(); true").await?;
}
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn nested_history_retains_children_of_a_script_created_parent_navigable() -> Result<()> {
let server = SmokeFixtureServer::start().await;
let leaf = markup_url(&server, "<!doctype html><body><p>initial</p>");
let parent = markup_url(
&server,
&format!("<!doctype html><body>{}", frame(&leaf, "grandchild")),
);
let source = markup_url(
&server,
&format!(
"<!doctype html><body><script>const f=document.createElement('iframe');f.src={};document.body.append(f);</script>",
serde_json::to_string(&parent)?
),
);
let away = markup_url(&server, "<!doctype html><body><p>away from child</p>");
for popup in [false, true] {
let (mut page, root) = open_root(&source, popup, &server.url("/plain?opener")).await?;
let container = format!("({root}).document.querySelector('iframe').contentWindow");
let child = format!("({container}).document.querySelector('iframe')");
srcdoc(&mut page, &child, "<p>script parent historical child</p>").await?;
let id = value(
&mut page,
&format!("({child}).contentWindow.navigation.currentEntry.id"),
)
.await?;
navigate(
&mut page,
&container,
&format!("location.href = {}", serde_json::to_string(&away)?),
&away,
)
.await?;
navigate(&mut page, &container, "history.back()", &parent).await?;
assert_eq!(
value(
&mut page,
&format!("({child}).contentWindow.navigation.currentEntry.id")
)
.await?,
id
);
assert_eq!(
value(
&mut page,
&format!("({child}).contentDocument.body.textContent")
)
.await?,
json!("script parent historical child")
);
if popup {
page.evaluate("testPopup.close(); true").await?;
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn nested_history_restores_grandchildren_with_their_parent_document_identity() -> Result<()> {
let server = SmokeFixtureServer::start().await;
let leaf = markup_url(&server, "<!doctype html><body><p>initial</p>");
let parent = markup_url(
&server,
&format!("<!doctype html><body>{}", frame(&leaf, "grandchild")),
);
let source = markup_url(
&server,
&format!("<!doctype html><body>{}", frame(&parent, "child")),
);
let away = markup_url(&server, "<!doctype html><body><p>away from grandchild</p>");
for popup in [false, true] {
let (mut page, root) = open_root(&source, popup, &server.url("/plain?opener")).await?;
let child = format!(
"({root}).document.querySelector('iframe').contentDocument.querySelector('iframe')"
);
srcdoc(&mut page, &child, "<p>historical grandchild</p>").await?;
wait(
&mut page,
&format!("({child}).contentDocument.body.textContent === 'historical grandchild'"),
)
.await?;
let id = value(
&mut page,
&format!("({child}).contentWindow.navigation.currentEntry.id"),
)
.await?;
navigate(
&mut page,
root,
&format!("location.href = {}", serde_json::to_string(&away)?),
&away,
)
.await?;
navigate(&mut page, root, "history.back()", &source).await?;
assert_eq!(
value(
&mut page,
&format!("({child}).contentWindow.navigation.currentEntry.id")
)
.await?,
id
);
assert_eq!(
value(
&mut page,
&format!("({child}).contentDocument.body.textContent")
)
.await?,
json!("historical grandchild")
);
if popup {
page.evaluate("testPopup.close(); true").await?;
}
}
Ok(())
}
@@ -290,6 +290,7 @@ async fn fixture_handler(
&[],
),
"/plain" => html("<!doctype html><main>plain ok</main>"),
"/history-markup" => html(query.get("markup").map(String::as_str).unwrap_or_default()),
"/iframe" => html(r#"<!doctype html><main>parent</main><iframe src="/child"></iframe>"#),
"/child" => html("<!doctype html><body>child body text</body>"),
"/wait-for-function" => html(
@@ -73,6 +73,8 @@ mod navigation_restore;
mod navigation_result;
mod navigation_seed;
mod navigation_serialize;
pub(crate) use navigation_entry::navigation_entry_public_token;
pub(crate) use navigation_serialize::history_document_id_for_holder;
mod navigation_surface;
mod navigation_traversal;
mod navigation_traversal_execution;
@@ -113,6 +113,28 @@ pub(super) fn refresh<'s>(
if pushing == Some(child) {
child_snapshot = before_push(child_snapshot);
}
if let Some(current) = child_snapshot
.entries
.iter()
.find(|entry| entry.index == child_snapshot.current_index)
&& let Some(positions) = host
.take_restored_child_history_positions(handle, &current.key)
.or_else(|| {
if host
.joint_histories
.get(root)?
.navigable_history(child)
.is_some()
{
return None;
}
host.saved_child_history_positions(handle, &current.key)
})
{
host.joint_histories
.get_mut(root)?
.restore_child(child, positions, &current.key);
}
host.joint_histories
.get_mut(root)?
.ensure_child(child, child_snapshot);
@@ -120,6 +142,7 @@ pub(super) fn refresh<'s>(
host.joint_histories
.get_mut(root)?
.retain_navigables(|owner| live.contains(&owner));
host.remember_nested_histories(root);
Some(root)
}
@@ -261,7 +284,7 @@ fn apply_plan(
host: &mut JsContextHost,
source_target: WindowTaskTarget,
root: OwnerDispatchScope,
plan: JointHistoryTraversal,
mut plan: JointHistoryTraversal,
mut method: Option<PendingHistoryTraversal>,
) {
if plan.targets.iter().any(|target| {
@@ -305,9 +328,47 @@ fn apply_plan(
host.record_pending_top_level_history_traversal(
i64::from(target.index) - i64::from(current),
);
host.top_level_navigation_history()
.select_joint_traversal(target.key.clone(), plan.step);
return;
}
}
// A replaced parent reconstructs its descendants from the target
// Document's nested histories. Traversing the old descendants as well
// can commit into a subtree whose parent is already being retired.
let mut replacing_documents = Vec::new();
for target in &plan.targets {
let Some(window) = window_for_owner(scope, host, target.owner) else {
continue;
};
let Some(history) = window_history_for_holder(scope, window) else {
continue;
};
let current = history_index(scope, history);
if super::navigation_seed::history_entry_seed_for_traversal(
scope,
window,
current,
target.index,
)
.is_some()
{
replacing_documents.push(target.owner);
}
}
plan.targets.retain(|target| {
let mut owner = target.owner;
while let OwnerDispatchScope::Child(handle) = owner {
let Some(parent) = host.owner_dispatch_scope_for_node(handle) else {
break;
};
if replacing_documents.contains(&parent) {
return false;
}
owner = parent;
}
true
});
let targets = plan
.targets
.iter()
@@ -437,6 +498,22 @@ pub(crate) fn commit<'s>(
let Some(snapshot) = snapshot(scope, history) else {
return;
};
if pushing.is_some() {
let root_window = runtime_top_window_owner(scope, owner);
if let Some(seed) = super::navigation_serialize::capture_navigation_entry_seed_for_holder(
scope,
root_window,
) && let Some(joint) = host.joint_histories.get(root)
{
let step = joint.source_step();
let roots = seed
.entries
.iter()
.map(|entry| entry.document_id.clone())
.collect::<Vec<_>>();
host.nested_history_store(root).prune(step, &roots);
}
}
let Some(joint) = host.joint_histories.get_mut(root) else {
return;
};
@@ -466,6 +543,7 @@ pub(crate) fn commit<'s>(
set_history_length(scope, history, length);
prune_runtime_entries(scope, host, removed);
finish_traversal(scope, host, root);
host.remember_nested_histories(root);
}
fn finish_traversal(
@@ -274,7 +274,7 @@ pub(super) fn navigation_entry_referrer_policy_value<'s>(
navigation_entry_stored_string(scope, entry, NAVIGATION_ENTRY_REFERRER_POLICY_SLOT)
}
pub(super) fn navigation_entry_public_token(token: &str) -> String {
pub(crate) fn navigation_entry_public_token(token: &str) -> String {
if token.is_empty() || is_uuid_v4_like(token) {
return token.to_owned();
}
@@ -21,7 +21,7 @@ use crate::{
referrer_policy::normalize_referrer_policy, util::context_host_ptr_from_window_object,
};
fn capture_navigation_entry_seed_for_holder<'s>(
pub(crate) fn capture_navigation_entry_seed_for_holder<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
) -> Option<NavigationHistoryEntrySeed> {
@@ -34,6 +34,14 @@ fn capture_navigation_entry_seed_for_holder<'s>(
})
}
pub(crate) fn history_document_id_for_holder<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
) -> Option<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>,
+5 -3
View File
@@ -388,13 +388,13 @@ pub(crate) fn parse_browsing_context_document_projection_from_source<'s>(
let source = preserve_decoded_bom_only_browsing_context_body(source, content_type);
let (parsed, kind) =
parse_browsing_context_document_snapshot(document_url, &source, content_type, html_parser);
build_detached_document_from_dom_host_with_content_type(
build_detached_document_object_from_dom_host_with_content_type(
scope,
kind.bridge_kind(),
parsed,
kind,
true,
content_type,
character_set,
true,
)
}
@@ -473,6 +473,7 @@ fn build_detached_document_with_content_type<'s>(
DomHost::from_dom(parsed),
content_type,
None,
false,
)
}
@@ -501,6 +502,7 @@ fn build_detached_document_from_dom_host_with_content_type<'s>(
parsed,
content_type,
character_set,
false,
)
}
@@ -237,6 +237,7 @@ impl JsContextHost {
{
entry.remember_srcdoc_history_resource(snapshot, origin);
}
self.remember_child_history(handle);
let owner_local_window_id = current_owner.local_window_id;
let owner_document_id = current_owner.document_id;
self.dom_host_mut()
@@ -476,6 +476,7 @@ impl JsContextHost {
scope: &mut v8::PinScope<'_, '_>,
handle: DomHandle,
) {
let restoring = self.take_child_history_restoration(handle);
let increments_joint_history = self
.child_browsing_contexts
.get_mut(&handle)
@@ -493,7 +494,7 @@ impl JsContextHost {
});
let update = if increments_joint_history {
moli_page_types::SameDocumentHistoryUpdate::Push
} else if traversing {
} else if traversing && !restoring {
moli_page_types::SameDocumentHistoryUpdate::Traverse { delta: 0 }
} else {
moli_page_types::SameDocumentHistoryUpdate::Replace
@@ -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, joint_snapshot};
pub(crate) use request_scope::WebStorageScope;
pub(in crate::native_bridge::context_host) use request_scope::{
document_sandbox_policy_from_attribute, sandbox_attribute_forces_opaque_origin,
@@ -42,6 +45,9 @@ pub(in crate::native_bridge::context_host) use request_scope::{
#[derive(Debug, Clone)]
pub(super) struct ChildBrowsingContextEntry {
frame_id: String,
history_identity: Option<ChildHistoryIdentity>,
restored_history_positions: Option<crate::native_bridge::joint_history::NavigableHistory>,
restoring_history: bool,
current_document_loader_id: Option<String>,
name: Option<String>,
id: Option<String>,
@@ -0,0 +1,351 @@
use super::*;
use crate::context_bootstrap::navigation_entry_public_token;
use crate::native_bridge::{
OwnerDispatchScope,
joint_history::{
JointHistoryEntry, JointHistorySnapshot, NavigableHistory, SessionHistoryStep,
},
};
use parking_lot::Mutex;
/// A parser-created frame can be recreated when its parent Document is
/// repopulated. Script-created frames retain their own nested histories, but
/// a new script-created navigable must not claim an old one's history.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(in crate::native_bridge::context_host) struct ChildHistoryIdentity {
parent: NavigationHistoryDocumentId,
key: ChildHistoryKey,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
enum ChildHistoryKey {
Parser { name: String, ordinal: usize },
Script(NavigationHistoryDocumentId),
}
#[derive(Clone, Debug)]
pub(super) struct ChildHistoryRecord {
seed: NavigationHistoryEntrySeed,
resources: HashMap<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, step: SessionHistoryStep, roots: &[NavigationHistoryDocumentId]) {
let mut records = self.records.lock();
for record in records.values_mut() {
record.positions.retain_through(step);
record.seed.entries.retain(|entry| {
record
.positions
.contains(&navigation_entry_public_token(entry.key.as_str()))
});
record.resources.retain(|id, _| {
record
.seed
.entries
.iter()
.any(|entry| &entry.document_id == id)
});
}
let mut reachable: HashSet<_> = roots.iter().cloned().collect();
loop {
let count = reachable.len();
for (identity, record) in records.iter() {
if reachable.contains(&identity.parent) {
reachable.extend(
record
.seed
.entries
.iter()
.map(|entry| entry.document_id.clone()),
);
}
}
if reachable.len() == count {
break;
}
}
records.retain(|identity, record| {
reachable.contains(&identity.parent) && !record.seed.entries.is_empty()
});
}
}
pub(crate) fn joint_snapshot(seed: &NavigationHistoryEntrySeed) -> JointHistorySnapshot {
JointHistorySnapshot {
entries: seed
.entries
.iter()
.map(|entry| JointHistoryEntry {
index: entry.history_index,
navigation_index: entry.index,
key: navigation_entry_public_token(entry.key.as_str()),
})
.collect(),
current_index: seed.current_index,
}
}
impl JsContextHost {
fn nested_history_root(&self, handle: DomHandle) -> Option<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 step = self.joint_histories.get(root)?.destination_step();
let store = self.nested_history_store(root);
let mut record = store.records.lock().get(identity)?.clone();
let key = &record.positions.entry_at(step)?.key;
let target = record
.seed
.entries
.iter()
.find(|entry| navigation_entry_public_token(entry.key.as_str()) == *key)?
.clone();
record.seed.current_index = target.history_index;
record.seed.activation = Some(NavigationActivationSeed {
entry: target,
from: None,
navigation_type: Some("traverse".to_owned()),
});
Some(record)
}
pub(super) fn restore_child_history_after_initial_empty(
&mut self,
handle: DomHandle,
record: ChildHistoryRecord,
) {
let Some(target) = record
.seed
.entries
.iter()
.find(|entry| entry.history_index == record.seed.current_index)
else {
return;
};
let url = target.url.clone();
if let Some(entry) = self.child_browsing_contexts.get_mut(&handle) {
entry.srcdoc_history = record.resources;
entry.restored_history_positions = Some(record.positions);
entry.restoring_history = true;
}
self.queue_deferred_child_browsing_context_navigation_from_entry_seed(
handle,
&url,
record.seed,
false,
None,
);
}
pub(crate) fn take_restored_child_history_positions(
&mut self,
handle: DomHandle,
key: &str,
) -> Option<NavigableHistory> {
let entry = self.child_browsing_contexts.get_mut(&handle)?;
if !entry.restored_history_positions.as_ref()?.contains(key) {
return None;
}
entry.restored_history_positions.take()
}
pub(crate) fn saved_child_history_positions(
&mut self,
handle: DomHandle,
key: &str,
) -> Option<NavigableHistory> {
let identity = self
.child_browsing_contexts
.get(&handle)?
.history_identity
.clone()?;
let root = self.nested_history_root(handle)?;
let store = self.nested_history_store(root);
let records = store.records.lock();
let positions = &records.get(&identity)?.positions;
positions.contains(key).then(|| positions.clone())
}
pub(crate) fn remember_child_history(&mut self, handle: DomHandle) {
let Some(root) = self.nested_history_root(handle) else {
return;
};
let store = self.nested_history_store(root);
let Some(entry) = self.child_browsing_contexts.get(&handle) else {
return;
};
let Some(identity) = entry.history_identity.clone() else {
return;
};
let seed = entry.committed_navigation_entry_seed();
let joint = self.joint_histories.get(root);
let positions = entry
.restored_history_positions
.clone()
.or_else(|| {
joint.and_then(|joint| joint.navigable_history(OwnerDispatchScope::Child(handle)))
})
// Lazy or retiring realms may have no live joint-ledger entry.
// Their committed positions still belong to the navigable and
// must not be rebased to the traversable's current step.
.or_else(|| {
store
.records
.lock()
.get(&identity)
.map(|record| record.positions.clone())
})
.or_else(|| {
NavigableHistory::new(
joint_snapshot(&seed),
joint.map_or(SessionHistoryStep::INITIAL, |joint| {
joint.destination_step()
}),
)
});
let Some(positions) = positions else {
return;
};
// A new runtime can briefly expose its initial empty Document while
// the historical target is pending. It must not overwrite that target.
if !seed
.entries
.iter()
.find(|entry| entry.history_index == seed.current_index)
.is_some_and(|entry| {
positions.contains(&navigation_entry_public_token(entry.key.as_str()))
})
{
return;
}
let record = ChildHistoryRecord {
seed,
resources: entry.srcdoc_history.clone(),
positions,
};
store.records.lock().insert(identity, record);
}
pub(in crate::native_bridge::context_host) fn take_child_history_restoration(
&mut self,
handle: DomHandle,
) -> bool {
self.child_browsing_contexts
.get_mut(&handle)
.is_some_and(|entry| std::mem::take(&mut entry.restoring_history))
}
pub(crate) fn remember_nested_histories(&mut self, root: OwnerDispatchScope) {
let handles: Vec<_> = self
.child_browsing_contexts
.keys()
.copied()
.filter(|handle| self.nested_history_root(*handle) == Some(root))
.collect();
for handle in handles {
self.remember_child_history(handle);
}
if root == OwnerDispatchScope::Top {
self.top_level_navigation_history.publish_joint_history(
self.joint_histories
.get(root)
.and_then(|joint| joint.restoration(root)),
);
}
}
}
@@ -38,6 +38,7 @@ impl JsContextHost {
retired_owner: crate::frame_owner_model::FrameDocumentTaskOwner,
document_handle: DomHandle,
) {
self.claimed_child_histories.remove(&document_handle);
let _ = self.retire_document_resource_loader(
crate::native_bridge::WindowDocumentOwner::Frame(retired_owner),
);
@@ -96,6 +97,17 @@ impl JsContextHost {
self.object_fallback_bootstraps.remove(&handle);
let existing = self.child_browsing_contexts.get(&handle).cloned();
let is_new = existing.is_none();
let history_identity = existing
.as_ref()
.and_then(|entry| entry.history_identity.clone())
.or_else(|| self.child_history_identity(scope, handle));
let restored_history = if is_new {
history_identity
.as_ref()
.and_then(|identity| self.child_history_for_restoration(handle, identity))
} else {
None
};
let attribute_bootstrap_changed = existing
.as_ref()
.is_some_and(|entry| entry.attribute_bootstrap_changed(&attribute_bootstrap));
@@ -301,10 +313,12 @@ impl JsContextHost {
});
}
let initial_about_blank_document_is_complete = is_new
&& restored_history.is_none()
&& child_browsing_context_bootstrap_is_initial_about_blank(
&attribute_bootstrap,
);
let initial_navigation_uses_initial_empty_load = is_new
&& restored_history.is_none()
&& child_browsing_context_bootstrap_uses_initial_empty_load(
&attribute_bootstrap,
);
@@ -438,6 +452,13 @@ impl JsContextHost {
handle,
ChildBrowsingContextEntry {
frame_id,
history_identity,
restoring_history: existing
.as_ref()
.is_some_and(|entry| entry.restoring_history),
restored_history_positions: existing
.as_ref()
.and_then(|entry| entry.restored_history_positions.clone()),
current_document_loader_id: existing.as_ref().and_then(|entry| {
entry.current_document_loader_id().map(ToOwned::to_owned)
}),
@@ -587,6 +608,9 @@ impl JsContextHost {
{
entry.set_document_permissions_policy(policy);
}
if let Some(restored) = restored_history {
self.restore_child_history_after_initial_empty(handle, restored);
}
if attribute_bootstrap_changed {
self.cancel_child_meta_refresh_navigation(handle);
}
@@ -339,6 +339,8 @@ impl JsContextHost {
opfs_owner_state: None,
history_queue: HistoryQueueState::default(),
joint_histories: super::super::joint_history::JointSessionHistories::default(),
popup_nested_histories: HashMap::new(),
claimed_child_histories: HashMap::new(),
rendering_updates: super::rendering_updates::RenderingUpdateState::default(),
scroll_observable_effect_batch:
super::interaction_batch::ScrollObservableEffectBatchState::default(),
@@ -195,6 +195,7 @@ pub(crate) use child_frame_snapshots::{
};
use child_frames::ChildBrowsingContextEntry;
pub(in crate::native_bridge::context_host) use child_frames::ChildParserClassicScriptCandidate;
pub(crate) use child_frames::{NestedHistoryStore, joint_snapshot};
pub(crate) use event_callbacks::{EventCallbackId, PreparedEventCallback};
pub(crate) use host_loads::ChildFrameAttachmentSnapshot;
#[cfg(test)]
@@ -970,6 +971,8 @@ pub(crate) struct JsContextHost {
opfs_owner_state: Option<opfs_tasks::WindowOpfsOwnerState>,
pub(super) history_queue: HistoryQueueState,
pub(crate) joint_histories: super::joint_history::JointSessionHistories,
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,
@@ -98,6 +98,16 @@ impl JsContextHost {
self.top_level_navigation_history.clone()
}
pub(crate) fn restore_top_level_navigation_history(
&mut self,
source: &crate::runtime::RendererNavigationHistory,
seed: &NavigationHistoryEntrySeed,
) {
self.top_level_navigation_history = source.restore_for(seed);
self.top_level_navigation_history
.install_joint_history(&mut self.joint_histories);
}
pub(in crate::native_bridge::context_host) fn child_frame_attribute_initiator_has_same_origin(
&self,
handle: DomHandle,
@@ -746,6 +746,8 @@ impl JsContextHost {
let Some(transition) = self.take_lightweight_popup_close_transition(popup_id) else {
return false;
};
self.popup_nested_histories
.remove(&OwnerDispatchScope::LightweightPopup(popup_id));
let window = self.lightweight_popup_window(scope, popup_id);
self.unregister_service_worker_popup_client(popup_id);
self.cancel_lightweight_popup_document_loads(popup_id);
@@ -3006,6 +3008,7 @@ impl JsContextHost {
popup_id: u64,
document_handle: DomHandle,
) {
self.claimed_child_histories.remove(&document_handle);
if self
.lightweight_popup_document_handles
.get(&document_handle)
@@ -208,7 +208,9 @@ fn import_detached_document_children_from_host<'s>(
document: v8::Local<'s, v8::Object>,
parsed: &DomHost,
) -> Option<()> {
import_detached_document_children_from_host_with_reaction_policy(scope, document, parsed, false)
import_detached_document_children_from_host_with_reaction_policy(
scope, document, parsed, false, false,
)
}
fn import_detached_document_children_from_host_with_reaction_policy<'s>(
@@ -216,6 +218,7 @@ fn import_detached_document_children_from_host_with_reaction_policy<'s>(
document: v8::Local<'s, v8::Object>,
parsed: &DomHost,
append_to_current_reaction_queue: bool,
preserve_parser_frames: bool,
) -> Option<()> {
let runtime_ptr = context_host_ptr_from_global_bridge(scope)?;
let document_handle = detached_native_handle(scope, document)?;
@@ -224,9 +227,34 @@ fn import_detached_document_children_from_host_with_reaction_policy<'s>(
.collect::<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,
@@ -268,7 +296,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>(
@@ -277,6 +307,7 @@ pub(crate) fn build_detached_document_object_from_dom_host_with_content_type<'s>
parsed: DomHost,
content_type: Option<&str>,
character_set: Option<&str>,
preserve_parser_frames: bool,
) -> Option<v8::Local<'s, v8::Object>> {
let url = detached_document_url(&parsed);
let quirks_mode = parsed.dom().document()?.quirks_mode();
@@ -292,7 +323,13 @@ pub(crate) fn build_detached_document_object_from_dom_host_with_content_type<'s>
character_set,
allow_declarative_shadow_roots,
)?;
import_detached_document_children_from_host(scope, document, &parsed)?;
if preserve_parser_frames {
import_detached_document_children_from_host_with_reaction_policy(
scope, document, &parsed, false, true,
)?;
} else {
import_detached_document_children_from_host(scope, document, &parsed)?;
}
Some(document)
}
@@ -7,6 +7,10 @@ use std::collections::{BTreeSet, HashMap, VecDeque};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct SessionHistoryStep(u64);
impl SessionHistoryStep {
pub(crate) const INITIAL: Self = Self(0);
}
#[derive(Clone, Debug)]
pub(crate) struct JointHistoryEntry {
pub(crate) index: u32,
@@ -26,12 +30,103 @@ struct EntryPosition {
step: SessionHistoryStep,
}
#[derive(Debug)]
struct NavigableHistory {
#[derive(Clone, Debug)]
pub(crate) struct NavigableHistory {
entries: Vec<EntryPosition>,
current_key: String,
}
impl NavigableHistory {
pub(crate) fn new(
snapshot: JointHistorySnapshot,
first_step: SessionHistoryStep,
) -> Option<Self> {
let current_key = snapshot
.entries
.iter()
.find(|entry| entry.index == snapshot.current_index)?
.key
.clone();
Some(Self {
entries: snapshot
.entries
.into_iter()
.map(|entry| EntryPosition {
step: SessionHistoryStep(first_step.0 + u64::from(entry.navigation_index)),
entry,
})
.collect(),
current_key,
})
}
pub(crate) fn entry_at(&self, step: SessionHistoryStep) -> Option<&JointHistoryEntry> {
self.entries
.iter()
.rev()
.find(|entry| entry.step <= step)
.map(|entry| &entry.entry)
}
pub(crate) fn retain_through(&mut self, step: SessionHistoryStep) {
self.entries.retain(|entry| entry.step <= step);
}
pub(crate) fn contains(&self, key: &str) -> bool {
self.entries.iter().any(|entry| entry.entry.key == key)
}
}
/// Committed history positions survive the renderer and DOM handles that
/// happened to present them. Child entries are restored separately when their
/// parent Document recreates its navigables.
#[derive(Clone, Debug)]
pub(crate) struct JointHistoryRestoration {
root: NavigableHistory,
current_step: SessionHistoryStep,
steps: BTreeSet<SessionHistoryStep>,
}
impl JointHistoryRestoration {
pub(crate) fn current_step(&self) -> SessionHistoryStep {
self.current_step
}
pub(crate) fn for_navigation(
self,
snapshot: JointHistorySnapshot,
navigation_type: Option<&str>,
target_step: Option<SessionHistoryStep>,
) -> Option<Self> {
let root = OwnerDispatchScope::Top;
let mut histories = JointSessionHistories::default();
histories.restore_root(root, self);
let joint = histories.get_mut(root)?;
match navigation_type {
Some("push") => {
joint.push(root, snapshot);
}
Some("traverse") => {
let key = &snapshot
.entries
.iter()
.find(|entry| entry.index == snapshot.current_index)?
.key;
let target = joint.plan_entry(root, key)?;
let entry_step = joint
.navigables
.get(&root)?
.entries
.iter()
.find(|entry| &entry.entry.key == key)?
.step;
joint.update_current(root, snapshot, Some(entry_step));
joint.current_step = target_step.unwrap_or(target.step);
}
_ => joint.replace(root, snapshot),
}
joint.restoration(root)
}
}
#[derive(Clone, Debug)]
pub(crate) struct JointHistoryTarget {
pub(crate) owner: OwnerDispatchScope,
@@ -85,6 +180,24 @@ pub(crate) struct JointSessionHistories {
}
impl JointSessionHistories {
pub(crate) fn restore_root(
&mut self,
owner: OwnerDispatchScope,
restoration: JointHistoryRestoration,
) {
self.histories.insert(
owner,
JointSessionHistory {
current_step: restoration.current_step,
revision: 0,
navigables: IndexMap::from([(owner, restoration.root)]),
retired_steps: restoration.steps,
pending_traversal: None,
deferred_mutations: VecDeque::new(),
completed_traversal: None,
},
);
}
pub(crate) fn root_for_owner(&self, owner: OwnerDispatchScope) -> Option<OwnerDispatchScope> {
self.histories
.iter()
@@ -127,6 +240,33 @@ impl JointSessionHistories {
}
impl JointSessionHistory {
pub(crate) fn restoration(&self, root: OwnerDispatchScope) -> Option<JointHistoryRestoration> {
Some(JointHistoryRestoration {
root: self.navigables.get(&root)?.clone(),
current_step: self.current_step,
steps: self.used_steps().into_iter().collect(),
})
}
pub(crate) fn navigable_history(&self, owner: OwnerDispatchScope) -> Option<NavigableHistory> {
self.navigables.get(&owner).cloned()
}
pub(crate) fn restore_child(
&mut self,
owner: OwnerDispatchScope,
mut history: NavigableHistory,
current_key: &str,
) {
history.current_key = current_key.to_owned();
self.navigables.insert(owner, history);
}
pub(crate) fn destination_step(&self) -> SessionHistoryStep {
self.pending_traversal
.as_ref()
.map_or(self.current_step, |pending| pending.step)
}
pub(crate) fn reset(&mut self, snapshots: Vec<(OwnerDispatchScope, JointHistorySnapshot)>) {
self.navigables.clear();
self.retired_steps.clear();
@@ -575,6 +715,63 @@ mod tests {
}
}
#[test]
fn restored_root_and_children_preserve_interleaved_session_history_steps() {
let root = OwnerDispatchScope::Top;
let old_a = OwnerDispatchScope::Child(DomHandle::new(1));
let old_b = OwnerDispatchScope::Child(DomHandle::new(2));
let mut histories = JointSessionHistories::default();
let original = histories
.ensure_root(root, snapshot(&["parent"], 0))
.unwrap();
original.ensure_child(old_a, snapshot(&["a0"], 0));
original.ensure_child(old_b, snapshot(&["b0"], 0));
original.push(old_a, snapshot(&["a0", "a1"], 1));
original.push(old_b, snapshot(&["b0", "b1"], 1));
original.push(old_a, snapshot(&["a0", "a1", "a2"], 2));
let a = original.navigable_history(old_a).unwrap();
let b = original.navigable_history(old_b).unwrap();
let source = original.restoration(root).unwrap();
let away = source
.for_navigation(snapshot(&["parent", "away"], 1), Some("push"), None)
.unwrap();
let back = away
.for_navigation(
snapshot(&["parent", "away"], 0),
Some("traverse"),
Some(SessionHistoryStep(3)),
)
.unwrap();
drop(histories);
let mut histories = JointSessionHistories::default();
histories.restore_root(root, back);
let restored = histories.get_mut(root).unwrap();
let new_a = OwnerDispatchScope::Child(DomHandle::new(101));
let new_b = OwnerDispatchScope::Child(DomHandle::new(102));
restored.restore_child(new_a, a.clone(), "a2");
restored.restore_child(new_b, b.clone(), "b1");
assert_eq!(restored.length(), 5);
for (owner, key) in [(new_a, "a1"), (new_b, "b0"), (new_a, "a0")] {
let plan = restored.plan_delta(restored.current_step(), -1).unwrap();
assert_eq!(plan.targets.len(), 1);
assert_eq!(plan.targets[0].owner, owner);
assert_eq!(plan.targets[0].key, key);
restored.begin_traversal(plan);
restored.commit_traversal_entry(owner, key);
}
assert_eq!(restored.current_step(), SessionHistoryStep(0));
assert_eq!(
restored
.plan_delta(restored.current_step(), 4)
.unwrap()
.step,
SessionHistoryStep(4)
);
assert_eq!(a.entry_at(SessionHistoryStep(1)).unwrap().key, "a1");
assert_eq!(b.entry_at(SessionHistoryStep(1)).unwrap().key, "b0");
}
#[test]
fn interleaved_children_share_steps_and_prune_each_others_forward_entries() {
let root = OwnerDispatchScope::Top;
@@ -8,7 +8,12 @@ use moli_page_types::{
use parking_lot::Mutex;
use url::Url;
use crate::native_bridge::{NavigationHistoryEntrySeed, NavigationHistorySerializedEntry};
use crate::native_bridge::{
NavigationHistoryDocumentId, NavigationHistoryEntrySeed, NavigationHistorySerializedEntry,
NestedHistoryStore, OwnerDispatchScope,
joint_history::{JointHistoryRestoration, SessionHistoryStep},
joint_snapshot,
};
/// The committed history of a renderer's top-level Document. Publishing a
/// snapshot happens at a history mutation, never while capturing Page state.
@@ -17,6 +22,9 @@ use crate::native_bridge::{NavigationHistoryEntrySeed, NavigationHistorySerializ
#[derive(Clone, Debug, Default)]
pub struct RendererNavigationHistory {
snapshot: Arc<Mutex<Option<Arc<NavigationHistoryEntrySeed>>>>,
joint: Arc<Mutex<Option<JointHistoryRestoration>>>,
nested: NestedHistoryStore,
selected_step: Arc<Mutex<Option<(String, SessionHistoryStep)>>>,
}
impl PartialEq for RendererNavigationHistory {
@@ -28,6 +36,73 @@ impl PartialEq for RendererNavigationHistory {
impl Eq for RendererNavigationHistory {}
impl RendererNavigationHistory {
pub(crate) fn nested_history(&self) -> NestedHistoryStore {
self.nested.clone()
}
pub(crate) fn current_document_id(&self) -> Option<NavigationHistoryDocumentId> {
Some(
current_entry(self.snapshot()?.as_ref())?
.document_id
.clone(),
)
}
pub(crate) fn publish_joint_history(&self, joint: Option<JointHistoryRestoration>) {
*self.joint.lock() = joint;
}
pub(crate) fn select_joint_traversal(&self, key: String, step: SessionHistoryStep) {
*self.selected_step.lock() = Some((key, step));
}
pub(crate) fn restore_for(&self, seed: &NavigationHistoryEntrySeed) -> Self {
if seed
.activation
.as_ref()
.is_some_and(|activation| activation.navigation_type.as_deref() == Some("push"))
&& let Some(joint) = self.joint.lock().as_ref()
{
self.nested.prune(
joint.current_step(),
&seed
.entries
.iter()
.map(|entry| entry.document_id.clone())
.collect::<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(|joint| {
joint.for_navigation(
joint_snapshot(seed),
seed.activation
.as_ref()
.and_then(|activation| activation.navigation_type.as_deref()),
target_step,
)
});
Self {
snapshot: Arc::new(Mutex::new(Some(Arc::new(seed.clone())))),
joint: Arc::new(Mutex::new(joint)),
nested: self.nested.clone(),
selected_step: Arc::default(),
}
}
pub(crate) fn install_joint_history(
&self,
histories: &mut crate::native_bridge::joint_history::JointSessionHistories,
) {
if let Some(joint) = self.joint.lock().clone() {
histories.restore_root(OwnerDispatchScope::Top, joint);
}
}
pub(crate) fn publish(&self, seed: NavigationHistoryEntrySeed) {
*self.snapshot.lock() = Some(Arc::new(seed));
}
@@ -95,6 +170,7 @@ impl RendererNavigationHistory {
requested: Arc::new(seed),
source_index,
initial_empty_source: false,
selected_step: self.selected_step.lock().clone(),
}
}
}
@@ -109,6 +185,7 @@ pub struct RendererNavigationHistoryRequest {
requested: Arc<NavigationHistoryEntrySeed>,
source_index: Option<u32>,
initial_empty_source: bool,
selected_step: Option<(String, SessionHistoryStep)>,
}
impl PartialEq for RendererNavigationHistoryRequest {
@@ -122,6 +199,11 @@ impl PartialEq for RendererNavigationHistoryRequest {
impl Eq for RendererNavigationHistoryRequest {}
impl RendererNavigationHistoryRequest {
pub(crate) fn source_history(&self) -> RendererNavigationHistory {
let mut source = self.source.clone();
source.selected_step = Arc::new(Mutex::new(self.selected_step.clone()));
source
}
/// The browser owns the initial-empty Document lifecycle. Its first
/// navigation has no activation source. Same-document URL updates do not
/// change that lifecycle state.
+4
View File
@@ -6915,6 +6915,9 @@ impl RendererOwnerHandle {
.as_ref()
.map(|request| request.resolve(&final_url))
.transpose()?,
navigation_history_source: navigation_history
.as_ref()
.map(|request| request.source_history()),
reserved_service_worker_client_id: reserved_service_worker_client
.map(RendererReservedServiceWorkerClient::release),
};
@@ -7215,6 +7218,7 @@ impl RendererOwnerHandle {
main_document_commit,
top_level_storage_key: None,
navigation_bootstrap_entry: navigation_history.as_ref().map(|request| request.resolve(&final_url)).transpose()?,
navigation_history_source: navigation_history.as_ref().map(|request| request.source_history()),
reserved_service_worker_client_id: reserved_service_worker_client
.map(RendererReservedServiceWorkerClient::release),
};
@@ -1211,6 +1211,7 @@ impl PageVm {
root_frame_id: self.vm().root_frame_id().map(str::to_owned),
top_level_storage_key: None,
navigation_bootstrap_entry: None,
navigation_history_source: Some(self.vm().top_level_navigation_history()),
reserved_service_worker_client_id: None,
}
}
+5 -3
View File
@@ -1099,6 +1099,7 @@ pub(crate) struct PageVmEnvConfig {
pub(crate) layout_policy: LayoutPolicy,
pub(crate) wpt_extensions_enabled: bool,
pub(crate) navigation_bootstrap_entry: Option<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>,
}
@@ -4466,9 +4467,10 @@ impl PageVm {
env.fetch_subresource_interception_enabled,
env.fetch_subresource_interception_resource_type,
);
page_vm
.vm_mut()
.install_navigation_bootstrap_entry(env.navigation_bootstrap_entry.clone());
page_vm.vm_mut().install_navigation_bootstrap_from_history(
env.navigation_bootstrap_entry.clone(),
env.navigation_history_source.clone(),
);
Ok(page_vm)
}
@@ -699,6 +699,7 @@ fn minimal_test_page_vm_env_config() -> PageVmEnvConfig {
layout_policy: crate::real_layout_test_policy(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
}
}
@@ -1828,6 +1828,7 @@ fn test_page_vm_with_loader_dom_host_hooks_and_response_referrer_policy(
layout_policy: crate::real_layout_test_policy(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
runtime_hooks,
@@ -3231,6 +3232,7 @@ fn default_runtime_hooks_reject_direct_no_owner_page_vm_construction() {
layout_policy: crate::real_layout_test_policy(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::default(),
@@ -2582,6 +2582,7 @@ document.body.setAttribute('data-error-state', [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
}
}
@@ -6510,6 +6511,7 @@ globalThis.__outerDocumentWriteScriptContinued = true;
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -15082,6 +15084,7 @@ document.body.setAttribute('data-result', [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -15262,6 +15265,7 @@ document.body.setAttribute('data-result', [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -15491,6 +15495,7 @@ document.body.setAttribute('data-result', [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -17490,6 +17495,7 @@ document.body.setAttribute("data-range", [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -17618,6 +17624,7 @@ document.body.setAttribute("data-range", [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -17756,6 +17763,7 @@ document.body.setAttribute("data-range", [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -17929,6 +17937,7 @@ document.body.setAttribute("data-range", [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -18252,6 +18261,7 @@ document.body.setAttribute("data-range", [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -18422,6 +18432,7 @@ document.body.setAttribute("data-range", [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -18524,6 +18535,7 @@ document.body.setAttribute("data-range", [
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
},
PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(),
@@ -960,6 +960,7 @@ mod tests {
layout_policy: moli_page_types::LayoutPolicy::default(),
wpt_extensions_enabled: false,
navigation_bootstrap_entry: None,
navigation_history_source: None,
reserved_service_worker_client_id: None,
}
}
+14 -1
View File
@@ -6004,14 +6004,27 @@ impl ScriptVm {
})
}
#[cfg(test)]
pub(super) fn install_navigation_bootstrap_entry(
&mut self,
entry_seed: Option<super::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);
}
super::context_bootstrap::install_navigation_bootstrap_entry(scope, &entry_seed);
Ok(())
});