fix(popup): share body and frameset Window handler properties

Route body/frameset IDL attributes through their owning popup Window's existing handler values and ordered registrations. Include onmessageerror in the shared Window handler list and clear private handler state on document retirement without invoking author-replaced setters.

Cover shared values, listener replacement and cancellation, onerror arguments, adoption, windowless ownership, and document retirement.
This commit is contained in:
ldm0
2026-09-23 01:20:12 +08:00
parent 21079f17af
commit 49b4e87db2
6 changed files with 224 additions and 18 deletions
@@ -63,6 +63,7 @@ pub(crate) const WINDOW_EVENT_HANDLER_PROPERTIES: &[&str] = &[
"onloadstart",
"onlostpointercapture",
"onmessage",
"onmessageerror",
"onmousedown",
"onmousemove",
"onmouseenter",
@@ -56,7 +56,7 @@ pub(in crate::context_bootstrap) fn install_window_global_accessors<'s>(
for name in WINDOW_EVENT_HANDLER_PROPERTIES {
if matches!(
*name,
"onerror" | "onunhandledrejection" | "onrejectionhandled"
"onerror" | "onmessageerror" | "onunhandledrejection" | "onrejectionhandled"
) {
continue;
}
@@ -1295,6 +1295,43 @@ impl JsContextHost {
.map(|record| v8::Local::new(scope, &record.window_proxy))
}
pub(crate) fn lightweight_popup_event_handler_property_value<'s>(
&self,
scope: &mut v8::PinScope<'s, '_>,
popup_id: u64,
property_name: &str,
) -> Option<v8::Local<'s, v8::Value>> {
let window = self.lightweight_popup_window(scope, popup_id)?;
Some(lightweight_popup_event_handler_value(
scope,
window,
property_name,
))
}
pub(crate) fn set_lightweight_popup_event_handler_property<'s>(
&mut self,
scope: &mut v8::PinScope<'s, '_>,
popup_id: u64,
property_name: &str,
handler: Option<v8::Local<'s, v8::Object>>,
) {
let Some(window) = self.lightweight_popup_window(scope, popup_id) else {
return;
};
let Some(property_name) = WINDOW_EVENT_HANDLER_PROPERTIES
.iter()
.copied()
.find(|candidate| *candidate == property_name)
else {
return;
};
let value = handler
.map(v8::Local::<v8::Value>::from)
.unwrap_or_else(|| v8::null(scope).into());
set_lightweight_popup_event_handler_value(scope, window, property_name, value);
}
pub(in crate::native_bridge::context_host) fn lightweight_popup_opener_endpoint(
&self,
popup_id: u64,
@@ -5545,11 +5582,21 @@ fn lightweight_popup_event_handler_getter<'s>(
rv.set_null();
return;
};
rv.set(
get_private_value(scope, args.this(), property_name)
.filter(|value| value.is_object())
.unwrap_or_else(|| v8::null(scope).into()),
);
rv.set(lightweight_popup_event_handler_value(
scope,
args.this(),
property_name,
));
}
fn lightweight_popup_event_handler_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
property_name: &str,
) -> v8::Local<'s, v8::Value> {
get_private_value(scope, window, property_name)
.filter(|value| value.is_object())
.unwrap_or_else(|| v8::null(scope).into())
}
fn lightweight_popup_event_handler_setter<'s>(
@@ -5565,22 +5612,30 @@ fn lightweight_popup_event_handler_setter<'s>(
rv.set_undefined();
return;
};
let value = args.get(0);
set_lightweight_popup_event_handler_value(scope, args.this(), property_name, args.get(0));
rv.set_undefined();
}
fn set_lightweight_popup_event_handler_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
property_name: &'static str,
value: v8::Local<'s, v8::Value>,
) {
let stored = if value.is_object() {
value
} else {
v8::null(scope).into()
};
set_private_value(scope, args.this(), property_name, stored);
set_private_value(scope, window, property_name, stored);
simple_object_event_set_ordered_handler(
scope,
args.this(),
window,
LIGHTWEIGHT_POPUP_EVENT_LISTENERS_SLOT,
property_name.strip_prefix("on").unwrap_or(property_name),
property_name,
stored.is_object(),
);
rv.set_undefined();
}
fn clear_lightweight_popup_window_document_event_state<'s>(
@@ -5596,7 +5651,9 @@ fn clear_lightweight_popup_window_document_event_state<'s>(
);
let null = v8::null(scope).into();
for name in WINDOW_EVENT_HANDLER_PROPERTIES {
let _ = window.set(scope, v8str(scope, name).into(), null);
// Reset the shared handler state even if script has replaced the
// public accessor. Document retirement must not invoke author setters.
set_private_value(scope, window, name, null);
}
}
@@ -74,7 +74,9 @@ fn body_window_event_handler_getter_function<'s>(
}
Some(OwnerDispatchScope::Child(child_handle)) => unsafe { &mut *runtime_ptr }
.child_window_event_handler_property_value(scope, child_handle, &handler_name),
Some(OwnerDispatchScope::LightweightPopup(_)) | None => None,
Some(OwnerDispatchScope::LightweightPopup(popup_id)) => unsafe { &*runtime_ptr }
.lightweight_popup_event_handler_property_value(scope, popup_id, &handler_name),
None => None,
};
match value {
Some(value) => rv.set(value),
@@ -122,7 +124,9 @@ fn body_window_event_handler_setter_function<'s>(
relevant_context,
);
}
Some(OwnerDispatchScope::LightweightPopup(_)) | None => {}
Some(OwnerDispatchScope::LightweightPopup(popup_id)) => unsafe { &mut *runtime_ptr }
.set_lightweight_popup_event_handler_property(scope, popup_id, &handler_name, handler),
None => {}
}
rv.set_undefined();
}
@@ -34,11 +34,7 @@ pub(super) fn body_or_frameset_window_owner(
if !is_body_or_frameset_element(runtime, handle) {
return None;
}
match runtime.owner_dispatch_scope_for_node(handle)? {
owner @ (crate::native_bridge::OwnerDispatchScope::Top
| crate::native_bridge::OwnerDispatchScope::Child(_)) => Some(owner),
crate::native_bridge::OwnerDispatchScope::LightweightPopup(_) => None,
}
runtime.owner_dispatch_scope_for_node(handle)
}
pub(crate) fn body_or_frameset_uses_runtime_window(
@@ -1,5 +1,153 @@
use super::*;
#[test]
fn popup_body_and_frameset_idl_handlers_share_their_window_handler_state() {
let mut vm = new_storage_test_vm("https://popup-body-window-handlers.test/");
let result = vm
.eval(
r#"
(() => {
const popup = open();
try {
const names = ['onblur','onerror','onfocus','onload','onresize','onscroll',
'onafterprint','onbeforeprint','onbeforeunload','onhashchange','onlanguagechange',
'onmessage','onmessageerror','onoffline','ononline','onpagehide','onpagereveal',
'onpageshow','onpageswap','onpopstate','onrejectionhandled','onstorage',
'onunhandledrejection','onunload'];
const body = popup.document.body;
const detachedBody = popup.document.createElement('body');
const frameset = popup.document.createElement('frameset');
const aliases = [popup, body, detachedBody, frameset];
const failures = [];
for (const name of names) {
for (const writer of aliases) {
const handler = function() {};
writer[name] = handler;
if (!aliases.every(alias => alias[name] === handler)) failures.push(name + ':function');
const objectHandler = {};
writer[name] = objectHandler;
if (!aliases.every(alias => alias[name] === objectHandler)) failures.push(name + ':object');
for (const value of [null, undefined, false, 1, '', Symbol(), 1n]) {
writer[name] = handler;
writer[name] = value;
if (!aliases.every(alias => alias[name] === null)) failures.push(name + ':null');
}
if (window[name] !== null) failures.push(name + ':opener');
}
}
return JSON.stringify(failures);
} finally { popup.close(); }
})()
"#,
)
.expect("popup body and frameset IDL aliases should evaluate");
assert_eq!(result, "[]");
}
#[test]
fn popup_body_idl_handlers_preserve_listener_order_replacement_and_cancellation() {
let mut vm = new_storage_test_vm("https://popup-body-handler-order.test/");
let result = vm.eval(r#"
(() => {
const popup = open();
try {
const body = popup.document.body;
const frameset = popup.document.createElement('frameset');
const trace = [];
let count = 0;
popup.addEventListener('resize', () => {
trace.push('before:' + ++count);
if (count === 1) frameset.onresize = function(event) {
trace.push('replacement:' + (this === popup && event.currentTarget === popup));
return false;
};
if (count === 2) {
body.onresize = null;
body.onresize = () => trace.push('readded');
}
});
body.onresize = () => trace.push('stale');
popup.addEventListener('resize', () => trace.push('after:' + count));
for (let n = 0; n < 3; ++n) {
trace.push('result:' + popup.dispatchEvent(new Event('resize', {cancelable:true})));
}
const errors = [];
const error = new Error('popup');
body.onerror = function(message, source, line, column, value) {
errors.push([this === popup, arguments.length, message, source, line, column, value === error]);
return true;
};
const errorCanceled = !popup.dispatchEvent(new ErrorEvent('error', {
message:'message', filename:'source', lineno:3, colno:4, error, cancelable:true
}));
return JSON.stringify({trace, errors, errorCanceled});
} finally { popup.close(); }
})()
"#).expect("popup body handler dispatch should evaluate");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!({
"trace":["before:1", "replacement:true", "after:1", "result:false",
"before:2", "after:2", "result:true", "before:3", "after:3",
"readded", "result:true"],
"errors":[[true, 5, "message", "source", 3, 4, true]],
"errorCanceled":true,
})
);
}
#[test]
fn popup_body_idl_handlers_follow_document_ownership_and_ignore_window_expandos() {
let mut vm = new_storage_test_vm("https://popup-body-handler-owner.test/");
let result = vm
.eval(
r#"
(() => {
const popup = open('about:blank', 'body-handler-owner');
try {
const oldDocument = popup.document;
const body = oldDocument.body;
const frameset = oldDocument.createElement('frameset');
const first = function() {};
const second = function() {};
body.onresize = first;
oldDocument.documentElement.replaceChild(oldDocument.createElement('body'), body);
const replacement = oldDocument.body.onresize === first && body.onresize === first;
let traps = 0;
Object.defineProperty(popup, 'onresize', {
configurable:true, get() { ++traps; return second; }, set() { ++traps; }
});
frameset.onresize = second;
const ignoresExpando = body.onresize === second && traps === 0;
window.onresize = first;
document.adoptNode(frameset);
const adoption = frameset.onresize === first && body.onresize === second;
frameset.onresize = null;
const independent = window.onresize === null && body.onresize === second;
const windowless = document.implementation.createHTMLDocument('');
windowless.adoptNode(frameset);
frameset.onresize = first;
const noWindow = frameset.onresize === null;
open('about:blank', 'body-handler-owner');
body.onresize = first;
const retired = popup.document !== oldDocument && body.onresize === null &&
popup.document.body.onresize === null;
return JSON.stringify({replacement, ignoresExpando, adoption, independent, noWindow, retired,
cleanupSkippedExpando:traps === 0});
} finally { popup.close(); }
})()
"#,
)
.expect("popup body handler ownership should evaluate");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!({
"replacement":true, "ignoresExpando":true, "adoption":true,
"independent":true, "noWindow":true, "retired":true, "cleanupSkippedExpando":true,
})
);
}
#[test]
fn popup_dom_events_propagate_through_their_window_without_reaching_the_opener() {
let mut vm = new_storage_test_vm("https://popup-event-path.test/");