fix(history): replace iframe entries until initial loading completes

This commit is contained in:
ldm0
2026-09-30 03:34:42 +08:00
parent d92d4da858
commit 799236fba0
7 changed files with 269 additions and 6 deletions
+5 -5
View File
@@ -915,7 +915,7 @@ async fn child_browsing_context_attribute_navigation_preserves_local_history() -
page.serialize_html_async()
.await
.unwrap()
.contains("data-child-history-length=\"3\""),
.contains("data-child-history-length=\"2\""),
"{}",
page.serialize_html_async().await.unwrap()
);
@@ -925,7 +925,7 @@ async fn child_browsing_context_attribute_navigation_preserves_local_history() -
.unwrap()
.contains(&format!(
"data-child-location-after-back=\"{}\"",
server.url("/compat/window-child-browsing-context-target-name-b")
server.url("/compat/window-child-browsing-context-target-name-a")
)),
"{}",
page.serialize_html_async().await.unwrap()
@@ -936,7 +936,7 @@ async fn child_browsing_context_attribute_navigation_preserves_local_history() -
.unwrap()
.contains(&format!(
"data-child-document-location-after-back=\"{}\"",
server.url("/compat/window-child-browsing-context-target-name-b")
server.url("/compat/window-child-browsing-context-target-name-a")
)),
"{}",
page.serialize_html_async().await.unwrap()
@@ -947,7 +947,7 @@ async fn child_browsing_context_attribute_navigation_preserves_local_history() -
.unwrap()
.contains(&format!(
"data-child-current-entry-after-back=\"{}\"",
server.url("/compat/window-child-browsing-context-target-name-b")
server.url("/compat/window-child-browsing-context-target-name-a")
)),
"{}",
page.serialize_html_async().await.unwrap()
@@ -3974,7 +3974,7 @@ async fn child_browsing_context_fragment_navigation_persists_through_attribute_n
page.serialize_html_async()
.await
.unwrap()
.contains("data-child-history-length=\"3\""),
.contains("data-child-history-length=\"2\""),
"{}",
page.serialize_html_async().await.unwrap()
);
+103
View File
@@ -0,0 +1,103 @@
use anyhow::Result;
use moli_core::runtime::{Browser, BrowserConfig};
use moli_test_support::FixtureServer;
use serde_json::{Value, json};
use tokio::time::Duration;
use url::Url;
async fn iframe_attribute_history(phase: &str, api: &str) -> Result<Value> {
let child = r#"<!doctype html><body><script>
const phase = new URL(location.href).searchParams.get('phase');
const go = () => parent.navigateFrame();
if (phase === 'parser') go();
else if (phase === 'DOMContentLoaded') document.addEventListener(phase, go, {once: true});
else if (phase === 'load' || phase === 'pageshow') addEventListener(phase, go, {once: true});
else if (phase !== 'owner-load') addEventListener('load', () => setTimeout(() => {
if (phase === 'reopen') document.open();
go();
}, 0), {once: true});
</script>"#;
let child = serde_json::to_string(child)?.replace("</script>", "<\\/script>");
let markup = r#"<!doctype html><body><script>
window.finished = new Promise(resolve => window.finish = resolve);
const phase = __PHASE__;
const api = __API__;
const initialLength = history.length;
const frame = document.createElement('iframe');
const destination = new URL('/compat/child-dynamic-markup-document?label=destination&markup=' +
encodeURIComponent('<!doctype html><body>destination'), location.href).href;
window.navigateFrame = () => {
if (api === 'property') frame.src = destination;
else frame.setAttribute('src', destination);
};
frame.onload = () => {
if (frame.contentWindow.location.href === destination) setTimeout(() => {
const win = frame.contentWindow;
finish({delta: history.length - initialLength,
entries: win.navigation.entries().map(entry => new URL(entry.url).searchParams.get('label')),
index: win.navigation.currentEntry.index, activation: win.navigation.activation.navigationType});
}, 0);
else if (phase === 'owner-load') navigateFrame();
};
frame.src = '/compat/child-dynamic-markup-document?label=source&phase=' + phase +
'&markup=' + encodeURIComponent(__CHILD__);
document.body.append(frame);
</script>"#
.replace("__PHASE__", &serde_json::to_string(phase)?)
.replace("__API__", &serde_json::to_string(api)?)
.replace("__CHILD__", &child);
let server = FixtureServer::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let mut url = Url::parse(&server.url("/compat/child-dynamic-markup-document"))?;
url.query_pairs_mut().append_pair("markup", &markup);
let result = tokio::time::timeout(Duration::from_secs(10), async {
let mut page = browser.fetch(url.as_str()).await?;
page.evaluate_runtime_expression_with_await_async(
"finished.then(value => JSON.stringify(value))",
true,
)
.await
})
.await??;
let result = serde_json::from_str(result["value"].as_str().unwrap())?;
server.shutdown().await;
Ok(result)
}
#[tokio::test(flavor = "multi_thread")]
async fn iframe_src_changes_replace_history_until_child_load_finishes() -> Result<()> {
for api in ["property", "setAttribute"] {
for phase in [
"parser",
"DOMContentLoaded",
"load",
"pageshow",
"owner-load",
] {
let result = iframe_attribute_history(phase, api).await?;
assert_eq!(
result,
json!({"delta": 0, "entries": ["destination"],
"index": 0, "activation": "replace"}),
"{phase}/{api}"
);
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn iframe_src_changes_push_history_after_load_even_when_document_is_reopened() -> Result<()> {
for api in ["property", "setAttribute"] {
for phase in ["after-load", "reopen"] {
let result = iframe_attribute_history(phase, api).await?;
assert_eq!(
result,
json!({"delta": 1, "entries": ["source", "destination"],
"index": 1, "activation": "push"}),
"{phase}/{api}"
);
}
}
Ok(())
}
@@ -515,6 +515,9 @@ pub(crate) struct DocumentLifecycleRecord {
domcontentloaded_transition_token: Option<DocumentLoadDelayTokenId>,
complete_transition_token: Option<DocumentLoadDelayTokenId>,
readiness: Option<DocumentReadinessState>,
// Unlike readiness and load-event progress, this is retained by
// document.open(). It becomes true only after the loading algorithm ends.
completely_loaded: bool,
load: DocumentLoadEventProgress,
child_load_delivery_admission: Option<FrameDocumentLoadDeliveryAdmissionId>,
unload: DocumentUnloadEventProgress,
@@ -535,6 +538,7 @@ impl DocumentLifecycleRecord {
domcontentloaded_transition_token: Some(domcontentloaded_transition_token),
complete_transition_token: None,
readiness: Some(DocumentReadinessState::Loading),
completely_loaded: false,
load: DocumentLoadEventProgress::Pending,
child_load_delivery_admission: None,
unload: DocumentUnloadEventProgress::Pending,
@@ -546,12 +550,14 @@ impl DocumentLifecycleRecord {
parsing_delay_token: DocumentLoadDelayTokenId,
domcontentloaded_transition_token: DocumentLoadDelayTokenId,
continuation: Option<DocumentOpenLoadContinuation>,
completely_loaded: bool,
) -> Self {
let mut lifecycle = Self::loading(
load_delivery_kind,
parsing_delay_token,
domcontentloaded_transition_token,
);
lifecycle.completely_loaded = completely_loaded;
lifecycle.load = match (load_delivery_kind, continuation) {
(_, None) => DocumentLoadEventProgress::Pending,
(DocumentLoadDeliveryKind::Main, Some(DocumentOpenLoadContinuation::MainLoad)) => {
@@ -592,6 +598,10 @@ impl DocumentLifecycleRecord {
}
}
pub(super) fn is_completely_loaded(&self) -> bool {
self.completely_loaded
}
pub(super) fn can_finish_parsing(&self) -> bool {
self.parsing_delay_token.is_some()
&& self.readiness == Some(DocumentReadinessState::Loading)
@@ -746,6 +756,12 @@ impl DocumentLifecycleRecord {
self.complete_transition_token = None;
self.readiness = Some(DocumentReadinessState::Complete);
self.load = next_load;
if next_load == DocumentLoadEventProgress::Dispatched {
// document.open() inside a main load callback inherits that
// delivery instead of dispatching load again. Completing the
// replacement parser must also settle its loaded status.
self.completely_loaded = true;
}
true
}
@@ -768,6 +784,7 @@ impl DocumentLifecycleRecord {
self.complete_transition_token = None;
self.readiness = Some(DocumentReadinessState::Complete);
self.load = DocumentLoadEventProgress::Suppressed;
self.completely_loaded = true;
self.child_load_delivery_admission = None;
Some(ready_state_changed)
@@ -796,6 +813,7 @@ impl DocumentLifecycleRecord {
self.complete_transition_token = None;
self.readiness = Some(DocumentReadinessState::Complete);
self.load = DocumentLoadEventProgress::Ready;
self.completely_loaded = true;
true
}
@@ -872,6 +890,10 @@ impl DocumentLifecycleRecord {
if self.load != DocumentLoadEventProgress::DispatchingMainLoad {
return None;
}
// Completing this Document's load/pageshow marks it completely loaded.
// A child navigation started by those callbacks can keep the parent's
// aggregate load completion pending without extending its initial load.
self.completely_loaded = true;
if self.has_incomplete_child_frames() {
self.load = DocumentLoadEventProgress::MainWindowLoadDispatched;
return Some(MainDocumentLoadCompletionState::WaitingForDescendants);
@@ -890,6 +912,7 @@ impl DocumentLifecycleRecord {
return Some(MainDocumentLoadCompletionState::WaitingForDescendants);
}
self.load = DocumentLoadEventProgress::Dispatched;
self.completely_loaded = true;
Some(MainDocumentLoadCompletionState::Completed)
}
@@ -986,6 +1009,9 @@ impl DocumentLifecycleRecord {
}
};
self.load = next;
if finished {
self.completely_loaded = true;
}
Some(finished)
}
@@ -63,6 +63,7 @@ impl FrameOwnerStore {
&mut self,
load_delivery_kind: DocumentLoadDeliveryKind,
continuation: Option<super::records::DocumentOpenLoadContinuation>,
completely_loaded: bool,
) -> DocumentLifecycleRecord {
let parsing_delay_token = self.ids.document_load_delay_token();
let domcontentloaded_transition_token = self.ids.document_load_delay_token();
@@ -71,6 +72,7 @@ impl FrameOwnerStore {
parsing_delay_token,
domcontentloaded_transition_token,
continuation,
completely_loaded,
)
}
@@ -312,6 +314,7 @@ impl FrameOwnerStore {
let load_continuation = retired_document
.lifecycle_progress
.document_open_load_continuation();
let completely_loaded = retired_document.lifecycle_progress.is_completely_loaded();
retired_document.lifecycle = DocumentLifecycleState::Replaced;
retired_document.lifecycle_progress.retire();
retired_document.active_requests.clear();
@@ -320,6 +323,7 @@ impl FrameOwnerStore {
let lifecycle_progress = self.new_loading_document_lifecycle_for_document_open(
DocumentLoadDeliveryKind::Main,
load_continuation,
completely_loaded,
);
self.documents.insert(
document_id,
@@ -885,6 +889,7 @@ impl FrameOwnerStore {
let load_continuation = retired_document
.lifecycle_progress
.document_open_load_continuation();
let completely_loaded = retired_document.lifecycle_progress.is_completely_loaded();
retired_document.lifecycle = DocumentLifecycleState::Replaced;
retired_document.lifecycle_progress.retire();
retired_document.active_requests.clear();
@@ -893,6 +898,7 @@ impl FrameOwnerStore {
let lifecycle_progress = self.new_loading_document_lifecycle_for_document_open(
DocumentLoadDeliveryKind::Child,
load_continuation,
completely_loaded,
);
self.documents.insert(
document_id,
@@ -3356,6 +3362,18 @@ impl FrameOwnerStore {
}
}
pub(crate) fn current_document_is_completely_loaded(
&self,
owner: FrameDocumentOwner,
) -> Option<bool> {
if !self.frame_document_owner_is_current(owner) {
return None;
}
self.documents
.get(&owner.document_id)
.map(|document| document.lifecycle_progress.is_completely_loaded())
}
pub(crate) fn frame_document_owner_is_current(&self, owner: FrameDocumentOwner) -> bool {
let Some(document) = self.documents.get(&owner.document_id) else {
return false;
@@ -465,11 +465,35 @@ fn main_document_lifecycle_actions_are_owned_and_replacement_stale_drops_them()
.prepare_current_main_document_complete_transition(third_owner)
.expect("third replacement should prepare complete");
assert!(store.apply_current_main_document_complete_transition(third_complete));
assert_eq!(
store.current_document_is_completely_loaded(third_owner.document_owner()),
Some(false)
);
assert!(store.begin_current_main_document_load_dispatch(third_owner));
assert_eq!(
store.current_document_is_completely_loaded(third_owner.document_owner()),
Some(false)
);
assert_eq!(
store.finish_current_main_document_load_dispatch(third_owner),
Some(MainDocumentLoadCompletionState::Completed)
);
assert_eq!(
store.current_document_is_completely_loaded(third_owner.document_owner()),
Some(true)
);
let reopened = store
.replace_main_document(
handle(1),
url("https://example.test/reopened"),
url("https://example.test/reopened"),
)
.expect("completed main document can reopen");
assert_eq!(
store.current_document_is_completely_loaded(reopened.current_owner().document_owner()),
Some(true),
"a reopened main document must retain completely-loaded status"
);
}
#[test]
@@ -3025,6 +3049,11 @@ fn child_document_open_during_owner_load_resumes_with_pageshow() {
let replacement_owner = replacement
.current_owner()
.expect("document.open should install a replacement owner");
assert_eq!(
store.current_document_is_completely_loaded(replacement_owner.document_owner()),
Some(false),
"document.open during load must preserve the not-yet-completely-loaded state"
);
assert!(
store
.finish_current_child_document_load_delivery(owner_element_load)
@@ -3069,6 +3098,65 @@ fn child_document_open_during_owner_load_resumes_with_pageshow() {
));
}
#[test]
fn child_completely_loaded_waits_for_delivery_and_survives_document_open() {
let mut store = FrameOwnerStore::default();
let child_handle = handle(500);
let document_handle = handle(501);
let owner = commit_test_child_document(
&mut store,
child_handle,
document_handle,
"completely-loaded",
Some("main"),
);
let task = prepare_test_child_load_delivery(&mut store, child_handle, owner);
for phase in [
FrameDocumentLoadDeliveryPhase::WindowLoad,
FrameDocumentLoadDeliveryPhase::OwnerElementLoad,
FrameDocumentLoadDeliveryPhase::PageShow,
FrameDocumentLoadDeliveryPhase::FrameFinish,
] {
let action = store
.begin_current_child_document_load_delivery(task)
.unwrap();
assert_eq!(action.phase(), phase);
assert_eq!(
store.current_document_is_completely_loaded(owner.document_owner()),
Some(false),
"complete readiness must not stand in for complete load delivery"
);
assert!(
store
.finish_current_child_document_load_delivery(action)
.is_some()
);
}
assert_eq!(
store.current_document_is_completely_loaded(owner.document_owner()),
Some(true)
);
let plan = store
.plan_child_document_open_replacement(
child_handle,
document_handle,
url("https://completely-loaded.test/reopened"),
url("https://completely-loaded.test/reopened"),
)
.unwrap();
let replacement = store.commit_child_document_open_replacement(plan);
let replacement_owner = replacement.current_owner().unwrap();
assert_eq!(
store.current_document_is_completely_loaded(owner.document_owner()),
None
);
assert_eq!(
store.current_document_is_completely_loaded(replacement_owner.document_owner()),
Some(true),
"document.open changes readiness but retains completely-loaded status"
);
}
#[test]
fn child_document_open_during_pageshow_resumes_after_pageshow() {
let mut store = FrameOwnerStore::default();
@@ -3779,6 +3867,11 @@ fn child_explicit_open_can_reacquire_parent_while_main_load_is_dispatching() {
store.finish_current_main_document_load_dispatch(main_owner),
Some(MainDocumentLoadCompletionState::WaitingForDescendants)
);
assert_eq!(
store.current_document_is_completely_loaded(main_owner.document_owner()),
Some(true),
"descendant work started during load must not extend the parent's initial load"
);
assert!(
store
.begin_child_frame_parent_document_load(child_handle)
@@ -111,6 +111,22 @@ impl JsContextHost {
);
}
pub(crate) fn document_is_completely_loaded(
&self,
document_handle: crate::document_runtime::DomHandle,
) -> Option<bool> {
let owner = if document_handle == self.document_handle() {
self.current_main_document_task_owner()?.document_owner()
} else {
let child_handle =
self.child_browsing_context_host_for_document_handle(document_handle)?;
self.frame_owner_store
.current_child_document_owner(child_handle)?
};
self.frame_owner_store
.current_document_is_completely_loaded(owner)
}
/// Replace the dynamically scoped Runtime command cause and return the
/// previous scope for exact restoration after V8 dispatch.
///
@@ -38,7 +38,14 @@ pub(in crate::native_bridge) fn update_iframe_snapshot_navigation(
handle,
previous_seed_snapshot,
);
let replace_current = runtime.child_current_document_is_initial_empty(handle);
// Attribute navigation replaces an incompletely loaded Document,
// including inside its Window load/pageshow callbacks. readyState
// alone cannot distinguish those callbacks from a completed load.
let replace_current = runtime.child_current_document_is_initial_empty(handle)
|| runtime
.child_browsing_context_document_handle(handle)
.and_then(|document| runtime.document_is_completely_loaded(document))
== Some(false);
if runtime.queue_child_browsing_context_navigation_from_existing_seed(
handle,
&navigation_target,