mirror of
https://github.com/lexmount/moli.git
synced 2026-10-05 16:00:54 +00:00
fix(history): replace iframe entries until initial loading completes
This commit is contained in:
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user