fix: unload descendant documents during frame navigation

Snapshot retiring frame documents and finish their beforeunload phase before
actual unload delivery. Preserve ancestor unload counters through descendant
callbacks and check exact owners before dispatching further events.

Retain visibility state on the native Document and dispatch trusted, bubbling
visibilitychange events through the host event path. Use native unload
counters to suppress navigation during visibility and ancestor callbacks;
cancel each retiring window's timers after its unload.

Eight Browser integration tests cover 50 scenarios. The 181-case WPT
comparison gains two passing cases and seven passing subtests without
regressions; update the passed ledger to 9,101 cases.

Validation: cargo fmt --all; cargo clippy --workspace --all-targets
--all-features -- -D warnings; cargo nextest run --no-fail-fast
(17,910 passed, 13 skipped). Rebuilt CLI matches the tested WPT binary.
This commit is contained in:
ldm0
2026-09-27 07:59:20 +08:00
parent fd33570e45
commit e86277ef0f
8 changed files with 291 additions and 122 deletions
@@ -7321,6 +7321,7 @@ html/webappapis/dynamic-markup-insertion/opening-the-input-stream/document-open-
html/webappapis/dynamic-markup-insertion/opening-the-input-stream/document.open-02.html
html/webappapis/dynamic-markup-insertion/opening-the-input-stream/document.open-03.html
html/webappapis/dynamic-markup-insertion/opening-the-input-stream/form-control-state.html
html/webappapis/dynamic-markup-insertion/opening-the-input-stream/ignore-opens-during-unload.window.js?moli-wpt-script=window
html/webappapis/dynamic-markup-insertion/opening-the-input-stream/location-set-and-document-open.html
html/webappapis/dynamic-markup-insertion/opening-the-input-stream/origin-check-in-document-open-basic.html
html/webappapis/dynamic-markup-insertion/the-outerhtml-property/outerhtml-documentfragment.html
+15 -3
View File
@@ -29,7 +29,7 @@ async fn stream_operation_during_unload(
let listenerFrame = frame;
let doc = frame.contentDocument;
if (target === 'other') doc = (await frameIn(window)).contentDocument;
if (target === 'ancestor') {{
if (target.endsWith('ancestor')) {{
const middle = await frameIn(frame.contentWindow);
doc = middle.contentDocument;
listenerFrame = await frameIn(middle.contentWindow);
@@ -99,7 +99,7 @@ async fn stream_operation_during_unload(
#[tokio::test(flavor = "multi_thread")]
async fn document_stream_operations_have_no_side_effects_during_unload() -> Result<()> {
for event in ["beforeunload", "pagehide", "unload"] {
for event in ["beforeunload", "pagehide", "visibilitychange", "unload"] {
for operation in [
"open",
"prototype-open",
@@ -132,9 +132,21 @@ async fn ancestor_unload_counter_covers_descendant_callbacks() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn ordinary_ancestor_unload_counter_covers_descendant_callbacks() -> Result<()> {
for event in ["beforeunload", "pagehide", "visibilitychange", "unload"] {
let result = stream_operation_during_unload(event, "open", "ordinary-ancestor").await?;
assert_eq!(result["sameRoot"], true, "{event}: {result}");
assert_eq!(result["sameLength"], true);
assert_eq!(result["listenerCount"], 1);
assert_eq!(result["returnedDocument"], true);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn unload_does_not_block_opening_another_document() -> Result<()> {
for event in ["beforeunload", "pagehide", "unload"] {
for event in ["beforeunload", "pagehide", "visibilitychange", "unload"] {
let result = stream_operation_during_unload(event, "open", "other").await?;
assert_eq!(result["sameRoot"], false, "{event}: {result}");
assert_eq!(result["listenerCount"], 0);
+84 -26
View File
@@ -11,6 +11,9 @@ async fn child_navigation_lifecycle(via: &str, kind: &str, depth: usize) -> Resu
let markup = format!(
r#"<!doctype html><body><script>
window.events = [];
window.visibility = [];
window.bubbledVisibility = [];
window.syntheticDispatchCalled = false;
window.staleTimerRan = false;
window.unloadNavigationRan = false;
window.finished = (async () => {{
@@ -24,18 +27,31 @@ async fn child_navigation_lifecycle(via: &str, kind: &str, depth: usize) -> Resu
const win = frame.contentWindow;
for (const type of ['beforeunload', 'pagehide', 'unload'])
win.addEventListener(type, () => events.push(label + ':' + type));
win.document.addEventListener('visibilitychange', () =>
events.push(label + ':visibilitychange'));
win.addEventListener('unload', () => {{
win.document.addEventListener('visibilitychange', event => {{
events.push(label + ':visibilitychange');
visibility.push({{label, hidden: win.document.hidden,
state: win.document.visibilityState, trusted: event.isTrusted,
bubbles: event.bubbles, cancelable: event.cancelable,
documentTarget: event.target === win.document}});
}});
win.addEventListener('visibilitychange', () => bubbledVisibility.push(label));
win.document.dispatchEvent = () => {{ syntheticDispatchCalled = true; }};
const cleanup = () => {{
win.setTimeout(() => staleTimerRan = true, 0);
win.location.href = 'javascript:top.unloadNavigationRan = true; void 0';
}});
}};
win.addEventListener('unload', cleanup);
win.document.addEventListener('visibilitychange', cleanup);
return frame;
}}
const frame = await makeFrame(window, 'target');
let owner = frame.contentWindow;
for (let i = 0; i < {depth}; ++i)
owner = (await makeFrame(owner, 'descendant-' + i)).contentWindow;
if ({depth} > 0) owner.addEventListener('unload', () => {{
frame.contentWindow.location.href =
'javascript:top.unloadNavigationRan = true; void 0';
}});
const unrelated = await makeFrame(window, 'unrelated');
const oldDocument = frame.contentDocument;
const unrelatedDocument = unrelated.contentDocument;
@@ -60,8 +76,10 @@ async fn child_navigation_lifecycle(via: &str, kind: &str, depth: usize) -> Resu
}}
await done;
await new Promise(resolve => setTimeout(resolve, 0));
return {{events, staleTimerRan, unloadNavigationRan, loads,
return {{events, visibility, bubbledVisibility, syntheticDispatchCalled,
staleTimerRan, unloadNavigationRan, loads,
sameDocument: frame.contentDocument === oldDocument,
oldHidden: oldDocument.hidden, newHidden: frame.contentDocument.hidden,
unrelatedUnchanged: unrelated.contentDocument === unrelatedDocument,
text: frame.contentDocument.body.textContent,
children: frame.contentWindow.length}};
@@ -83,6 +101,11 @@ async fn child_navigation_lifecycle(via: &str, kind: &str, depth: usize) -> Resu
server.shutdown().await;
assert_eq!(result["unrelatedUnchanged"], true, "{via}/{kind}: {result}");
assert_eq!(result["staleTimerRan"], false, "{via}/{kind}: {result}");
assert_eq!(
result["syntheticDispatchCalled"], false,
"{via}/{kind}: {result}"
);
assert_eq!(result["newHidden"], false, "{via}/{kind}: {result}");
assert_eq!(
result["unloadNavigationRan"], false,
"{via}/{kind}: {result}"
@@ -137,28 +160,63 @@ async fn child_javascript_url_non_string_preserves_document_and_descendants() ->
}
#[tokio::test(flavor = "multi_thread")]
async fn ordinary_child_navigation_still_dispatches_beforeunload() -> Result<()> {
async fn ordinary_child_navigation_unloads_descendants_and_updates_visibility() -> Result<()> {
for via in ["location", "src", "anchor"] {
let result = child_navigation_lifecycle(via, "network", 0).await?;
assert_eq!(result["sameDocument"], false);
assert_eq!(result["loads"], 1);
assert_eq!(result["text"], "network");
let events: Vec<_> = result["events"]
.as_array()
.unwrap()
.iter()
.filter(|event| *event != "target:visibilitychange")
.cloned()
.collect();
assert_eq!(
events,
vec![
json!("target:beforeunload"),
json!("target:pagehide"),
json!("target:unload")
],
"{via}: {result}"
);
for depth in [0, 2] {
let result = child_navigation_lifecycle(via, "network", depth).await?;
assert_eq!(result["sameDocument"], false);
assert_eq!(result["loads"], 1);
assert_eq!(result["text"], "network");
assert_eq!(result["oldHidden"], true);
let events = result["events"].as_array().unwrap();
let labels: Vec<_> = std::iter::once("target".to_owned())
.chain((0..depth).map(|index| format!("descendant-{index}")))
.collect();
// Cancellation checks for every document precede actual unloads.
assert!(
events
.iter()
.take(labels.len())
.all(|event| event.as_str().unwrap().ends_with(":beforeunload")),
"{via}/{depth}: {result}"
);
for label in &labels {
let actual: Vec<_> = events
.iter()
.filter(|event| event.as_str().unwrap().starts_with(&format!("{label}:")))
.cloned()
.collect();
assert_eq!(
actual,
vec![
json!(format!("{label}:beforeunload")),
json!(format!("{label}:pagehide")),
json!(format!("{label}:visibilitychange")),
json!(format!("{label}:unload"))
],
"{via}/{depth}: {result}"
);
assert!(
result["visibility"].as_array().unwrap().contains(&json!({
"label": label, "hidden": true, "state": "hidden", "trusted": true,
"bubbles": true, "cancelable": false, "documentTarget": true
})),
"{via}/{depth}: {result}"
);
assert!(
result["bubbledVisibility"]
.as_array()
.unwrap()
.contains(&json!(label))
);
}
assert_eq!(events.len(), 4 * labels.len(), "{via}/{depth}: {result}");
assert_eq!(result["visibility"].as_array().unwrap().len(), labels.len());
assert_eq!(
result["bubbledVisibility"].as_array().unwrap().len(),
labels.len()
);
}
}
Ok(())
}
+12
View File
@@ -49,6 +49,8 @@ pub struct Document {
url: Url,
content_type: Box<str>,
ready_state: DocumentReadyState,
// Retained with the Document when its Window is replaced during navigation.
visibility_hidden: bool,
quirks_mode: QuirksMode,
kind: DocumentKind,
// The document's HTML scripting flag. Browsing-context policy can still
@@ -77,6 +79,7 @@ impl Document {
url,
content_type: "text/html".into(),
ready_state: DocumentReadyState::Complete,
visibility_hidden: false,
quirks_mode: QuirksMode::NoQuirks,
kind: DocumentKind::Html,
scripting_enabled,
@@ -93,6 +96,7 @@ impl Document {
url,
content_type: "application/xml".into(),
ready_state: DocumentReadyState::Complete,
visibility_hidden: false,
quirks_mode: QuirksMode::NoQuirks,
kind: DocumentKind::Xml,
scripting_enabled: true,
@@ -119,6 +123,10 @@ impl Document {
self.ready_state
}
pub fn visibility_hidden(&self) -> bool {
self.visibility_hidden
}
pub fn default_language(&self) -> Option<&str> {
self.default_language.as_deref()
}
@@ -204,6 +212,10 @@ impl Document {
self.ready_state = ready_state;
}
pub fn set_visibility_hidden(&mut self, hidden: bool) {
self.visibility_hidden = hidden;
}
pub fn set_default_language(&mut self, language: Option<String>) {
self.default_language = language.map(String::into_boxed_str);
}
+19
View File
@@ -1379,6 +1379,25 @@ impl DomHost {
true
}
pub fn set_document_visibility_hidden_for_handle(
&mut self,
document_handle: DomHandle,
hidden: bool,
) -> bool {
let Some(document) = self
.node_mut(document_handle)
.and_then(|node| node.data_mut().as_document_mut())
else {
return false;
};
if document.visibility_hidden() == hidden {
return false;
}
document.set_visibility_hidden(hidden);
self.record_mutation(MutationScope::LocalState);
true
}
pub fn child_nodes(&self, handle: DomHandle) -> Option<Vec<DomHandle>> {
self.node(handle)?;
Some(self.child_handles(handle).collect())
@@ -190,6 +190,11 @@ pub(super) fn navigation_unload_event_active<'s>(
owner: v8::Local<'s, v8::Object>,
) -> bool {
object_bool_property(scope, owner, WINDOW_UNLOAD_EVENT_ACTIVE_SLOT).unwrap_or(false)
|| context_host_ptr_from_global_bridge(scope).is_some_and(|host_ptr| {
let host = unsafe { &*host_ptr };
super::window_accessors::window_document_handle(scope, owner, host)
.is_some_and(|document| host.has_document_unload_counter(document))
})
}
pub(super) fn set_navigation_unload_event_active<'s>(
@@ -7,9 +7,8 @@ enum ChildDocumentInteractiveScriptDisposition {
}
use crate::{
context_bootstrap::{
construct_original_event, dispatch_beforeunload_for_runtime_owner,
dispatch_pagehide_for_runtime_owner, dispatch_unload_for_runtime_owner,
record_performance_dom_content_loaded_event_end,
dispatch_beforeunload_for_runtime_owner, dispatch_pagehide_for_runtime_owner,
dispatch_unload_for_runtime_owner, record_performance_dom_content_loaded_event_end,
record_performance_dom_content_loaded_event_start,
},
detached_event_target::dispatch_detached_simple_event,
@@ -20,13 +19,10 @@ use crate::{
FrameDocumentDescendantLoadParent, FrameDocumentInteractiveLifecycleAction,
FrameDocumentLifecycleAction, FrameDocumentLifecycleTaskEffect,
FrameDocumentLoadDeliveryTask, FrameDocumentNavigationLoadBinding, FrameDocumentTaskOwner,
FrameRealmId,
FrameDocumentUnloadLifecycleAction, FrameRealmId,
},
native_bridge::document::DETACHED_STATE_SLOT,
util::{
call_object_method, context_host_ptr_from_global_bridge, get_private_object, v8_string,
v8str,
},
util::{context_host_ptr_from_global_bridge, get_private_object, v8_string, v8str},
};
#[derive(Clone, Copy, Debug, Default)]
@@ -875,45 +871,88 @@ impl JsContextHost {
scope: &mut v8::PinScope<'_, '_>,
handle: DomHandle,
) -> bool {
let Some(window) = self.existing_child_browsing_context_window_wrapper(scope, handle)
else {
return false;
let documents = self.child_document_unload_tree_snapshot(handle);
let mut unload_guards = Vec::new();
let mut actions = Vec::new();
// Every descendant gets its cancellation check before actual unload
// begins. Keep ancestor counters active across descendant callbacks.
for (handle, document, parent_document) in documents {
while unload_guards
.last()
.is_some_and(|(document, _)| Some(*document) != parent_document)
{
unload_guards.pop();
}
if self.child_browsing_context_document_handle(handle) != Some(document) {
continue;
}
let Some(window) = self.existing_child_browsing_context_window_wrapper(scope, handle)
else {
continue;
};
if self
.child_browsing_context_document_wrapper(scope, handle)
.is_none()
{
continue;
}
let Some(action) = self
.frame_owner_store
.begin_current_child_document_unload(handle)
else {
continue;
};
unload_guards.push((document, self.enter_document_unload(document)));
actions.push((document, parent_document, action));
dispatch_beforeunload_for_runtime_owner(scope, window);
}
unload_guards.clear();
let dispatched = !actions.is_empty();
for (document, parent_document, action) in actions {
while unload_guards
.last()
.is_some_and(|(document, _)| Some(*document) != parent_document)
{
unload_guards.pop();
}
if self
.frame_owner_store
.child_document_task_owner_is_current(action.child_handle(), action.owner())
{
unload_guards.push((document, self.enter_document_unload(document)));
Self::dispatch_child_document_unload_action(scope, self, action);
}
}
dispatched
}
fn child_document_unload_tree_snapshot(
&self,
handle: DomHandle,
) -> Vec<(DomHandle, DomHandle, Option<DomHandle>)> {
let Some(document) = self.child_browsing_context_document_handle(handle) else {
return Vec::new();
};
let Some(_document) = self.child_browsing_context_document_wrapper(scope, handle) else {
return false;
};
let Some(action) = self
.frame_owner_store
.begin_current_child_document_unload(handle)
else {
return false;
};
tracing::debug!(
?handle,
owner = ?action.owner(),
"dispatching document-owned child unload lifecycle"
let mut handles = vec![handle];
self.collect_child_browsing_context_handles_in_document_order_from_document(
document,
&mut handles,
);
let execution_context_owner = crate::native_bridge::WindowExecutionContextOwner::Frame(
action.owner().local_window_id,
);
let _document_unload = self
.child_browsing_context_document_handle(handle)
.map(|document| self.enter_document_unload(document));
dispatch_beforeunload_for_runtime_owner(scope, window);
dispatch_pagehide_for_runtime_owner(scope, window);
dispatch_unload_for_runtime_owner(scope, window);
let finished = self
.frame_owner_store
.finish_current_child_document_unload(action);
tracing::debug!(
?handle,
owner = ?action.owner(),
finished,
"finished document-owned child unload lifecycle"
);
unsafe { &mut *self.runtime }
.cancel_window_execution_context_timers(execution_context_owner);
true
// Snapshot the documents before any unload handler can remove or
// replace a descendant. A new document must not inherit this unload.
handles
.into_iter()
.filter_map(|handle| {
self.child_browsing_context_document_handle(handle)
.map(|document| {
(
handle,
document,
self.dom_host().owner_document_handle(handle),
)
})
})
.collect()
}
pub(in crate::native_bridge::context_host) fn dispatch_child_javascript_url_unload_lifecycle(
@@ -921,29 +960,7 @@ impl JsContextHost {
host_ptr: *mut Self,
handle: DomHandle,
) {
let Some(document) = unsafe { &*host_ptr }.child_browsing_context_document_handle(handle) else {
return;
};
let mut handles = vec![handle];
unsafe { &*host_ptr }.collect_child_browsing_context_handles_in_document_order_from_document(
document,
&mut handles,
);
// Snapshot the documents before any unload handler can remove or
// replace a descendant. A new document must not inherit this unload.
let documents: Vec<_> = handles
.into_iter()
.filter_map(|handle| {
unsafe { &*host_ptr }.child_browsing_context_document_handle(handle)
.map(|document| {
(
handle,
document,
unsafe { &*host_ptr }.dom_host().owner_document_handle(handle),
)
})
})
.collect();
let documents = unsafe { &mut *host_ptr }.child_document_unload_tree_snapshot(handle);
// Keep an ancestor's counter active while its descendants unload,
// and release a completed sibling before entering the next subtree.
let mut unload_guards = Vec::new();
@@ -954,8 +971,8 @@ impl JsContextHost {
{
unload_guards.pop();
}
if unsafe { &*host_ptr }.child_browsing_context_document_handle(handle) == Some(document) {
unload_guards.push((document, unsafe { &*host_ptr }.enter_document_unload(document)));
if unsafe { &mut *host_ptr }.child_browsing_context_document_handle(handle) == Some(document) {
unload_guards.push((document, unsafe { &mut *host_ptr }.enter_document_unload(document)));
Self::dispatch_child_document_unload_without_beforeunload(scope, host_ptr, handle);
}
}
@@ -969,38 +986,80 @@ impl JsContextHost {
host_ptr: *mut Self,
handle: DomHandle,
) {
let Some(window) =
unsafe { &mut *host_ptr }.existing_child_browsing_context_window_wrapper(scope, handle)
else {
if unsafe { &mut *host_ptr }
.existing_child_browsing_context_window_wrapper(scope, handle)
.is_none()
|| unsafe { &mut *host_ptr }
.child_browsing_context_document_wrapper(scope, handle)
.is_none()
{
return;
};
let Some(document) =
unsafe { &mut *host_ptr }.child_browsing_context_document_wrapper(scope, handle)
else {
return;
};
}
let Some(action) = unsafe { &mut *host_ptr }
.frame_owner_store
.begin_current_child_document_unload(handle)
else {
return;
};
Self::dispatch_child_document_unload_action(scope, host_ptr, action);
}
fn dispatch_child_document_unload_action(
scope: &mut v8::PinScope<'_, '_>,
host_ptr: *mut Self,
action: FrameDocumentUnloadLifecycleAction,
) {
let handle = action.child_handle();
if !unsafe { &mut *host_ptr }
.frame_owner_store
.child_document_task_owner_is_current(handle, action.owner())
{
return;
}
let Some(window) = unsafe { &mut *host_ptr }.existing_child_browsing_context_window_wrapper(scope, handle)
else {
return;
};
let Some(document) = unsafe { &mut *host_ptr }.child_browsing_context_document_wrapper(scope, handle) else {
return;
};
let execution_context_owner = crate::native_bridge::WindowExecutionContextOwner::Frame(
action.owner().local_window_id,
);
let _document_unload = unsafe { &*host_ptr }
let _document_unload = unsafe { &mut *host_ptr }
.child_browsing_context_document_handle(handle)
.map(|document| unsafe { &*host_ptr }.enter_document_unload(document));
.map(|document| unsafe { &mut *host_ptr }.enter_document_unload(document));
dispatch_pagehide_for_runtime_owner(scope, window);
if let Some(event) = construct_original_event(scope, "visibilitychange") {
let _ = call_object_method(scope, document, "dispatchEvent", &[event.into()]);
if let Some(document_handle) = unsafe { &mut *host_ptr }.child_browsing_context_document_handle(handle)
&& unsafe { &mut *host_ptr }
.frame_owner_store
.child_document_task_owner_is_current(handle, action.owner())
&& unsafe { &mut *host_ptr }
.dom_host_mut()
.set_document_visibility_hidden_for_handle(document_handle, true)
{
unsafe { &mut *host_ptr }.dispatch_child_document_event_for_owner(
scope,
handle,
action.owner(),
document,
"visibilitychange",
true,
false,
);
}
if unsafe { &mut *host_ptr }
.frame_owner_store
.child_document_task_owner_is_current(handle, action.owner())
{
dispatch_unload_for_runtime_owner(scope, window);
}
dispatch_unload_for_runtime_owner(scope, window);
let _ = unsafe { &mut *host_ptr }
.frame_owner_store
.finish_current_child_document_unload(action);
unsafe { &mut *host_ptr }.cancel_window_execution_context_timers(execution_context_owner);
unsafe { &mut *unsafe { &mut *host_ptr }.runtime }
.cancel_window_execution_context_timers(execution_context_owner);
}
pub(crate) fn dispatch_document_open_descendant_frame_unload_lifecycle(
+11 -8
View File
@@ -1415,6 +1415,13 @@ fn document_has_browsing_context(runtime: &JsContextHost, handle: DomHandle) ->
.is_some()
}
fn document_is_hidden(runtime: &JsContextHost, handle: DomHandle) -> bool {
!runtime.document_activity().visible
|| runtime.dom_host().node(handle).and_then(Node::as_document)
.is_some_and(|document| document.visibility_hidden())
|| !document_has_browsing_context(runtime, handle)
}
fn document_hidden_getter_function<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
@@ -1425,9 +1432,7 @@ fn document_hidden_getter_function<'s>(
rv.set_undefined();
return;
};
let visible = unsafe { &*runtime_ptr }.document_activity().visible
&& document_has_browsing_context(unsafe { &*runtime_ptr }, handle);
rv.set_bool(!visible);
rv.set_bool(document_is_hidden(unsafe { &*runtime_ptr }, handle));
}
fn document_visibility_state_getter_function<'s>(
@@ -1440,12 +1445,10 @@ fn document_visibility_state_getter_function<'s>(
rv.set_undefined();
return;
};
let state = if unsafe { &*runtime_ptr }.document_activity().visible
&& document_has_browsing_context(unsafe { &*runtime_ptr }, handle)
{
"visible"
} else {
let state = if document_is_hidden(unsafe { &*runtime_ptr }, handle) {
"hidden"
} else {
"visible"
};
set_document_string_return_value(scope, &mut rv, state);
}