diff --git a/moli-core/tests/history_child.rs b/moli-core/tests/history_child.rs index db0fd964fb..96bc507058 100644 --- a/moli-core/tests/history_child.rs +++ b/moli-core/tests/history_child.rs @@ -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() ); diff --git a/moli-core/tests/history_iframe.rs b/moli-core/tests/history_iframe.rs new file mode 100644 index 0000000000..4917c2bf68 --- /dev/null +++ b/moli-core/tests/history_iframe.rs @@ -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 { + let child = r#""#; + let child = serde_json::to_string(child)?.replace("", "<\\/script>"); + let markup = r#""# + .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(()) +} diff --git a/moli-renderer-v8/src/frame_owner_model/records.rs b/moli-renderer-v8/src/frame_owner_model/records.rs index 409a4bcfb2..c1eb7d074a 100644 --- a/moli-renderer-v8/src/frame_owner_model/records.rs +++ b/moli-renderer-v8/src/frame_owner_model/records.rs @@ -515,6 +515,9 @@ pub(crate) struct DocumentLifecycleRecord { domcontentloaded_transition_token: Option, complete_transition_token: Option, readiness: Option, + // 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, 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, + 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) } diff --git a/moli-renderer-v8/src/frame_owner_model/store.rs b/moli-renderer-v8/src/frame_owner_model/store.rs index a5f5d2144f..844d477a7b 100644 --- a/moli-renderer-v8/src/frame_owner_model/store.rs +++ b/moli-renderer-v8/src/frame_owner_model/store.rs @@ -63,6 +63,7 @@ impl FrameOwnerStore { &mut self, load_delivery_kind: DocumentLoadDeliveryKind, continuation: Option, + 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 { + 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; diff --git a/moli-renderer-v8/src/frame_owner_model/store_tests.rs b/moli-renderer-v8/src/frame_owner_model/store_tests.rs index 029f75c705..903109bf3a 100644 --- a/moli-renderer-v8/src/frame_owner_model/store_tests.rs +++ b/moli-renderer-v8/src/frame_owner_model/store_tests.rs @@ -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) diff --git a/moli-renderer-v8/src/native_bridge/context_host/navigation.rs b/moli-renderer-v8/src/native_bridge/context_host/navigation.rs index 67b2d6893e..04c637e0de 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/navigation.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/navigation.rs @@ -111,6 +111,22 @@ impl JsContextHost { ); } + pub(crate) fn document_is_completely_loaded( + &self, + document_handle: crate::document_runtime::DomHandle, + ) -> Option { + 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. /// diff --git a/moli-renderer-v8/src/native_bridge/element/url_attributes/iframe.rs b/moli-renderer-v8/src/native_bridge/element/url_attributes/iframe.rs index bde955ffa6..db36c58e20 100644 --- a/moli-renderer-v8/src/native_bridge/element/url_attributes/iframe.rs +++ b/moli-renderer-v8/src/native_bridge/element/url_attributes/iframe.rs @@ -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,