fix(events): implement beforeunload return semantics

This commit is contained in:
ldm0
2026-10-02 03:27:49 +08:00
parent 933a5b361e
commit 9283dfd2dc
11 changed files with 420 additions and 21 deletions
+1 -1
View File
@@ -259,7 +259,7 @@ pub(crate) use self::dom_rect::build_dom_rect_object;
pub(crate) use self::dom_rect_list::{build_dom_rect_list_object};
pub(crate) use self::events::{
EVENT_DISPATCHING_SLOT, EVENT_PASSIVE_SLOT, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT,
EVENT_STOP_PROPAGATION_SLOT, EventHandlerType, apply_event_handler_return_value, clear_event_composed_path, event_attribute, event_backing,
EVENT_STOP_PROPAGATION_SLOT, EventHandlerType, apply_before_unload_event_handler_return_value, apply_event_handler_return_value, clear_event_composed_path, event_attribute, event_backing,
event_bool_attribute, event_initialized, event_internal_bool_flag, event_is_dispatching,
event_is_error_event, event_is_mouse_event, event_private_value, initialize_event_object,
initialize_event_wrapper, mark_event_trusted, new_event_state, new_event_wrapper,
@@ -1,4 +1,6 @@
use super::events::{new_uninitialized_text_event, set_event_initialized};
use super::events::{
new_uninitialized_before_unload_event, new_uninitialized_text_event, set_event_initialized,
};
use super::*;
use crate::webidl;
use std::str::FromStr;
@@ -42,7 +44,7 @@ enum DocumentCreateEventKind {
impl DocumentCreateEventKind {
fn constructor_name(self) -> &'static str {
match self {
DocumentCreateEventKind::BeforeUnloadEvent => "Event",
DocumentCreateEventKind::BeforeUnloadEvent => "BeforeUnloadEvent",
DocumentCreateEventKind::Event => "Event",
DocumentCreateEventKind::CustomEvent => "CustomEvent",
DocumentCreateEventKind::DeviceMotionEvent => "DeviceMotionEvent",
@@ -73,8 +75,12 @@ fn new_uninitialized_document_event<'s>(
let name = kind.constructor_name();
let constructor =
super::exposed_interfaces::ensure_intrinsic_interface_constructor(scope, name).ok()?;
if kind == DocumentCreateEventKind::TextEvent {
return new_uninitialized_text_event(scope);
match kind {
DocumentCreateEventKind::BeforeUnloadEvent => {
return new_uninitialized_before_unload_event(scope);
}
DocumentCreateEventKind::TextEvent => return new_uninitialized_text_event(scope),
_ => {}
}
let empty_type = v8str(scope, "");
let event = constructor.new_instance(scope, &[empty_type.into()])?;
@@ -7,16 +7,18 @@ use super::{
ui_event_init_callback,
},
events::{
close_event_code_getter_function, close_event_reason_getter_function,
close_event_was_clean_getter_function, event_bubbles_getter_function,
event_cancel_bubble_getter_function, event_cancel_bubble_setter_function,
event_cancelable_getter_function, event_composed_getter_function,
event_composed_path_callback, event_current_target_getter_function,
event_default_prevented_getter_function, event_event_phase_getter_function,
event_prevent_default_callback, event_return_value_getter_function,
event_return_value_setter_function, event_src_element_getter_function,
event_stop_immediate_propagation_callback, event_stop_propagation_callback,
event_target_getter_function, event_time_stamp_getter_function, event_type_getter_function,
before_unload_event_return_value_getter_function,
before_unload_event_return_value_setter_function, close_event_code_getter_function,
close_event_reason_getter_function, close_event_was_clean_getter_function,
event_bubbles_getter_function, event_cancel_bubble_getter_function,
event_cancel_bubble_setter_function, event_cancelable_getter_function,
event_composed_getter_function, event_composed_path_callback,
event_current_target_getter_function, event_default_prevented_getter_function,
event_event_phase_getter_function, event_prevent_default_callback,
event_return_value_getter_function, event_return_value_setter_function,
event_src_element_getter_function, event_stop_immediate_propagation_callback,
event_stop_propagation_callback, event_target_getter_function,
event_time_stamp_getter_function, event_type_getter_function,
focus_event_related_target_getter_function, form_data_event_form_data_getter_function,
mouse_event_related_target_getter_function, pointer_event_get_predicted_events_callback,
submit_event_submitter_getter_function, track_event_track_getter_function,
@@ -130,6 +132,17 @@ struct EventBaseTemplateMethodsDeclaration {
init_event: (),
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::BeforeUnloadEvent, enumerable)]
struct BeforeUnloadEventTemplateAccessorsDeclaration {
#[webapi(
accessor_property = "returnValue",
getter = before_unload_event_return_value_getter_function,
setter = before_unload_event_return_value_setter_function
)]
return_value: (),
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::CloseEvent, enumerable, receiver)]
struct CloseEventTemplateAccessorsDeclaration {
@@ -339,6 +352,12 @@ pub(super) fn install_event_template_bindings<'s>(
super::events::install_device_event_template_bindings(scope, template, spec.interface.name());
match spec.interface.name() {
"BeforeUnloadEvent" => {
let proto = template.prototype_template(scope);
BeforeUnloadEventTemplateAccessorsDeclaration::initialize_prototype_template(
scope, proto,
);
}
"UIEvent" => {
let proto = template.prototype_template(scope);
UiEventTemplateMethodsDeclaration::initialize_prototype_template(scope, proto);
@@ -29,6 +29,7 @@ const SUBMIT_EVENT_SUBMITTER_SLOT: &str = "__moliSubmitEventSubmitter";
const FORM_DATA_EVENT_FORM_DATA_SLOT: &str = "__moliFormDataEventFormData";
const TRACK_EVENT_TRACK_SLOT: &str = "__moliTrackEventTrack";
const EVENT_SUBCLASS_KIND_SLOT: &str = "__moliEventSubclassKind";
const BEFORE_UNLOAD_EVENT_RETURN_VALUE_SLOT: &str = "__moliBeforeUnloadEventReturnValue";
#[derive(WebApiObject)]
#[webapi(plain)]
struct PageTransitionEventInitDeclaration {
@@ -82,6 +83,102 @@ pub(crate) fn construct_original_event<'s>(
Some(event)
}
fn new_before_unload_event<'s>(
scope: &mut v8::PinScope<'s, '_>,
event_type: &str,
cancelable: bool,
) -> Option<v8::Local<'s, v8::Object>> {
let prototype = global_constructor_prototype(scope, "BeforeUnloadEvent")?;
let event = v8::Object::new(scope);
if event.set_prototype(scope, prototype.into()) != Some(true) {
return None;
}
base::initialize_event_object(scope, event, event_type, false, cancelable);
set_private_value(
scope,
event,
BEFORE_UNLOAD_EVENT_RETURN_VALUE_SLOT,
v8str(scope, "").into(),
);
Some(event)
}
pub(crate) fn construct_original_before_unload_event<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> Option<v8::Local<'s, v8::Object>> {
new_before_unload_event(scope, "beforeunload", true)
}
pub(in crate::context_bootstrap) fn new_uninitialized_before_unload_event<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> Option<v8::Local<'s, v8::Object>> {
let event = new_before_unload_event(scope, "", false)?;
base::set_event_initialized(scope, event, false);
Some(event)
}
fn before_unload_event_return_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::String>> {
get_private_value(scope, event, BEFORE_UNLOAD_EVENT_RETURN_VALUE_SLOT)
.and_then(|value| v8::Local::<v8::String>::try_from(value).ok())
}
pub(crate) fn event_is_before_unload_event<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
) -> bool {
before_unload_event_return_value(scope, event).is_some()
}
pub(crate) fn set_before_unload_event_return_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
value: v8::Local<'s, v8::String>,
) {
set_private_value(
scope,
event,
BEFORE_UNLOAD_EVENT_RETURN_VALUE_SLOT,
value.into(),
);
}
pub(crate) fn before_unload_event_return_value_is_empty<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
) -> bool {
before_unload_event_return_value(scope, event).is_some_and(|value| value.length() == 0)
}
pub(super) fn before_unload_event_return_value_getter_function<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(value) = before_unload_event_return_value(scope, args.this()) else {
throw_type_error(scope, "Illegal invocation");
return;
};
rv.set(value.into());
}
pub(super) fn before_unload_event_return_value_setter_function<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
if !event_is_before_unload_event(scope, args.this()) {
throw_type_error(scope, "Illegal invocation");
return;
}
let Some(value) = args.get(0).to_string(scope) else {
return;
};
set_before_unload_event_return_value(scope, args.this(), value);
}
pub(in crate::context_bootstrap) fn new_uninitialized_text_event<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> Option<v8::Local<'s, v8::Object>> {
@@ -367,7 +464,10 @@ pub(super) use base::{
};
pub(in crate::context_bootstrap) use init::parse_event_init;
pub(super) use kind::EventSubclassKind;
pub(crate) use methods::{EventHandlerType, apply_event_handler_return_value};
pub(crate) use methods::{
EventHandlerType, apply_before_unload_event_handler_return_value,
apply_event_handler_return_value,
};
pub(super) use methods::{
event_cancel_bubble_getter_function, event_cancel_bubble_setter_function,
event_composed_path_callback, event_prevent_default_callback,
@@ -43,6 +43,20 @@ pub(crate) fn apply_event_handler_return_value<'s>(
}
}
pub(crate) fn apply_before_unload_event_handler_return_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
returned: v8::Local<'s, v8::String>,
) {
if !event_is_before_unload_event(scope, event) {
return;
}
set_event_default_prevented(scope, event);
if before_unload_event_return_value_is_empty(scope, event) {
set_before_unload_event_return_value(scope, event, returned);
}
}
pub(in crate::context_bootstrap) fn event_prevent_default_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
@@ -1,4 +1,7 @@
use super::events::{construct_original_event, run_navigate_event_precommit_handlers};
use super::events::{
construct_original_before_unload_event, construct_original_event,
run_navigate_event_precommit_handlers,
};
use super::location_history_storage::{
NAVIGATION_ENTRY_EVENT_LISTENERS_SLOT, NAVIGATION_EVENT_LISTENERS_SLOT,
};
@@ -630,7 +633,7 @@ pub(crate) fn dispatch_beforeunload_for_runtime_owner<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
) {
let Some(event) = construct_original_event(scope, "beforeunload") else {
let Some(event) = construct_original_before_unload_event(scope) else {
return;
};
dispatch_unload_lifecycle_event_for_runtime_owner(scope, owner, "beforeunload", event);
+2 -1
View File
@@ -32,7 +32,8 @@ pub(super) use self::events::{
DispatchStatus, HostEventTargetRegistry, PublicEventDispatchResult, create_host_event,
dispatch_host_event, dispatch_public_event, dispatch_public_event_with_original_target,
event_dispatch_status, event_handler_content_attribute_owner, event_target_value,
host_event_defaults, invoke_prepared_event_callback,
host_event_defaults, invoke_prepared_before_unload_event_handler,
invoke_prepared_event_callback,
report_event_callback_exception, report_event_listener_exception,
};
pub(crate) use self::events::{EventListenerInspectorSnapshot, EventListenerRegistration};
+118 -1
View File
@@ -1,7 +1,8 @@
use super::*;
use crate::{
context_bootstrap::{
CHILD_BROWSING_CONTEXT_HANDLE_SLOT, EventHandlerType, apply_event_handler_return_value,
CHILD_BROWSING_CONTEXT_HANDLE_SLOT, EventHandlerType,
apply_before_unload_event_handler_return_value, apply_event_handler_return_value,
clear_event_composed_path, event_is_error_event, mark_event_trusted,
set_event_composed_path,
},
@@ -298,6 +299,98 @@ fn invoke_prepared_event_callback_with_receiver<'s>(
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn invoke_prepared_before_unload_event_handler<'s>(
scope: &mut v8::PinScope<'s, '_>,
host_ptr: *mut JsContextHost,
target: EventTargetHandle,
invocation_target_in_shadow_tree: bool,
event_type: &str,
callback_name: &str,
callback: crate::native_bridge::PreparedEventCallback,
event: v8::Local<'s, v8::Object>,
) {
let receiver = event_target_receiver(scope, host_ptr, target, event);
let _dom_debugger_pause = unsafe { &*host_ptr }
.schedule_dom_debugger_event_listener_pause_for_target(event_type, target);
let relevant_identity = callback.relevant_identity();
let arguments = [event.into()];
let invocation = CallbackInvocation::new(
callback.callback(scope),
receiver,
callback.relevant_context(scope),
callback.incumbent_context(scope),
callback.is_callable(),
"handleEvent",
&arguments,
(!invocation_target_in_shadow_tree).then_some(event),
)
.with_execution_context_currentness(host_ptr, relevant_identity);
CallbackInvoker::invoke_event_and_then(
scope,
"event listener",
"host event listener threw",
crate::exception_reporting::CallbackExceptionLogLevel::Debug,
callback_name,
invocation,
|scope, outcome| match outcome {
CallbackInvocationOutcome::Returned(value) => {
let value = v8::Local::new(scope, value);
if value.is_null_or_undefined() {
return;
}
let conversion_report = {
let try_catch = std::pin::pin!(v8::TryCatch::new(scope));
let mut conversion_scope = try_catch.init();
match value.to_string(&conversion_scope) {
Some(value) => {
apply_before_unload_event_handler_return_value(
&mut conversion_scope,
event,
value,
);
None
}
None if conversion_scope.has_caught() => {
let exception = conversion_scope.exception();
let message = conversion_scope.message();
let stack_trace = conversion_scope.stack_trace();
Some(build_event_handler_exception_report(
&mut conversion_scope,
exception,
message,
stack_trace,
))
}
None => None,
}
};
if let Some(report) = conversion_report {
report_event_callback_exception(
scope,
host_ptr,
event_type,
relevant_identity,
None,
&report,
);
}
}
CallbackInvocationOutcome::Threw(report) => {
report_event_callback_exception(
scope,
host_ptr,
event_type,
relevant_identity,
None,
&report,
);
}
CallbackInvocationOutcome::Retired => {}
},
);
}
fn invoke_event_handler_property<'s>(
registry: &mut HostEventTargetRegistry,
scope: &mut v8::PinScope<'s, '_>,
@@ -445,6 +538,30 @@ fn invoke_registered_event_handler<'s>(
return;
}
if event_type == "beforeunload" {
invoke_prepared_before_unload_event_handler(
scope,
host_ptr,
target,
invocation_target_in_shadow_tree,
event_type,
&handler_name,
prepared,
event,
);
if let Some(timing_started) = timing_started {
tracing::info!(
target: "moli_cdp_nav_timing",
stage = "event_handler_property_invoked",
event_type,
handler_name,
?target,
elapsed_ms = timing_started.elapsed().as_millis(),
);
}
return;
}
let returned = invoke_prepared_event_callback(
scope,
host_ptr,
@@ -8,7 +8,7 @@ use crate::{
frame_owner_model::{FrameDocumentTaskOwner, LocalWindowId},
host::{
ChildWindowEventTarget, DispatchStatus, create_host_event, event_dispatch_status,
invoke_prepared_event_callback,
invoke_prepared_before_unload_event_handler, invoke_prepared_event_callback,
},
native_bridge::{
ACTIVE_CHILD_WINDOW_HANDLE_SLOT, EventCallbackId, PreparedEventCallback,
@@ -626,6 +626,21 @@ impl JsContextHost {
if !self.child_window_event_target_is_current(ready.target) {
return (false, None);
}
if ready.registration_kind == ChildWindowEventRegistrationKind::EventHandlerProperty
&& event_type == "beforeunload"
{
invoke_prepared_before_unload_event_handler(
scope,
self as *mut JsContextHost,
EventTargetHandle::ChildWindow(ready.target),
false,
event_type,
&format!("child window {event_type} listener"),
ready.callback,
event,
);
return (true, None);
}
let arguments = child_window_event_callback_arguments(
scope,
ready.registration_kind,
@@ -1676,3 +1676,74 @@ fn selection_endpoints_follow_associated_live_range_mutations() {
""
);
}
#[test]
fn before_unload_event_uses_its_string_return_value_interface() {
let mut vm = new_storage_test_vm("https://before-unload-event-interface.test/");
let result = vm
.eval(
r#"
(() => {
const errorName = callback => {
try {
callback();
return "none";
} catch (error) {
return error.name;
}
};
const descriptor = Object.getOwnPropertyDescriptor(
BeforeUnloadEvent.prototype,
"returnValue"
);
const event = document.createEvent("BeforeUnloadEvent");
const initial = [
event instanceof BeforeUnloadEvent,
event instanceof Event,
Object.hasOwn(event, "returnValue"),
event.returnValue
];
event.initEvent("beforeunload", false, true);
event.returnValue = null;
const nullValue = event.returnValue;
event.returnValue = undefined;
const undefinedValue = event.returnValue;
event.returnValue = { toString() { return "object value"; } };
const objectValue = event.returnValue;
const symbolError = errorName(() => {
event.returnValue = Symbol("return value");
});
return JSON.stringify({
constructor: [
typeof BeforeUnloadEvent,
Object.getPrototypeOf(BeforeUnloadEvent.prototype) === Event.prototype,
errorName(() => new BeforeUnloadEvent())
],
initial,
initialized: [event.type, event.cancelable],
values: [nullValue, undefinedValue, objectValue, symbolError, event.returnValue],
metadata: [
descriptor.get.name,
descriptor.get.length,
descriptor.set.name,
descriptor.set.length,
descriptor.enumerable,
descriptor.configurable
],
receiverErrors: [
errorName(() => descriptor.get.call(new Event("plain"))),
errorName(() => descriptor.set.call({}, "value"))
]
});
})()
"#,
)
.expect("BeforeUnloadEvent returnValue interface probe should evaluate");
assert_eq!(
result,
r#"{"constructor":["function",true,"TypeError"],"initial":[true,true,false,""],"initialized":["beforeunload",true],"values":["null","undefined","object value","TypeError","object value"],"metadata":["get returnValue",0,"set returnValue",1,true,true],"receiverErrors":["TypeError","TypeError"]}"#
);
}
@@ -175,6 +175,59 @@ fn cross_document_unload_lifecycle_orders_pagehide_before_unload_without_timer()
);
}
#[test]
fn before_unload_handler_coerces_its_result_while_window_event_is_current() {
let mut vm = new_storage_test_vm("https://example.com/base");
let result = vm
.eval(
r##"
(() => {
let customCurrent = false;
onbeforeunload = event => ({
toString() {
customCurrent = window.event === event;
return "custom";
}
});
const custom = new CustomEvent("beforeunload", { cancelable: true });
const customDispatchResult = dispatchEvent(custom);
let realEvent;
const realSteps = [];
onbeforeunload = event => {
realEvent = event;
realSteps.push([
event instanceof BeforeUnloadEvent,
event.cancelable,
event.returnValue,
window.event === event
]);
return {
toString() {
realSteps.push(["coerce", window.event === event]);
return "leave";
}
};
};
navigation.navigate("/next-document");
return JSON.stringify({
custom: [customCurrent, custom.defaultPrevented, customDispatchResult],
realSteps,
real: [realEvent.defaultPrevented, realEvent.returnValue]
});
})()
"##,
)
.expect("beforeunload handler return processing should evaluate");
assert_eq!(
result,
r#"{"custom":[true,false,true],"realSteps":[[true,true,"",true],["coerce",true]],"real":[true,"leave"]}"#
);
}
#[test]
fn navigate_event_intercept_option_stringification_preserves_thrown_exception() {
let mut vm = new_storage_test_vm("https://example.com/base");