From fd483cc8f4bc6acdf84ff7febc15bcfcf48eaf99 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 10 Sep 2026 10:28:46 +0800 Subject: [PATCH] fix: preserve child history traversal lifecycle Run cross-document child traversal through the shared unload tree after its root navigation checks, retaining visibility transitions and one unload lifecycle per retiring document and descendant. Apply native Window lifecycle events' legacy Document target override explicitly, and reset trust for script-dispatched events. Add Browser coverage for native versus synthetic dispatch, nested history/navigation roundtrips, and same-document traversal. Validation: cargo fmt --all; workspace Clippy with all targets/features and -D warnings; nextest (17913 passed, 13 skipped). 213 WPT cases gained one passing case and two passing subtests with no regressions. Seven matching Chrome fixture flows passed. --- .../wpt-cross-current/failed-cases.txt | 1 - .../wpt-cross-current/passed-cases.txt | 1 + moli-core/tests/history_visibility.rs | 233 ++++++++++++++++++ .../context_bootstrap/navigation_events.rs | 3 +- .../navigation_traversal_execution.rs | 30 ++- .../context_host/child_documents/lifecycle.rs | 28 ++- .../context_host/child_events.rs | 17 +- .../native_bridge/context_host/host_loads.rs | 10 +- moli-renderer-v8/src/window_host.rs | 2 + 9 files changed, 298 insertions(+), 27 deletions(-) create mode 100644 moli-core/tests/history_visibility.rs diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 22b4effbb6..350c55627f 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -3023,7 +3023,6 @@ old-tests/submission/Microsoft/selection/insertNodeIntoSelection.htm old-tests/submission/Microsoft/selection/select.htm old-tests/submission/Microsoft/selection/selectionStartEnd.htm old-tests/submission/Microsoft/selection/setSelectionRange.htm -page-visibility/iframe-session-history.html permissions-policy/experimental-features/focus-without-user-activation-disabled.html permissions-policy/experimental-features/focus-without-user-activation-focused-frame-descendant.html permissions-policy/experimental-features/focus-without-user-activation-setter-policy.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 1f42584e26..d856c0bcd5 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -8206,6 +8206,7 @@ old-tests/submission/Microsoft/selection/removeRange.htm old-tests/submission/Microsoft/selection/selectAllChildren.htm orientation-sensor/OrientationSensor_insecure_context.html page-lifecycle/idlharness.html +page-visibility/iframe-session-history.html page-visibility/test_attributes_exist.html page-visibility/test_child_document.html page-visibility/test_default_view.html diff --git a/moli-core/tests/history_visibility.rs b/moli-core/tests/history_visibility.rs new file mode 100644 index 0000000000..5f180f0a17 --- /dev/null +++ b/moli-core/tests/history_visibility.rs @@ -0,0 +1,233 @@ +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 history_visibility(flow: &str, api: &str, depth: usize) -> Result { + let child = r#"

child

