Implements the event dispatch infrastructure enabling JS scripts to register event listeners and respond to click events with DOM-spec-compliant bubbling. Key additions: - Function expression parsing (anonymous and named) in js_parser - Function identity via monotonic u64 IDs on JsFunction for removeEventListener - call_function_with_host on JsVm that isolates handler errors (stays Primed, never Failed) - EventListenerRegistry with addEventListener/removeEventListener deduplication - DomEvent model with preventDefault/stopPropagation support - Event dispatch algorithm: target phase + bubble phase with listener snapshots - 11 integration tests covering click handlers, bubbling, error recovery, and event properties Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
432 lines
14 KiB
Rust
432 lines
14 KiB
Rust
use js::JsFunction;
|
|
use shared::NodeId;
|
|
use std::collections::HashMap;
|
|
|
|
/// Identifies an event target — either a DOM node or the Document itself.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum EventTargetId {
|
|
Node(NodeId),
|
|
Document,
|
|
}
|
|
|
|
/// A single registered event listener.
|
|
#[derive(Debug, Clone)]
|
|
pub struct EventListener {
|
|
pub event_type: String,
|
|
pub callback: JsFunction,
|
|
pub capture: bool,
|
|
}
|
|
|
|
/// Registry of event listeners, keyed by target.
|
|
#[derive(Debug, Default)]
|
|
pub struct EventListenerRegistry {
|
|
listeners: HashMap<EventTargetId, Vec<EventListener>>,
|
|
}
|
|
|
|
impl EventListenerRegistry {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Add a listener. Deduplicates by (event_type, callback.id, capture).
|
|
pub fn add_listener(&mut self, target: EventTargetId, listener: EventListener) {
|
|
let list = self.listeners.entry(target).or_default();
|
|
// Deduplicate: same event_type + function id + capture → skip
|
|
let already_exists = list.iter().any(|existing| {
|
|
existing.event_type == listener.event_type
|
|
&& existing.callback.id == listener.callback.id
|
|
&& existing.capture == listener.capture
|
|
});
|
|
if !already_exists {
|
|
list.push(listener);
|
|
}
|
|
}
|
|
|
|
/// Remove first listener matching (event_type, function_id). Returns true if removed.
|
|
pub fn remove_listener(
|
|
&mut self,
|
|
target: EventTargetId,
|
|
event_type: &str,
|
|
function_id: u64,
|
|
) -> bool {
|
|
if let Some(list) = self.listeners.get_mut(&target) {
|
|
if let Some(pos) = list
|
|
.iter()
|
|
.position(|l| l.event_type == event_type && l.callback.id == function_id)
|
|
{
|
|
list.remove(pos);
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Get listeners for a specific target and event type (insertion order).
|
|
pub fn get_listeners(&self, target: EventTargetId, event_type: &str) -> Vec<&EventListener> {
|
|
self.listeners
|
|
.get(&target)
|
|
.map(|list| list.iter().filter(|l| l.event_type == event_type).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Snapshot-clone listeners for all targets in the given path.
|
|
/// Returns (target, cloned listeners) pairs for dispatch.
|
|
pub fn collect_for_path(
|
|
&self,
|
|
path: &[EventTargetId],
|
|
event_type: &str,
|
|
) -> Vec<(EventTargetId, Vec<EventListener>)> {
|
|
path.iter()
|
|
.map(|target| {
|
|
let listeners: Vec<EventListener> = self
|
|
.listeners
|
|
.get(target)
|
|
.map(|list| {
|
|
list.iter()
|
|
.filter(|l| l.event_type == event_type)
|
|
.cloned()
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
(*target, listeners)
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use js::JsFunction;
|
|
|
|
fn make_func(name: &str) -> JsFunction {
|
|
JsFunction::new(name.into(), vec![], vec![])
|
|
}
|
|
|
|
#[test]
|
|
fn add_and_get_listeners() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
let func = make_func("handler");
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: func,
|
|
capture: false,
|
|
},
|
|
);
|
|
let listeners = reg.get_listeners(target, "click");
|
|
assert_eq!(listeners.len(), 1);
|
|
assert_eq!(listeners[0].event_type, "click");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_add_is_noop() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
let func = make_func("handler");
|
|
let listener = EventListener {
|
|
event_type: "click".into(),
|
|
callback: func.clone(),
|
|
capture: false,
|
|
};
|
|
reg.add_listener(target, listener.clone());
|
|
reg.add_listener(target, listener);
|
|
assert_eq!(reg.get_listeners(target, "click").len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn different_functions_both_registered() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
let func1 = make_func("a");
|
|
let func2 = make_func("b");
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: func1,
|
|
capture: false,
|
|
},
|
|
);
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: func2,
|
|
capture: false,
|
|
},
|
|
);
|
|
assert_eq!(reg.get_listeners(target, "click").len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_listener_by_function_id() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
let func = make_func("handler");
|
|
let func_id = func.id;
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: func,
|
|
capture: false,
|
|
},
|
|
);
|
|
assert!(reg.remove_listener(target, "click", func_id));
|
|
assert_eq!(reg.get_listeners(target, "click").len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_nonexistent_is_noop() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
assert!(!reg.remove_listener(target, "click", 9999));
|
|
}
|
|
|
|
#[test]
|
|
fn ordering_preserved() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
let f1 = make_func("first");
|
|
let f2 = make_func("second");
|
|
let f3 = make_func("third");
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: f1,
|
|
capture: false,
|
|
},
|
|
);
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: f2,
|
|
capture: false,
|
|
},
|
|
);
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: f3,
|
|
capture: false,
|
|
},
|
|
);
|
|
let listeners = reg.get_listeners(target, "click");
|
|
assert_eq!(listeners.len(), 3);
|
|
assert_eq!(listeners[0].callback.name, "first");
|
|
assert_eq!(listeners[1].callback.name, "second");
|
|
assert_eq!(listeners[2].callback.name, "third");
|
|
}
|
|
|
|
#[test]
|
|
fn document_listeners() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let func = make_func("doc_handler");
|
|
reg.add_listener(
|
|
EventTargetId::Document,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: func,
|
|
capture: false,
|
|
},
|
|
);
|
|
assert_eq!(reg.get_listeners(EventTargetId::Document, "click").len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn collect_for_path_snapshots_listeners() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let t1 = EventTargetId::Node(NodeId::new(1));
|
|
let t2 = EventTargetId::Node(NodeId::new(2));
|
|
let f1 = make_func("h1");
|
|
let f2 = make_func("h2");
|
|
reg.add_listener(
|
|
t1,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: f1,
|
|
capture: false,
|
|
},
|
|
);
|
|
reg.add_listener(
|
|
t2,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: f2,
|
|
capture: false,
|
|
},
|
|
);
|
|
let result = reg.collect_for_path(&[t1, t2], "click");
|
|
assert_eq!(result.len(), 2);
|
|
assert_eq!(result[0].1.len(), 1);
|
|
assert_eq!(result[1].1.len(), 1);
|
|
}
|
|
|
|
// --- collect_for_path with empty path returns empty vec ---
|
|
|
|
#[test]
|
|
fn collect_for_path_empty_path_returns_empty() {
|
|
let reg = EventListenerRegistry::new();
|
|
let result = reg.collect_for_path(&[], "click");
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
// --- collect_for_path filters by event type ---
|
|
|
|
#[test]
|
|
fn collect_for_path_filters_by_event_type() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
// Register a "mouseover" listener — should NOT appear for "click" path collection.
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "mouseover".into(),
|
|
callback: make_func("hover_handler"),
|
|
capture: false,
|
|
},
|
|
);
|
|
let result = reg.collect_for_path(&[target], "click");
|
|
assert_eq!(result.len(), 1);
|
|
// The target is included in the result but its listener list for "click" is empty.
|
|
assert!(result[0].1.is_empty());
|
|
}
|
|
|
|
// --- capture=true and capture=false are treated as distinct listeners ---
|
|
|
|
#[test]
|
|
fn same_function_capture_true_and_false_both_registered() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
let func = make_func("handler");
|
|
// Add with capture=false
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: func.clone(),
|
|
capture: false,
|
|
},
|
|
);
|
|
// Add same function with capture=true — different dedup key, so both should be stored.
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: func,
|
|
capture: true,
|
|
},
|
|
);
|
|
assert_eq!(reg.get_listeners(target, "click").len(), 2);
|
|
}
|
|
|
|
// --- get_listeners returns empty when target has listeners for other events ---
|
|
|
|
#[test]
|
|
fn get_listeners_empty_for_other_event_type() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "mouseover".into(),
|
|
callback: make_func("h"),
|
|
capture: false,
|
|
},
|
|
);
|
|
// "click" listeners should be empty even though the target exists.
|
|
assert!(reg.get_listeners(target, "click").is_empty());
|
|
}
|
|
|
|
// --- remove_listener with wrong event_type leaves listener intact ---
|
|
|
|
#[test]
|
|
fn remove_listener_wrong_event_type_is_noop() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
let func = make_func("handler");
|
|
let func_id = func.id;
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: func,
|
|
capture: false,
|
|
},
|
|
);
|
|
// Try to remove with wrong event_type — should return false and leave click listener.
|
|
assert!(!reg.remove_listener(target, "mouseover", func_id));
|
|
assert_eq!(reg.get_listeners(target, "click").len(), 1);
|
|
}
|
|
|
|
// --- remove_listener with wrong function_id leaves listener intact ---
|
|
|
|
#[test]
|
|
fn remove_listener_wrong_function_id_is_noop() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
let func = make_func("handler");
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: func,
|
|
capture: false,
|
|
},
|
|
);
|
|
// Remove with a different (non-existent) function ID.
|
|
assert!(!reg.remove_listener(target, "click", 999_999));
|
|
assert_eq!(reg.get_listeners(target, "click").len(), 1);
|
|
}
|
|
|
|
// --- remove listener when target has no entry in the map ---
|
|
|
|
#[test]
|
|
fn remove_listener_target_not_in_map_returns_false() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let absent_target = EventTargetId::Node(NodeId::new(42));
|
|
assert!(!reg.remove_listener(absent_target, "click", 1));
|
|
}
|
|
|
|
// --- get_listeners when target has no entry at all ---
|
|
|
|
#[test]
|
|
fn get_listeners_for_unknown_target_returns_empty() {
|
|
let reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(99));
|
|
assert!(reg.get_listeners(target, "click").is_empty());
|
|
}
|
|
|
|
// --- multiple event types on the same target are independent ---
|
|
|
|
#[test]
|
|
fn multiple_event_types_on_same_target_are_independent() {
|
|
let mut reg = EventListenerRegistry::new();
|
|
let target = EventTargetId::Node(NodeId::new(1));
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "click".into(),
|
|
callback: make_func("click_handler"),
|
|
capture: false,
|
|
},
|
|
);
|
|
reg.add_listener(
|
|
target,
|
|
EventListener {
|
|
event_type: "keydown".into(),
|
|
callback: make_func("key_handler"),
|
|
capture: false,
|
|
},
|
|
);
|
|
assert_eq!(reg.get_listeners(target, "click").len(), 1);
|
|
assert_eq!(reg.get_listeners(target, "keydown").len(), 1);
|
|
}
|
|
}
|