fix(events): preserve native dispatch state and listener lifetime

Set and clear child Window event dispatch fields, and clear Worker propagation flags after native delivery. Remove once listeners before invocation and skip listeners removed by an earlier callback, including during synchronous nested dispatch.

Add Window, child Window, and Worker regression probes for active event state, redispatch after stopImmediatePropagation, and once/listener removal during nested dispatch.
This commit is contained in:
ldm0
2026-10-11 18:04:11 +08:00
parent 6301024824
commit 4c7ad5fc9b
4 changed files with 157 additions and 8 deletions
+3
View File
@@ -30,6 +30,9 @@ use tokio::time::Duration;
#[path = "web_apis/abort_signal.rs"]
mod abort_signal;
#[path = "web_apis/event_dispatch.rs"]
mod event_dispatch;
fn diagnostic_global<'a>(
page: &'a moli_core::page::Page,
name: &str,
+129
View File
@@ -0,0 +1,129 @@
use super::*;
fn markup_url(server: &FixtureServer, markup: &str) -> String {
let mut url = url::Url::parse(&server.url("/compat/child-dynamic-markup-document")).unwrap();
url.query_pairs_mut().append_pair("markup", markup);
url.into()
}
pub(super) async fn run_probe(
browser: &Browser,
server: &FixtureServer,
target: &str,
source: &str,
) -> Result<serde_json::Value> {
let markup = if target == "worker" {
let source = serde_json::to_string(&format!(
"self.finish = value => postMessage(value);\n{source}"
))?;
format!(
r#"<!doctype html><script>
const worker = new Worker(URL.createObjectURL(new Blob([{source}], {{type: 'text/javascript'}})));
window.done = new Promise(resolve => {{
worker.onmessage = event => {{ resolve(event.data); worker.terminate(); }};
}});
worker.postMessage('go');
</script>"#
)
} else {
format!(
"<!doctype html><script>self.done = new Promise(resolve => self.finish = resolve);\n{source}</script>"
)
};
let url = markup_url(server, &markup);
let url = if target == "child" {
markup_url(
server,
&format!(
"<!doctype html><iframe id=target src=\"{}\"></iframe>",
url.replace('&', "&amp;")
),
)
} else {
url
};
let mut page = browser.fetch(&url).await?;
let expression = if target == "child" {
"document.getElementById('target').contentWindow.done.then(JSON.stringify)"
} else {
"done.then(JSON.stringify)"
};
let value = page
.evaluate_runtime_expression_with_await_async(expression, true)
.await?;
Ok(serde_json::from_str(
value["value"].as_str().expect("callback probe result"),
)?)
}
#[tokio::test(flavor = "multi_thread")]
async fn native_message_events_keep_dispatch_state_and_clear_propagation_flags() -> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let source = r#"
const log = [];
addEventListener('message', event => {
log.push(['during', event.currentTarget === self, event.eventPhase === 2,
typeof document === 'undefined' || window.event === event]);
event.initEvent('mutated', true, true);
log.push(event.type);
event.stopImmediatePropagation();
setTimeout(() => {
log.push(['after', event.currentTarget === null, event.eventPhase === 0,
typeof document === 'undefined' || window.event === undefined]);
dispatchEvent(event);
finish(log);
}, 0);
}, {once: true});
addEventListener('message', () => log.push('second'));
if (typeof document !== 'undefined') postMessage('go', '*');
"#;
for target in ["window", "child", "worker"] {
let observed = run_probe(&browser, &server, target, source).await?;
assert_eq!(
observed,
serde_json::json!([
["during", true, true, true],
"message",
["after", true, true, true],
"second"
]),
"target={target}"
);
}
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn native_message_listeners_observe_once_and_removal_during_nested_dispatch() -> Result<()> {
let server = FixtureServer::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let source = r#"
const log = [];
let calls = 0;
const removed = () => log.push('unexpected removed listener');
addEventListener('message', () => {
log.push('once');
if (++calls > 1) return;
removeEventListener('message', removed);
dispatchEvent(new Event('message'));
}, {once: true});
addEventListener('message', removed);
addEventListener('message', () => {
log.push('retained');
setTimeout(() => finish(log), 0);
});
if (typeof document !== 'undefined') postMessage('go', '*');
"#;
for target in ["window", "child", "worker"] {
let observed = run_probe(&browser, &server, target, source).await?;
assert_eq!(
observed,
serde_json::json!(["once", "retained", "retained"]),
"target={target}"
);
}
server.shutdown().await;
Ok(())
}
@@ -2,7 +2,11 @@ use super::{
JsContextHost, OwnerDispatchScope, child_frame_runtime::WINDOW_EVENT_HANDLER_PROPERTIES,
};
use crate::{
context_bootstrap::{EventHandlerType, apply_event_handler_return_value, event_is_error_event},
context_bootstrap::{
EVENT_DISPATCHING_SLOT, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, EVENT_STOP_PROPAGATION_SLOT,
EventHandlerType, apply_event_handler_return_value, event_is_error_event,
set_event_internal_flag,
},
document_runtime::DomHandle,
document_runtime::EventTargetHandle,
exception_reporting::invoke_event_handler,
@@ -495,16 +499,14 @@ impl JsContextHost {
} else {
window.into()
};
let _ = crate::context_bootstrap::event_backing(scope, event).set(scope, v8str(scope, "target").into(), target);
let _ = crate::context_bootstrap::event_backing(scope, event).set(scope, v8str(scope, "currentTarget").into(), window.into());
let _ = crate::context_bootstrap::event_backing(scope, event).set(
scope,
v8str(scope, "target").into(),
target,
);
let _ = crate::context_bootstrap::event_backing(scope, event).set(
scope,
v8str(scope, "currentTarget").into(),
window.into(),
v8str(scope, "eventPhase").into(),
v8::Integer::new(scope, 2).into(),
);
set_event_internal_flag(scope, event, EVENT_DISPATCHING_SLOT, true);
if event_type == "load" {
install_child_body_load_attribute_handler_if_needed(scope, self, handle);
@@ -553,6 +555,19 @@ impl JsContextHost {
break;
}
}
let _ = crate::context_bootstrap::event_backing(scope, event).set(
scope,
v8str(scope, "eventPhase").into(),
v8::Integer::new(scope, 0).into(),
);
let _ = crate::context_bootstrap::event_backing(scope, event).set(
scope,
v8str(scope, "currentTarget").into(),
v8::null(scope).into(),
);
set_event_internal_flag(scope, event, EVENT_DISPATCHING_SLOT, false);
set_event_internal_flag(scope, event, EVENT_STOP_PROPAGATION_SLOT, false);
set_event_internal_flag(scope, event, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, false);
self.pop_child_subresource_request_scope();
restore_child_window_event_dispatch(scope, previous_active_child_window);
}
@@ -662,6 +662,8 @@ fn clear_event_dispatch_fields(scope: &mut v8::PinScope<'_, '_>, event: v8::Loca
EVENT_DISPATCHING_SLOT,
v8::Boolean::new(scope, false).into(),
);
set_event_internal_flag(scope, event, EVENT_STOP_PROPAGATION_SLOT, false);
set_event_internal_flag(scope, event, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, false);
}
fn worker_event_prevent_default_callback(