mirror of
https://github.com/lexmount/moli.git
synced 2026-10-07 08:01:00 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user