"#; + let child = serde_json::to_string(child)?.replace("", "<\\/script>"); + let markup = r#""# + .replace("__CHILD__", &child) + .replace("__FLOW__", &serde_json::to_string(flow)?) + .replace("__API__", &serde_json::to_string(api)?) + .replace("__DEPTH__", &depth.to_string()); + 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 native_window_event_targets_are_distinct_from_script_dispatch() -> Result<()> { + let result = history_visibility("synthetic", "history", 0).await?; + let native = result["native"].as_array().unwrap(); + assert_eq!(native.len(), 2, "{result}"); + for (event, kind) in native.iter().zip(["load", "pageshow"]) { + assert_eq!(event["type"], kind); + assert_eq!(event["target"], "document", "{result}"); + assert_eq!(event["currentWindow"], true); + assert_eq!(event["trusted"], true); + } + let synthetic = result["synthetic"].as_array().unwrap(); + assert_eq!(synthetic.len(), 5); + for event in synthetic { + assert_eq!(event["target"], "window", "{result}"); + assert_eq!(event["currentWindow"], true); + assert_eq!(event["trusted"], false, "{result}"); + } + assert_eq!(result["documentCalls"], 0); + assert_eq!(result["documentWindowEventCalls"], 0); + assert_eq!(result["wasTrusted"], true); + assert_eq!(result["completedBeforeRedispatch"], true); + assert_eq!(result["currentTargetCleared"], true); + assert_eq!(result["phase"], 0); + assert_eq!(result["hidden"], false); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn history_traversal_updates_visibility_and_unloads_descendants_once() -> Result<()> { + for api in ["history", "navigation"] { + for depth in [0, 2] { + let result = history_visibility("roundtrip", api, depth).await?; + assert_eq!(result["unrelatedUnchanged"], true); + for (phase, old, new, descendant_count) in + [("back", "B", "A", depth), ("forward", "A", "B", 0)] + { + let phase = &result[phase]; + assert_eq!(phase["oldHidden"], true, "{api}/{depth}: {result}"); + assert_eq!(phase["newHidden"], false); + assert_eq!(phase["sameDocument"], false); + let events = phase["events"].as_array().unwrap(); + let labels: Vec<_> = std::iter::once(old.to_owned()) + .chain((0..descendant_count).map(|n| format!("descendant-{n}"))) + .collect(); + assert!( + events + .iter() + .take(labels.len()) + .all(|e| e["type"] == "beforeunload"), + "{api}/{depth}: {result}" + ); + for label in &labels { + let actual: Vec<_> = events.iter().filter(|e| e["label"] == *label).collect(); + assert_eq!(actual.len(), 4, "{api}/{depth}: {result}"); + for (event, kind) in actual.iter().zip([ + "beforeunload", + "pagehide", + "visibilitychange", + "unload", + ]) { + assert_eq!(event["type"], kind, "{api}/{depth}: {result}"); + assert_eq!( + event["hidden"], + matches!(kind, "visibilitychange" | "unload") + ); + if kind != "beforeunload" { + assert_eq!(event["target"], "document", "{result}"); + } + assert_eq!(event["trusted"], true); + } + } + let loaded: Vec<_> = events.iter().filter(|e| e["label"] == new).collect(); + assert_eq!(loaded.len(), 2, "{api}/{depth}: {result}"); + for (event, kind) in loaded.iter().zip(["load", "pageshow"]) { + assert_eq!(event["type"], kind); + assert_eq!(event["target"], "document"); + assert_eq!(event["hidden"], false); + } + assert_eq!(events.len(), labels.len() * 4 + 2); + } + } + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn same_document_traversal_keeps_visibility_and_skips_unload() -> Result<()> { + for api in ["history", "navigation"] { + let result = history_visibility("same-document", api, 0).await?; + assert_eq!(result["events"], json!([]), "{api}: {result}"); + assert_eq!(result["sameDocument"], true); + assert_eq!(result["hidden"], false); + } + Ok(()) +} diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_events.rs b/moli-renderer-v8/src/context_bootstrap/navigation_events.rs index fc962a056f..a939a7cd7d 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_events.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_events.rs @@ -675,11 +675,12 @@ fn dispatch_unload_lifecycle_event_for_runtime_owner<'s>( } else if let Some(child_handle) = child_browsing_context_handle_for_runtime_owner(scope, owner) && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { - unsafe { &mut *host_ptr }.dispatch_child_window_event( + unsafe { &mut *host_ptr }.dispatch_child_window_event_with_target_override( scope, child_handle, event_type, event, + matches!(event_type, "pagehide" | "unload"), ); } set_navigation_unload_event_active(scope, owner, false); diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_traversal_execution.rs b/moli-renderer-v8/src/context_bootstrap/navigation_traversal_execution.rs index 46d0203219..4678adc3cc 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_traversal_execution.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_traversal_execution.rs @@ -8,8 +8,7 @@ use super::navigation_entry::{ }; use super::navigation_events::{ dispatch_beforeunload_for_runtime_owner, dispatch_navigation_traverse_event, - dispatch_navigation_traverse_event_with_outcome, dispatch_pagehide_for_runtime_owner, - dispatch_unload_for_runtime_owner, mark_navigation_outcome_default_prevented, + dispatch_navigation_traverse_event_with_outcome, mark_navigation_outcome_default_prevented, }; use super::navigation_lifecycle::finish_navigation_error_events; use super::navigation_result::{ @@ -339,7 +338,7 @@ fn dispatch_child_cross_document_traverse_event<'s>( info: Option>, ) -> bool { dispatch_beforeunload_for_runtime_owner(scope, target.owner); - let proceed = window_navigation_for_holder(scope, target.owner).is_none_or(|navigation| { + window_navigation_for_holder(scope, target.owner).is_none_or(|navigation| { let outcome = dispatch_navigation_traverse_event_with_outcome( scope, navigation, @@ -352,12 +351,7 @@ fn dispatch_child_cross_document_traverse_event<'s>( return false; } outcome.proceed - }); - if proceed { - dispatch_pagehide_for_runtime_owner(scope, target.owner); - dispatch_unload_for_runtime_owner(scope, target.owner); - } - proceed + }) } pub(in crate::context_bootstrap) fn apply_pending_child_cross_document_traversal( @@ -393,13 +387,25 @@ pub(in crate::context_bootstrap) fn apply_pending_child_cross_document_traversal .info .as_ref() .map(|info| v8::Local::new(scope, info)); + let retiring_document = host.child_browsing_context_document_handle(traversal.child_handle); if !dispatch_child_cross_document_traverse_event(scope, &target, info) { reject_child_cross_document_traversal(scope, &traversal); return; } - let _ = host.mark_current_child_document_unload_dispatched_after_navigation_traversal( - traversal.child_handle, - ); + if retiring_document.is_none() + || host.child_browsing_context_document_handle(traversal.child_handle) != retiring_document + || window_task_target_for_runtime_owner(scope, host, owner) != Some(traversal.target) + { + reject_child_cross_document_traversal(scope, &traversal); + return; + } + host.dispatch_child_document_unload_after_traversal_check(scope, traversal.child_handle); + if host.child_browsing_context_document_handle(traversal.child_handle) != retiring_document + || window_task_target_for_runtime_owner(scope, host, owner) != Some(traversal.target) + { + reject_child_cross_document_traversal(scope, &traversal); + return; + } queue_child_cross_document_traversal( host, traversal.child_handle, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/lifecycle.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/lifecycle.rs index ac6ed6f158..785087536c 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/lifecycle.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/lifecycle.rs @@ -852,18 +852,14 @@ impl JsContextHost { } } - pub(crate) fn mark_current_child_document_unload_dispatched_after_navigation_traversal( + pub(crate) fn dispatch_child_document_unload_after_traversal_check( &mut self, + scope: &mut v8::PinScope<'_, '_>, handle: DomHandle, ) -> bool { - let Some(action) = self - .frame_owner_store - .begin_current_child_document_unload(handle) - else { - return false; - }; - self.frame_owner_store - .finish_current_child_document_unload(action) + // Traversal has already fired the root beforeunload before navigate. + // Descendants still need their checks before any document unloads. + self.dispatch_child_document_tree_unload_lifecycle(scope, handle, false) } pub(in crate::native_bridge::context_host) fn dispatch_child_browsing_context_unload_lifecycle_if_needed( @@ -871,6 +867,16 @@ impl JsContextHost { scope: &mut v8::PinScope<'_, '_>, handle: DomHandle, ) -> bool { + self.dispatch_child_document_tree_unload_lifecycle(scope, handle, true) + } + + fn dispatch_child_document_tree_unload_lifecycle( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + handle: DomHandle, + include_root_beforeunload: bool, + ) -> bool { + let root_handle = handle; let documents = self.child_document_unload_tree_snapshot(handle); let mut unload_guards = Vec::new(); let mut actions = Vec::new(); @@ -904,7 +910,9 @@ impl JsContextHost { }; unload_guards.push((document, self.enter_document_unload(document))); actions.push((document, parent_document, action)); - dispatch_beforeunload_for_runtime_owner(scope, window); + if include_root_beforeunload || handle != root_handle { + dispatch_beforeunload_for_runtime_owner(scope, window); + } } unload_guards.clear(); let dispatched = !actions.is_empty(); diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_events.rs b/moli-renderer-v8/src/native_bridge/context_host/child_events.rs index 5c616f6058..901be145e3 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_events.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_events.rs @@ -480,6 +480,19 @@ impl JsContextHost { handle: DomHandle, event_type: &str, event: v8::Local<'s, v8::Object>, + ) { + self.dispatch_child_window_event_with_target_override( + scope, handle, event_type, event, false, + ); + } + + pub(crate) fn dispatch_child_window_event_with_target_override<'s>( + &mut self, + scope: &mut v8::PinScope<'s, '_>, + handle: DomHandle, + event_type: &str, + event: v8::Local<'s, v8::Object>, + legacy_target_override: bool, ) { if !self.child_window_event_requires_runtime_dispatch(handle, event_type) { return; @@ -492,7 +505,9 @@ impl JsContextHost { }; let previous_active_child_window = enter_child_window_event_dispatch(scope, handle); self.push_child_subresource_request_scope(handle); - let target = if event_type == "unload" { + // The legacy flag changes event.target, while dispatch still takes + // place at Window. Script dispatch never sets this flag. + let target = if legacy_target_override { self.child_browsing_context_document_wrapper(scope, handle) .map(Into::into) .unwrap_or_else(|| window.into()) diff --git a/moli-renderer-v8/src/native_bridge/context_host/host_loads.rs b/moli-renderer-v8/src/native_bridge/context_host/host_loads.rs index eb0a80641a..24a8c33e1a 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/host_loads.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/host_loads.rs @@ -417,7 +417,7 @@ impl JsContextHost { record_performance_load_event_start_for_window(scope, window); } self.enter_child_browsing_context_host_load_dispatch(handle); - self.dispatch_child_window_event(scope, handle, "load", event); + self.dispatch_child_window_event_with_target_override(scope, handle, "load", event, true); self.leave_child_browsing_context_host_load_dispatch(handle); if let Some(window) = performance_window { record_performance_load_event_end_for_window(scope, window); @@ -473,7 +473,13 @@ impl JsContextHost { let callback_dispatched = if let Some(event) = construct_original_page_transition_event(scope, "pageshow", false) { - self.dispatch_child_window_event(scope, action.child_handle(), "pageshow", event); + self.dispatch_child_window_event_with_target_override( + scope, + action.child_handle(), + "pageshow", + event, + true, + ); true } else { false diff --git a/moli-renderer-v8/src/window_host.rs b/moli-renderer-v8/src/window_host.rs index 97a230c498..b164549edf 100644 --- a/moli-renderer-v8/src/window_host.rs +++ b/moli-renderer-v8/src/window_host.rs @@ -9,6 +9,7 @@ use super::{ context_bootstrap::increment_performance_event_count, context_bootstrap::mark_event_trusted, context_bootstrap::performance_slot_number, + context_bootstrap::set_event_trusted, context_bootstrap::simple_event_target_add_event_listener_callback, context_bootstrap::simple_event_target_dispatch_event_callback, context_bootstrap::simple_event_target_remove_event_listener_callback, @@ -500,6 +501,7 @@ pub(super) fn event_target_dispatch_event_callback<'s>( return; } + set_event_trusted(scope, event, false); let event_type = event_type_string(scope, event); if let Some(handle) = child_window_target { let event_type = event_type.as_deref().unwrap_or_default();