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.
This commit is contained in:
ldm0
2026-09-23 00:11:56 +08:00
parent 1001400737
commit fd483cc8f4
9 changed files with 298 additions and 27 deletions
@@ -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
@@ -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
+233
View File
@@ -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<Value> {
let child = r#"<!doctype html><body><p>child</p><script>
const label = new URL(location.href).searchParams.get('label');
function record(event) {
top.events.push({label, type: event.type, hidden: document.hidden,
target: event.target === document ? 'document' : event.target === window ? 'window' : 'other',
currentWindow: event.currentTarget === window, trusted: event.isTrusted,
bubbles: event.bubbles, cancelable: event.cancelable});
}
for (const type of ['beforeunload', 'pagehide', 'unload', 'load', 'pageshow'])
addEventListener(type, record);
for (const type of ['load', 'pageshow', 'pagehide', 'unload'])
document.addEventListener(type, () => top.documentWindowEventCalls++, true);
document.addEventListener('visibilitychange', record);
document.addEventListener('DOMContentLoaded', event => {
window.savedDOMContentLoaded = event;
});
addEventListener('pageshow', event => {
if (event.isTrusted) {
top.pageReady(label);
}
});
</script>"#;
let child = serde_json::to_string(child)?.replace("</script>", "<\\/script>");
let markup = r#"<!doctype html><body><script>
window.events = [];
window.documentWindowEventCalls = 0;
let onReady = null;
window.pageReady = label => { if (onReady) onReady(label); };
window.finished = (async () => {
const childMarkup = __CHILD__;
const flow = __FLOW__;
const api = __API__;
function childURL(label) {
return '/compat/child-dynamic-markup-document?label=' + label +
'&markup=' + encodeURIComponent(childMarkup);
}
async function waitForPage(label, action) {
const ready = new Promise(resolve => {
onReady = observed => {
if (observed === label) {
onReady = null;
setTimeout(resolve, 0);
}
};
});
action();
await ready;
}
async function makeFrame(owner, label) {
const frame = owner.document.createElement('iframe');
await waitForPage(label, () => {
frame.src = childURL(label);
owner.document.body.append(frame);
});
return frame;
}
const frame = await makeFrame(window, 'A');
if (flow === 'synthetic') {
const native = events.slice();
const win = frame.contentWindow;
const doc = win.document;
let documentCalls = 0;
for (const type of ['load', 'pageshow', 'pagehide', 'unload'])
doc.addEventListener(type, () => documentCalls++, true);
win.addEventListener('DOMContentLoaded', win.record);
const saved = win.savedDOMContentLoaded;
const completedBeforeRedispatch = saved.eventPhase === 0 && saved.currentTarget === null;
const wasTrusted = saved.isTrusted;
events = [];
for (const type of ['load', 'pagehide', 'unload']) win.dispatchEvent(new Event(type));
win.dispatchEvent(new PageTransitionEvent('pageshow', {persisted: true}));
win.dispatchEvent(saved);
return {native, synthetic: events, documentCalls, documentWindowEventCalls, wasTrusted,
completedBeforeRedispatch,
currentTargetCleared: saved.currentTarget === null,
phase: saved.eventPhase, hidden: doc.hidden};
}
if (flow === 'same-document') {
const win = frame.contentWindow;
const doc = win.document;
win.history.pushState({}, '', '#one');
events = [];
await new Promise(resolve => {
win.addEventListener('popstate', () => setTimeout(resolve, 0), {once: true});
win[api].back();
});
return {events, sameDocument: win.document === doc, hidden: doc.hidden};
}
await waitForPage('B', () => frame.contentWindow.location.href = childURL('B'));
let owner = frame.contentWindow;
for (let i = 0; i < __DEPTH__; ++i)
owner = (await makeFrame(owner, 'descendant-' + i)).contentWindow;
const unrelated = await makeFrame(window, 'unrelated');
const unrelatedDocument = unrelated.contentDocument;
const beforeBack = frame.contentDocument;
events = [];
await waitForPage('A', () => frame.contentWindow[api].back());
const back = {events: events.slice(), oldHidden: beforeBack.hidden,
newHidden: frame.contentDocument.hidden, sameDocument: beforeBack === frame.contentDocument};
const beforeForward = frame.contentDocument;
events = [];
await waitForPage('B', () => frame.contentWindow[api].forward());
const forward = {events: events.slice(), oldHidden: beforeForward.hidden,
newHidden: frame.contentDocument.hidden, sameDocument: beforeForward === frame.contentDocument};
return {back, forward, unrelatedUnchanged: unrelated.contentDocument === unrelatedDocument};
})();
</script>"#
.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(())
}
@@ -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);
@@ -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<v8::Local<'s, v8::Value>>,
) -> 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,
@@ -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();
@@ -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())
@@ -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
+2
View File
@@ -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();