fix(navigation): share non-cancelable child unload lifecycle

This commit is contained in:
ldm0
2026-09-26 09:44:36 +08:00
parent c3c279cf36
commit 1922971bb8
4 changed files with 205 additions and 13 deletions
+164
View File
@@ -0,0 +1,164 @@
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 child_navigation_lifecycle(via: &str, kind: &str, depth: usize) -> Result<Value> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(BrowserConfig::default())?;
let markup = format!(
r#"<!doctype html><body><script>
window.events = [];
window.staleTimerRan = false;
window.unloadNavigationRan = false;
window.finished = (async () => {{
async function makeFrame(owner, label) {{
const frame = owner.document.createElement('iframe');
const loaded = new Promise(resolve => frame.onload = resolve);
frame.src = '/compat/child-dynamic-markup-document?markup=' +
encodeURIComponent('<!doctype html><body>original');
owner.document.body.append(frame);
await loaded;
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.setTimeout(() => staleTimerRan = true, 0);
win.location.href = 'javascript:top.unloadNavigationRan = true; void 0';
}});
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;
const unrelated = await makeFrame(window, 'unrelated');
const oldDocument = frame.contentDocument;
const unrelatedDocument = unrelated.contentDocument;
let resolveDone;
const done = new Promise(resolve => resolveDone = resolve);
let loads = 0;
frame.onload = () => {{ loads++; resolveDone(); }};
window.nonStringDone = () => setTimeout(resolveDone, 0);
const kind = {kind:?};
const url = kind === 'string' ? 'javascript:"<body>replacement"' :
kind === 'undefined' ? 'javascript:top.nonStringDone(); void 0' :
'/compat/child-dynamic-markup-document?markup=' +
encodeURIComponent('<!doctype html><body>network');
const via = {via:?};
if (via === 'location') frame.contentWindow.location.href = url;
else if (via === 'src') frame.src = url;
else {{
const anchor = frame.contentDocument.createElement('a');
anchor.href = url;
frame.contentDocument.body.append(anchor);
anchor.click();
}}
await done;
await new Promise(resolve => setTimeout(resolve, 0));
return {{events, staleTimerRan, unloadNavigationRan, loads,
sameDocument: frame.contentDocument === oldDocument,
unrelatedUnchanged: unrelated.contentDocument === unrelatedDocument,
text: frame.contentDocument.body.textContent,
children: frame.contentWindow.length}};
}})();
</script>"#
);
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: Value = serde_json::from_str(result["value"].as_str().unwrap())?;
server.shutdown().await;
assert_eq!(result["unrelatedUnchanged"], true, "{via}/{kind}: {result}");
assert_eq!(result["staleTimerRan"], false, "{via}/{kind}: {result}");
assert_eq!(
result["unloadNavigationRan"], false,
"{via}/{kind}: {result}"
);
Ok(result)
}
#[tokio::test(flavor = "multi_thread")]
async fn child_javascript_url_string_unloads_without_beforeunload() -> Result<()> {
for via in ["location", "src", "anchor"] {
for depth in [0, 2] {
let result = child_navigation_lifecycle(via, "string", depth).await?;
assert_eq!(result["sameDocument"], false);
assert_eq!(result["loads"], 1);
assert_eq!(result["text"], "replacement");
assert_eq!(result["children"], 0);
let events = result["events"].as_array().unwrap();
let labels = std::iter::once("target".to_owned())
.chain((0..depth).map(|index| format!("descendant-{index}")));
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}:pagehide")),
json!(format!("{label}:visibilitychange")),
json!(format!("{label}:unload")),
],
"{via}/{depth}: {result}"
);
}
assert_eq!(events.len(), 3 * (depth + 1), "{via}/{depth}: {result}");
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn child_javascript_url_non_string_preserves_document_and_descendants() -> Result<()> {
for via in ["location", "src", "anchor"] {
let result = child_navigation_lifecycle(via, "undefined", 2).await?;
assert_eq!(result["sameDocument"], true);
assert_eq!(result["loads"], 0);
assert_eq!(result["events"], json!([]));
assert_eq!(result["children"], 1);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn ordinary_child_navigation_still_dispatches_beforeunload() -> 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}"
);
}
Ok(())
}
@@ -867,14 +867,44 @@ impl JsContextHost {
true
}
/// Dispatches the unload sequence used when `Document::open()` removes a
/// descendant frame.
///
/// This is intentionally distinct from navigation teardown: Chromium's
/// document-open steps do not prompt the child with `beforeunload`, and
/// dispatch pagehide/visibilitychange before unload while the parent
/// document's listeners are still installed.
fn dispatch_child_browsing_context_document_open_unload_lifecycle_if_needed(
pub(in crate::native_bridge::context_host) fn dispatch_child_javascript_url_unload_lifecycle(
scope: &mut v8::PinScope<'_, '_>,
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))
})
.collect();
for (handle, document) in documents {
if unsafe { &*host_ptr }.child_browsing_context_document_handle(handle)
== Some(document)
{
Self::dispatch_child_document_unload_without_beforeunload(scope, host_ptr, handle);
}
}
}
/// JavaScript URL replacement and removal by `Document::open()` unload
/// documents without checking whether unloading is canceled. They still
/// dispatch the actual unload lifecycle and cancel the old window's timers.
fn dispatch_child_document_unload_without_beforeunload(
scope: &mut v8::PinScope<'_, '_>,
host_ptr: *mut Self,
handle: DomHandle,
@@ -924,9 +954,7 @@ impl JsContextHost {
{
continue;
}
Self::dispatch_child_browsing_context_document_open_unload_lifecycle_if_needed(
scope, host_ptr, handle,
);
Self::dispatch_child_document_unload_without_beforeunload(scope, host_ptr, handle);
}
}
}
@@ -669,7 +669,7 @@ impl JsContextHost {
self.child_document_credentialless_storage_nonce(document_credentialless);
self.clear_pending_child_document_loads_for_handle(handle);
self.dispatch_child_browsing_context_unload_lifecycle_if_needed(scope, handle);
Self::dispatch_child_javascript_url_unload_lifecycle(scope, self, handle);
if !self.child_document_window_commit_preflight_is_current(handle, &window_commit_preflight)
{
let _ = self.finish_child_frame_navigation_without_load_dispatch(
@@ -54,7 +54,7 @@ impl JsContextHost {
self.lightweight_popup_id_for_document_handle(owner_document)
}
fn collect_child_browsing_context_handles_in_document_order_from_document(
pub(in crate::native_bridge::context_host) fn collect_child_browsing_context_handles_in_document_order_from_document(
&self,
document: DomHandle,
out: &mut Vec<DomHandle>,