diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 8891569b5e..e9d42543bf 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -276,11 +276,11 @@ pub(crate) use self::events::{ EVENT_DISPATCHING_SLOT, EVENT_PASSIVE_SLOT, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, EVENT_STOP_PROPAGATION_SLOT, clear_event_composed_path, construct_original_error_event, construct_original_message_event, 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_agent_submit_event, mark_event_trusted, new_event_state, new_event_wrapper, - set_event_composed_path, set_event_internal_flag, set_event_private_value, set_event_trusted, - submit_event_submitter_value, window_event_callback_values, + event_callback_values, 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_agent_submit_event, mark_event_trusted, new_event_state, + new_event_wrapper, set_event_composed_path, set_event_internal_flag, set_event_private_value, + set_event_trusted, submit_event_submitter_value, }; pub(crate) use self::file_api::{ DataTransferStringCallbackTask, DataTransferStringCallbackTaskEffect, diff --git a/moli-renderer-v8/src/context_bootstrap/events.rs b/moli-renderer-v8/src/context_bootstrap/events.rs index 20029ef31d..33484e5d72 100644 --- a/moli-renderer-v8/src/context_bootstrap/events.rs +++ b/moli-renderer-v8/src/context_bootstrap/events.rs @@ -496,8 +496,8 @@ pub(super) fn track_event_track_getter_function<'s>( } pub(crate) use wrappers::{ - event_attribute, event_bool_attribute, event_private_value, initialize_event_wrapper, - new_event_state, new_event_wrapper, set_event_private_value, window_event_callback_values, + event_attribute, event_bool_attribute, event_callback_values, event_private_value, + initialize_event_wrapper, new_event_state, new_event_wrapper, set_event_private_value, }; pub(in crate::context_bootstrap) use message::message_event_init_callback; diff --git a/moli-renderer-v8/src/context_bootstrap/events/wrappers.rs b/moli-renderer-v8/src/context_bootstrap/events/wrappers.rs index 234413262e..ad9c066760 100644 --- a/moli-renderer-v8/src/context_bootstrap/events/wrappers.rs +++ b/moli-renderer-v8/src/context_bootstrap/events/wrappers.rs @@ -153,12 +153,12 @@ pub(crate) fn new_event_wrapper<'s>( Some(wrapper) } -/// Project another Realm of the same live Window through the existing weak -/// Event cache. The dispatch state stays shared and same-world identity stays -/// intact. Foreign-Window callbacks retain their existing behavior: resolving -/// an isolated world of another target Window requires an alias/world resolver -/// that this path does not yet provide. -pub(crate) fn window_event_callback_values<'s>( +/// Project DOM and Window listeners of the same live Window through their +/// existing world caches. The dispatch state stays shared and same-world +/// identity stays intact. Foreign-Window callbacks retain their existing +/// behavior: resolving an isolated world of another target Window requires an +/// alias/world resolver that this path does not yet provide. +pub(crate) fn event_callback_values<'s>( scope: &mut v8::PinScope<'s, '_>, receiver: v8::Local<'s, v8::Value>, event: v8::Local<'s, v8::Object>, @@ -167,9 +167,10 @@ pub(crate) fn window_event_callback_values<'s>( let Ok(target) = v8::Local::::try_from(receiver) else { return Some((receiver, event)); }; - let Some(target) = - super::super::shared_event_targets::window_target_in_realm(scope, target, context) - else { + let Some(target) = super::super::shared_event_targets::window_target_in_realm( + scope, target, context, + ) + .or_else(|| super::super::shared_event_targets::node_target_in_realm(scope, target, context)) else { return Some((receiver, event)); }; if world_wrappers::belongs_to_world(scope, event, context) { diff --git a/moli-renderer-v8/src/context_bootstrap/shared_event_targets.rs b/moli-renderer-v8/src/context_bootstrap/shared_event_targets.rs index 2a696f2a94..cc605b1ec2 100644 --- a/moli-renderer-v8/src/context_bootstrap/shared_event_targets.rs +++ b/moli-renderer-v8/src/context_bootstrap/shared_event_targets.rs @@ -63,6 +63,9 @@ pub(super) fn target_in_realm<'s>( if let Some(window) = window_target_in_realm(scope, target, context) { return window; } + if let Some(node) = node_target_in_realm(scope, target, context) { + return node; + } let Some(owner) = get_private_object(scope, target, OWNER) else { return target; }; @@ -119,6 +122,35 @@ pub(super) fn window_target_in_realm<'s>( Some(context.global(scope)) } +/// Select the listener world's wrapper only for a Node owned by this live +/// Window. A wrapper's creation Context is not its native ownerDocument: +/// adoption and borrowed callbacks can make those belong to different Windows. +pub(super) fn node_target_in_realm<'s>( + scope: &mut v8::PinScope<'s, '_>, + target: v8::Local<'s, v8::Object>, + context: v8::Local<'s, v8::Context>, +) -> Option> { + if !crate::web_api_interfaces::Node::is_instance(scope, target) { + return None; + } + let (host_ptr, handle) = + crate::native_bridge::node_runtime_and_handle_from_object_or_detached(scope, target) + .ok()?; + if crate::util::context_host_ptr_from_context_slot(context) != Some(host_ptr) { + return None; + } + let host = unsafe { &*host_ptr }; + let identity = host.window_execution_context_identity_for_access_check(context)?; + let owner_scope = host.owner_dispatch_scope_for_node(handle)?; + if identity.dispatch_scope() != owner_scope + || !host.window_execution_context_identity_is_current(identity) + { + return None; + } + super::platform_object_worlds::in_realm(scope, target.into(), context) + .and_then(|value| v8::Local::::try_from(value).ok()) +} + pub(super) fn install_handlers<'s>( scope: &mut v8::PinScope<'s, '_>, target: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/host/events.rs b/moli-renderer-v8/src/host/events.rs index a938f4059e..9b4026f34a 100644 --- a/moli-renderer-v8/src/host/events.rs +++ b/moli-renderer-v8/src/host/events.rs @@ -156,15 +156,9 @@ pub(crate) fn invoke_prepared_event_callback<'s>( ) -> Option> { let receiver = event_target_receiver(scope, host_ptr, target, event); let has_event_argument = arguments.len() == 1 && arguments[0].strict_equals(event.into()); - let (receiver, event) = if matches!( - target, - EventTargetHandle::Window | EventTargetHandle::ChildWindow(_) - ) { - let context = callback.relevant_context(scope); - crate::context_bootstrap::window_event_callback_values(scope, receiver, event, context)? - } else { - (receiver, event) - }; + let context = callback.relevant_context(scope); + let (receiver, event) = + crate::context_bootstrap::event_callback_values(scope, receiver, event, context)?; // Window.onerror's five arguments are JS values, not an Event conversion. let event_arguments = [event.into()]; let arguments = if has_event_argument { diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 1fabdf2823..77e2421c65 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2197,6 +2197,7 @@ mod mouse_snapshot; mod native_bridge_identity; mod native_exception_stack_capture; mod no_cors_header_fill; +mod node_event_worlds; mod observer_callbacks; mod observer_receivers; mod offline_audio_float; diff --git a/moli-renderer-v8/src/script_vm/tests/node_event_worlds.rs b/moli-renderer-v8/src/script_vm/tests/node_event_worlds.rs new file mode 100644 index 0000000000..4776a2d79a --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/node_event_worlds.rs @@ -0,0 +1,271 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn node_event_callbacks_project_nodes_and_event_state_in_both_layout_modes() { + for policy in [ + moli_page_types::LayoutPolicy::Mock, + moli_page_types::LayoutPolicy::OnDemand, + ] { + let mut vm = new_storage_test_vm("https://node-event-worlds.test/"); + vm.set_layout_policy(policy); + vm.eval( + r#" + globalThis.button = document.createElement('button'); + button.id = 'probe'; + button.textContent = 'text'; + (document.body || document.documentElement || document).appendChild(button); + globalThis.nodes = [button, button.firstChild, document]; + globalThis.defaultFacts = []; + globalThis.originals = []; + globalThis.payload = {token: 'payload'}; + nodes.forEach((node, index) => { + node.defaultNodeOnly = true; + node.addEventListener('node-world-' + index, function(event) { + defaultFacts.push([ + this === node, event === originals[index], event instanceof CustomEvent, + event.target === node, event.currentTarget === node, + event.srcElement === node, event.composedPath()[0] === node + ]); + }); + }); + "#, + ) + .unwrap(); + let isolated = vm + .create_isolated_world("node-event-listeners", false) + .unwrap(); + vm.eval_in_isolated_context(isolated, r#" + globalThis.nodes = [document.getElementById('probe'), document.getElementById('probe').firstChild, document]; + globalThis.facts = []; + globalThis.objectFacts = []; + globalThis.savedEvents = []; + nodes.forEach((node, index) => { + node.isolatedNodeOnly = true; + node.addEventListener('node-world-' + index, function(event) { + savedEvents[index] = event; + facts.push([ + this === node, event instanceof CustomEvent, + event.target === node, event.currentTarget === node, + event.srcElement === node, event.composedPath()[0] === node, + window.event === event, event.defaultOnly === undefined, + event.target.defaultNodeOnly === undefined, event.detail.token === 'payload' + ]); + event.preventDefault(); + }); + const listener = {handleEvent(event) { + objectFacts.push([this === listener, event === savedEvents[index], + event.defaultPrevented, event.currentTarget === node]); + }}; + node.addEventListener('node-world-' + index, listener); + }); + "#).unwrap(); + let builds = vm.layout_pass_observability_for_test().1; + let result = vm.eval(r#" + JSON.stringify(nodes.map((node, index) => { + const event = new CustomEvent('node-world-' + index, {cancelable: true, detail: payload}); + event.defaultOnly = true; + originals[index] = event; + return [node.dispatchEvent(event) === false, event.defaultPrevented, + event.detail === payload, + event.currentTarget === null, event.composedPath().length === 0, + node.isolatedNodeOnly === undefined]; + })); + "#).unwrap(); + assert_eq!( + serde_json::from_str::(&result).unwrap(), + serde_json::json!(vec![vec![true; 6]; 3]), + "shared dispatch state and arbitrary JS payloads: {policy:?}" + ); + assert_eq!( + serde_json::from_str::( + &vm.eval("JSON.stringify(defaultFacts)").unwrap() + ) + .unwrap(), + serde_json::json!(vec![vec![true; 7]; 3]), + "default wrappers: {policy:?}" + ); + for (expression, expected) in [ + ( + "JSON.stringify(facts)", + serde_json::json!(vec![vec![true; 10]; 3]), + ), + ( + "JSON.stringify(objectFacts)", + serde_json::json!(vec![vec![true; 4]; 3]), + ), + ( + "JSON.stringify(savedEvents.map(event => [event.currentTarget === null, event.composedPath().length === 0, event.defaultPrevented]))", + serde_json::json!(vec![vec![true; 3]; 3]), + ), + ] { + assert_eq!( + serde_json::from_str::( + &vm.eval_in_isolated_context(isolated, expression).unwrap() + ) + .unwrap(), + expected, + "{expression}: {policy:?}" + ); + } + assert_eq!( + vm.layout_pass_observability_for_test().1, + builds, + "Node event projection must not obtain layout: {policy:?}" + ); + vm.destroy_isolated_world_context(isolated); + assert_eq!(vm.eval(r#" + const later = new CustomEvent('node-world-0', {cancelable: true}); + originals[0] = later; + JSON.stringify([button.dispatchEvent(later), later.defaultPrevented, defaultFacts.length]); + "#).unwrap(), "[true,false,4]", "retired Node listeners: {policy:?}"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn bubbling_node_events_project_the_whole_path_and_share_stop_flags() { + for policy in [ + moli_page_types::LayoutPolicy::Mock, + moli_page_types::LayoutPolicy::OnDemand, + ] { + let mut vm = new_storage_test_vm("https://bubbling-node-worlds.test/"); + vm.set_layout_policy(policy); + vm.eval( + r#" + globalThis.container = document.createElement('div'); + container.id = 'container'; + globalThis.button = document.createElement('button'); + button.id = 'probe'; + container.appendChild(button); + (document.body || document.documentElement || document).appendChild(container); + globalThis.sideEffects = 0; + "#, + ) + .unwrap(); + let isolated = vm + .create_isolated_world("bubbling-node-listeners", false) + .unwrap(); + vm.eval_in_isolated_context( + isolated, + r#" + globalThis.button = document.getElementById('probe'); + globalThis.container = document.getElementById('container'); + globalThis.facts = []; + globalThis.firstEvent = null; + function observe(label, target, phase) { + target.addEventListener('bubble-world', function(event) { + if (!firstEvent) firstEvent = event; + const path = event.composedPath(); + facts.push([label, this === target, event === firstEvent, + event instanceof Event, event.target === button, + event.currentTarget === target, event.eventPhase === phase, + path[0] === button, path.includes(container), path.includes(document), + path[path.length - 1] === window, window.event === event]); + event.preventDefault(); + }, phase === 1); + } + observe('capture', document, 1); + observe('node', button, 2); + observe('ancestor', container, 3); + observe('window', window, 3); + button.addEventListener('stop-world', event => { + event.preventDefault(); + event.stopImmediatePropagation(); + }); + "#, + ) + .unwrap(); + vm.eval( + r#" + button.addEventListener('stop-world', () => sideEffects++); + document.addEventListener('stop-world', () => sideEffects++); + "#, + ) + .unwrap(); + let builds = vm.layout_pass_observability_for_test().1; + assert_eq!( + vm.eval( + r#" + const first = new Event('bubble-world', {bubbles: true, cancelable: true}); + const second = new Event('stop-world', {bubbles: true, cancelable: true}); + JSON.stringify([button.dispatchEvent(first), first.defaultPrevented, + button.dispatchEvent(second), second.defaultPrevented, sideEffects]); + "# + ) + .unwrap(), + "[false,true,false,true,0]", + "cancellation and propagation across worlds: {policy:?}" + ); + let result = vm + .eval_in_isolated_context(isolated, "JSON.stringify(facts)") + .unwrap(); + let expected: Vec<_> = ["capture", "node", "ancestor", "window"] + .into_iter() + .map(|label| { + let mut row = vec![serde_json::json!(label)]; + row.extend(std::iter::repeat_n(serde_json::json!(true), 11)); + row + }) + .collect(); + assert_eq!( + serde_json::from_str::(&result).unwrap(), + serde_json::json!(expected), + "Node-to-Window path projection: {policy:?}" + ); + assert_eq!(vm.eval_in_isolated_context(isolated, "JSON.stringify([firstEvent.currentTarget === null, firstEvent.composedPath().length === 0, firstEvent.defaultPrevented])").unwrap(), "[true,true,true]"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + builds, + "bubbling must not obtain layout: {policy:?}" + ); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn borrowed_default_world_node_callback_keeps_the_target_documents_wrappers() { + for policy in [ + moli_page_types::LayoutPolicy::Mock, + moli_page_types::LayoutPolicy::OnDemand, + ] { + let mut vm = new_storage_test_vm("https://borrowed-node-world.test/"); + vm.set_layout_policy(policy); + vm.eval( + r#" + globalThis.frame = document.createElement('iframe'); + globalThis.button = document.createElement('button'); + const container = document.createElement('div'); + (document.body || document.documentElement || document).appendChild(container); + container.append(button, frame); + "#, + ) + .unwrap(); + materialize_single_child_default_realm_for_test(&mut vm, "borrowed Node callback"); + vm.eval(r#" + globalThis.facts = []; + globalThis.original = null; + button.addEventListener('borrowed-node', frame.contentWindow.Function('event', ` + parent.facts.push([ + this === parent.button, this instanceof parent.Node, !(this instanceof Node), + event === parent.original, event instanceof parent.Event, !(event instanceof Event), + event.target === parent.button, event.currentTarget === parent.button, + event.composedPath()[0] === parent.button, window.event === event + ]); + `)); + "#).unwrap(); + let builds = vm.layout_pass_observability_for_test().1; + let result = vm + .eval( + r#" + original = new Event('borrowed-node'); + button.dispatchEvent(original); + JSON.stringify(facts); + "#, + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&result).unwrap(), + serde_json::json!([vec![true; 10]]), + "borrowed callback must retain the real target Window: {policy:?}" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, builds); + } +}