fix(renderer): project Node listeners and event targets into their world

This commit is contained in:
ldm0
2026-10-18 05:16:35 +08:00
committed by Donough Liu
parent e1f2169f68
commit f681b6fb7c
7 changed files with 324 additions and 25 deletions
+5 -5
View File
@@ -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,
@@ -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;
@@ -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::<v8::Object>::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) {
@@ -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<v8::Local<'s, v8::Object>> {
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::<v8::Object>::try_from(value).ok())
}
pub(super) fn install_handlers<'s>(
scope: &mut v8::PinScope<'s, '_>,
target: v8::Local<'s, v8::Object>,
+3 -9
View File
@@ -156,15 +156,9 @@ pub(crate) fn invoke_prepared_event_callback<'s>(
) -> Option<v8::Global<v8::Value>> {
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 {
@@ -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;
@@ -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::<serde_json::Value>(&result).unwrap(),
serde_json::json!(vec![vec![true; 6]; 3]),
"shared dispatch state and arbitrary JS payloads: {policy:?}"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(
&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::<serde_json::Value>(
&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::<serde_json::Value>(&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::<serde_json::Value>(&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);
}
}