From 1c17a9388deb899055a9da9bd6890aeedf527cce Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 27 Mar 2026 21:17:17 +0100 Subject: [PATCH] Implement DOM event system: addEventListener, dispatch, bubbling Implements the W3C DOM event model connecting JavaScript event handlers to the DOM tree with full capture/target/bubble phase propagation. - Event constructor: new Event(type, options) with bubbles/cancelable - Event methods: preventDefault, stopPropagation, stopImmediatePropagation - EventTarget methods on all node wrappers and document: addEventListener, removeEventListener, dispatchEvent - Event propagation: capture phase -> at-target -> bubble phase - Support for capture option (boolean or options object) - Support for once option (auto-remove after first invocation) - Event listener GcRefs registered as GC roots - 20 new tests covering all acceptance criteria Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/js/src/dom_bridge.rs | 1004 +++++++++++++++++++++++++++++++++++ crates/js/src/vm.rs | 79 +++ 2 files changed, 1083 insertions(+) diff --git a/crates/js/src/dom_bridge.rs b/crates/js/src/dom_bridge.rs index 7e471b9..eac948b 100644 --- a/crates/js/src/dom_bridge.rs +++ b/crates/js/src/dom_bridge.rs @@ -522,6 +522,9 @@ fn register_node_methods(gc: &mut Gc, wrapper: GcRef, doc: &Document ("replaceChild", node_replace_child), ("cloneNode", node_clone_node), ("hasChildNodes", node_has_child_nodes), + ("addEventListener", event_target_add_listener), + ("removeEventListener", event_target_remove_listener), + ("dispatchEvent", event_target_dispatch_event), ]; for &(name, callback) in node_methods { let func = make_native(gc, name, callback); @@ -1498,6 +1501,606 @@ fn camel_to_kebab(s: &str) -> String { result } +// ── Event system ──────────────────────────────────────────────────── + +// Internal property keys for Event objects. +const EVENT_TYPE_KEY: &str = "__event_type__"; +const EVENT_BUBBLES_KEY: &str = "__event_bubbles__"; +const EVENT_CANCELABLE_KEY: &str = "__event_cancelable__"; +const EVENT_STOP_PROP_KEY: &str = "__event_stop_prop__"; +const EVENT_STOP_IMMEDIATE_KEY: &str = "__event_stop_immediate__"; +const EVENT_DEFAULT_PREVENTED_KEY: &str = "__event_default_prevented__"; +const EVENT_PHASE_KEY: &str = "__event_phase__"; + +/// Create an Event JS object with the given type string and options. +fn create_event_object( + gc: &mut Gc, + event_type: &str, + bubbles: bool, + cancelable: bool, +) -> GcRef { + let mut data = ObjectData::new(); + + // Public properties. + data.properties.insert( + "type".to_string(), + Property::data(Value::String(event_type.to_string())), + ); + data.properties.insert( + "bubbles".to_string(), + Property::data(Value::Boolean(bubbles)), + ); + data.properties.insert( + "cancelable".to_string(), + Property::data(Value::Boolean(cancelable)), + ); + data.properties.insert( + "defaultPrevented".to_string(), + Property::data(Value::Boolean(false)), + ); + data.properties + .insert("eventPhase".to_string(), Property::data(Value::Number(0.0))); + data.properties + .insert("target".to_string(), Property::data(Value::Null)); + data.properties + .insert("currentTarget".to_string(), Property::data(Value::Null)); + data.properties + .insert("timeStamp".to_string(), Property::data(Value::Number(0.0))); + + // Internal state. + data.properties.insert( + EVENT_TYPE_KEY.to_string(), + Property::builtin(Value::String(event_type.to_string())), + ); + data.properties.insert( + EVENT_BUBBLES_KEY.to_string(), + Property::builtin(Value::Boolean(bubbles)), + ); + data.properties.insert( + EVENT_CANCELABLE_KEY.to_string(), + Property::builtin(Value::Boolean(cancelable)), + ); + data.properties.insert( + EVENT_STOP_PROP_KEY.to_string(), + Property::builtin(Value::Boolean(false)), + ); + data.properties.insert( + EVENT_STOP_IMMEDIATE_KEY.to_string(), + Property::builtin(Value::Boolean(false)), + ); + data.properties.insert( + EVENT_DEFAULT_PREVENTED_KEY.to_string(), + Property::builtin(Value::Boolean(false)), + ); + data.properties.insert( + EVENT_PHASE_KEY.to_string(), + Property::builtin(Value::Number(0.0)), + ); + + // Event phase constants. + data.properties + .insert("NONE".to_string(), Property::builtin(Value::Number(0.0))); + data.properties.insert( + "CAPTURING_PHASE".to_string(), + Property::builtin(Value::Number(1.0)), + ); + data.properties.insert( + "AT_TARGET".to_string(), + Property::builtin(Value::Number(2.0)), + ); + data.properties.insert( + "BUBBLING_PHASE".to_string(), + Property::builtin(Value::Number(3.0)), + ); + + let event_ref = gc.alloc(HeapObject::Object(data)); + + // Register methods. + let methods: &[NativeMethod] = &[ + ("preventDefault", event_prevent_default), + ("stopPropagation", event_stop_propagation), + ("stopImmediatePropagation", event_stop_immediate_propagation), + ]; + for &(name, callback) in methods { + let func = make_native(gc, name, callback); + set_builtin_prop(gc, event_ref, name, Value::Function(func)); + } + + event_ref +} + +/// `Event` constructor: `new Event(type, options)`. +fn event_constructor(args: &[Value], ctx: &mut NativeContext) -> Result { + let event_type = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let mut bubbles = false; + let mut cancelable = false; + + // Parse options object if provided. + if let Some(Value::Object(opts_ref)) = args.get(1) { + if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) { + if let Some(prop) = opts.properties.get("bubbles") { + bubbles = prop.value.to_boolean(); + } + if let Some(prop) = opts.properties.get("cancelable") { + cancelable = prop.value.to_boolean(); + } + } + } + + let event_ref = create_event_object(ctx.gc, &event_type, bubbles, cancelable); + Ok(Value::Object(event_ref)) +} + +fn event_prevent_default(_args: &[Value], ctx: &mut NativeContext) -> Result { + if let Value::Object(r) = &ctx.this { + // Only prevent if cancelable. + let cancelable = match ctx.gc.get(*r) { + Some(HeapObject::Object(data)) => data + .properties + .get(EVENT_CANCELABLE_KEY) + .map(|p| p.value.to_boolean()) + .unwrap_or(false), + _ => false, + }; + if cancelable { + set_builtin_prop( + ctx.gc, + *r, + EVENT_DEFAULT_PREVENTED_KEY, + Value::Boolean(true), + ); + set_builtin_prop(ctx.gc, *r, "defaultPrevented", Value::Boolean(true)); + } + } + Ok(Value::Undefined) +} + +fn event_stop_propagation(_args: &[Value], ctx: &mut NativeContext) -> Result { + if let Value::Object(r) = &ctx.this { + set_builtin_prop(ctx.gc, *r, EVENT_STOP_PROP_KEY, Value::Boolean(true)); + } + Ok(Value::Undefined) +} + +fn event_stop_immediate_propagation( + _args: &[Value], + ctx: &mut NativeContext, +) -> Result { + if let Value::Object(r) = &ctx.this { + set_builtin_prop(ctx.gc, *r, EVENT_STOP_PROP_KEY, Value::Boolean(true)); + set_builtin_prop(ctx.gc, *r, EVENT_STOP_IMMEDIATE_KEY, Value::Boolean(true)); + } + Ok(Value::Undefined) +} + +// ── EventTarget methods ───────────────────────────────────────────── + +fn event_target_add_listener( + args: &[Value], + ctx: &mut NativeContext, +) -> Result { + let bridge = ctx + .dom_bridge + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; + let node_id = match &ctx.this { + Value::Object(r) => get_node_id(ctx.gc, *r), + _ => None, + } + .ok_or_else(|| RuntimeError::type_error("addEventListener called on non-node"))?; + + let event_type = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let callback_ref = match args.get(1) { + Some(Value::Function(r)) => *r, + _ => return Ok(Value::Undefined), // Silently ignore non-function + }; + + let mut capture = false; + let mut once = false; + + // Third arg: boolean (capture) or options object. + if let Some(arg) = args.get(2) { + match arg { + Value::Boolean(b) => capture = *b, + Value::Object(opts_ref) => { + if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) { + if let Some(prop) = opts.properties.get("capture") { + capture = prop.value.to_boolean(); + } + if let Some(prop) = opts.properties.get("once") { + once = prop.value.to_boolean(); + } + } + } + _ => {} + } + } + + let idx = node_id.index(); + let mut listeners = bridge.event_listeners.borrow_mut(); + let list = listeners.entry(idx).or_default(); + + // Don't add duplicate: same type, same callback ref, same capture. + let already_exists = list + .iter() + .any(|l| l.event_type == event_type && l.callback == callback_ref && l.capture == capture); + if !already_exists { + list.push(EventListener { + event_type, + callback: callback_ref, + capture, + once, + }); + } + + Ok(Value::Undefined) +} + +fn event_target_remove_listener( + args: &[Value], + ctx: &mut NativeContext, +) -> Result { + let bridge = ctx + .dom_bridge + .ok_or_else(|| RuntimeError::type_error("no document attached"))?; + let node_id = match &ctx.this { + Value::Object(r) => get_node_id(ctx.gc, *r), + _ => None, + } + .ok_or_else(|| RuntimeError::type_error("removeEventListener called on non-node"))?; + + let event_type = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + + let callback_ref = match args.get(1) { + Some(Value::Function(r)) => *r, + _ => return Ok(Value::Undefined), + }; + + let mut capture = false; + if let Some(arg) = args.get(2) { + match arg { + Value::Boolean(b) => capture = *b, + Value::Object(opts_ref) => { + if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) { + if let Some(prop) = opts.properties.get("capture") { + capture = prop.value.to_boolean(); + } + } + } + _ => {} + } + } + + let idx = node_id.index(); + let mut listeners = bridge.event_listeners.borrow_mut(); + if let Some(list) = listeners.get_mut(&idx) { + list.retain(|l| { + !(l.event_type == event_type && l.callback == callback_ref && l.capture == capture) + }); + } + + Ok(Value::Undefined) +} + +/// Native `dispatchEvent` — returns a marker for the VM to handle. +fn event_target_dispatch_event( + args: &[Value], + ctx: &mut NativeContext, +) -> Result { + let node_id = match &ctx.this { + Value::Object(r) => get_node_id(ctx.gc, *r), + _ => None, + } + .ok_or_else(|| RuntimeError::type_error("dispatchEvent called on non-node"))?; + + let event_ref = match args.first() { + Some(Value::Object(r)) => *r, + _ => { + return Err(RuntimeError::type_error( + "dispatchEvent requires an Event argument", + )) + } + }; + + // Build a marker object for the VM to process. + let mut marker = ObjectData::new(); + marker.properties.insert( + "__event_dispatch__".to_string(), + Property::builtin(Value::Boolean(true)), + ); + marker.properties.insert( + "__target_id__".to_string(), + Property::builtin(Value::Number(node_id.index() as f64)), + ); + marker.properties.insert( + "__event_ref__".to_string(), + Property::builtin(Value::Object(event_ref)), + ); + + Ok(Value::Object(ctx.gc.alloc(HeapObject::Object(marker)))) +} + +/// Register the `Event` constructor and event target methods on the VM. +pub fn init_event_system(vm: &mut Vm) { + // Register `Event` as a global constructor. + let ctor = make_native(&mut vm.gc, "Event", event_constructor); + vm.set_global("Event", Value::Function(ctor)); + + // Add event target methods to the document object. + if let Some(Value::Object(doc_ref)) = vm.get_global("document").cloned() { + let methods: &[NativeMethod] = &[ + ("addEventListener", event_target_add_listener), + ("removeEventListener", event_target_remove_listener), + ("dispatchEvent", event_target_dispatch_event), + ]; + for &(name, callback) in methods { + let func = make_native(&mut vm.gc, name, callback); + set_builtin_prop(&mut vm.gc, doc_ref, name, Value::Function(func)); + } + + // Ensure the document object has __node_id__ set to the root node. + if let Some(bridge) = &vm.dom_bridge { + let root_idx = bridge.document.borrow().root().index(); + set_builtin_prop( + &mut vm.gc, + doc_ref, + NODE_ID_KEY, + Value::Number(root_idx as f64), + ); + // Also cache the document wrapper. + bridge.node_wrappers.borrow_mut().insert(root_idx, doc_ref); + } + } +} + +/// Run the event dispatch algorithm. Called from the VM when it detects an +/// event dispatch marker returned by the native `dispatchEvent`. +/// +/// Returns the result of `!event.defaultPrevented`. +pub fn run_event_dispatch(vm: &mut Vm, target_idx: usize, event_ref: GcRef) -> Value { + let bridge = match vm.dom_bridge.clone() { + Some(b) => b, + None => return Value::Boolean(true), + }; + + let target_id = NodeId::from_index(target_idx); + + // Build propagation path: target -> ... -> root. + let path = { + let doc = bridge.document.borrow(); + let mut path = vec![target_id]; + let mut current = target_id; + while let Some(parent) = doc.parent(current) { + path.push(parent); + current = parent; + } + path.reverse(); // Now root -> ... -> target. + path + }; + + // Set event.target to the target wrapper. + let target_wrapper = get_or_create_wrapper(target_id, &mut vm.gc, &bridge, vm.object_prototype); + set_builtin_prop( + &mut vm.gc, + event_ref, + "target", + Value::Object(target_wrapper), + ); + + let target_pos = path.len() - 1; + + // --- Capture phase (root to target, excluding target) --- + set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(1.0)); + set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(1.0)); + + for &node_id in &path[..target_pos] { + if is_propagation_stopped(&vm.gc, event_ref) { + break; + } + let wrapper = get_or_create_wrapper(node_id, &mut vm.gc, &bridge, vm.object_prototype); + set_builtin_prop( + &mut vm.gc, + event_ref, + "currentTarget", + Value::Object(wrapper), + ); + invoke_listeners(vm, &bridge, node_id, event_ref, wrapper, true); + } + + // --- At-target phase --- + if !is_propagation_stopped(&vm.gc, event_ref) { + set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(2.0)); + set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(2.0)); + set_builtin_prop( + &mut vm.gc, + event_ref, + "currentTarget", + Value::Object(target_wrapper), + ); + // At target, fire BOTH capture and bubble listeners in registration order. + invoke_listeners(vm, &bridge, target_id, event_ref, target_wrapper, false); + } + + // --- Bubble phase (target to root, excluding target) --- + let bubbles = match vm.gc.get(event_ref) { + Some(HeapObject::Object(data)) => data + .properties + .get(EVENT_BUBBLES_KEY) + .map(|p| p.value.to_boolean()) + .unwrap_or(false), + _ => false, + }; + + if bubbles { + set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(3.0)); + set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(3.0)); + + for &node_id in path[..target_pos].iter().rev() { + if is_propagation_stopped(&vm.gc, event_ref) { + break; + } + let wrapper = get_or_create_wrapper(node_id, &mut vm.gc, &bridge, vm.object_prototype); + set_builtin_prop( + &mut vm.gc, + event_ref, + "currentTarget", + Value::Object(wrapper), + ); + invoke_listeners(vm, &bridge, node_id, event_ref, wrapper, false); + } + } + + // Reset event state. + set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(0.0)); + set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(0.0)); + set_builtin_prop(&mut vm.gc, event_ref, "currentTarget", Value::Null); + + // Return !defaultPrevented. + let default_prevented = match vm.gc.get(event_ref) { + Some(HeapObject::Object(data)) => data + .properties + .get(EVENT_DEFAULT_PREVENTED_KEY) + .map(|p| p.value.to_boolean()) + .unwrap_or(false), + _ => false, + }; + Value::Boolean(!default_prevented) +} + +fn is_propagation_stopped(gc: &Gc, event_ref: GcRef) -> bool { + match gc.get(event_ref) { + Some(HeapObject::Object(data)) => data + .properties + .get(EVENT_STOP_PROP_KEY) + .map(|p| p.value.to_boolean()) + .unwrap_or(false), + _ => false, + } +} + +fn is_immediate_stopped(gc: &Gc, event_ref: GcRef) -> bool { + match gc.get(event_ref) { + Some(HeapObject::Object(data)) => data + .properties + .get(EVENT_STOP_IMMEDIATE_KEY) + .map(|p| p.value.to_boolean()) + .unwrap_or(false), + _ => false, + } +} + +/// Invoke matching listeners on a node for the given event. +/// +/// When `capture_only` is true (capture phase), only capture listeners fire. +/// When false (at-target or bubble), at-target fires all listeners and +/// bubble fires only non-capture listeners. We distinguish by the event phase: +/// at-target (phase 2) fires all, bubble (phase 3) fires only non-capture. +fn invoke_listeners( + vm: &mut Vm, + bridge: &DomBridge, + node_id: NodeId, + event_ref: GcRef, + current_target_wrapper: GcRef, + capture_only: bool, +) { + let event_type = match vm.gc.get(event_ref) { + Some(HeapObject::Object(data)) => match data.properties.get(EVENT_TYPE_KEY) { + Some(Property { + value: Value::String(s), + .. + }) => s.clone(), + _ => return, + }, + _ => return, + }; + + let phase = match vm.gc.get(event_ref) { + Some(HeapObject::Object(data)) => match data.properties.get(EVENT_PHASE_KEY) { + Some(Property { + value: Value::Number(n), + .. + }) => *n as u8, + _ => 0, + }, + _ => 0, + }; + + let idx = node_id.index(); + + // Collect matching listeners (snapshot the list to avoid borrow issues). + let matching: Vec<(GcRef, bool)> = { + let listeners = bridge.event_listeners.borrow(); + match listeners.get(&idx) { + Some(list) => list + .iter() + .filter(|l| { + if l.event_type != event_type { + return false; + } + if capture_only { + // Capture phase: only capture listeners. + l.capture + } else if phase == 2 { + // At-target: all listeners fire. + true + } else { + // Bubble phase: only non-capture listeners. + !l.capture + } + }) + .map(|l| (l.callback, l.once)) + .collect(), + None => return, + } + }; + + // Invoke each listener. + for (callback_ref, once) in &matching { + if is_immediate_stopped(&vm.gc, event_ref) { + break; + } + if is_propagation_stopped(&vm.gc, event_ref) && capture_only { + break; + } + + // Set `this` to currentTarget for the callback. + let old_this = vm.get_global("this").cloned(); + vm.set_global("this", Value::Object(current_target_wrapper)); + + let _ = vm.call_function(*callback_ref, &[Value::Object(event_ref)]); + + // Restore `this`. + match old_this { + Some(v) => { + vm.set_global("this", v); + } + None => { + vm.remove_global("this"); + } + } + + // Remove if once. + if *once { + let mut listeners = bridge.event_listeners.borrow_mut(); + if let Some(list) = listeners.get_mut(&idx) { + list.retain(|l| { + !(l.event_type == event_type && l.callback == *callback_ref && l.once) + }); + } + } + } +} + // ── Tests ─────────────────────────────────────────────────────────── #[cfg(test)] @@ -2339,4 +2942,405 @@ mod tests { .unwrap(); assert!(matches!(result, Value::Boolean(true))); } + + // ── Event system tests ────────────────────────────────── + + #[test] + fn test_event_constructor() { + let result = eval_with_doc( + "", + r#" + var e = new Event("click"); + e.type + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "click"), + v => panic!("expected 'click', got {v:?}"), + } + } + + #[test] + fn test_event_constructor_options() { + let result = eval_with_doc( + "", + r#" + var e = new Event("click", { bubbles: true, cancelable: true }); + e.bubbles + "," + e.cancelable + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "true,true"), + v => panic!("expected 'true,true', got {v:?}"), + } + } + + #[test] + fn test_event_default_properties() { + let result = eval_with_doc( + "", + r#" + var e = new Event("test"); + e.bubbles + "," + e.cancelable + "," + e.defaultPrevented + "," + e.eventPhase + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "false,false,false,0"), + v => panic!("expected 'false,false,false,0', got {v:?}"), + } + } + + #[test] + fn test_add_event_listener_and_dispatch() { + let result = eval_with_doc( + r#"
"#, + r#" + var called = false; + var d = document.getElementById("d"); + d.addEventListener("click", function(e) { + called = true; + }); + d.dispatchEvent(new Event("click")); + called + "#, + ) + .unwrap(); + assert!(matches!(result, Value::Boolean(true))); + } + + #[test] + fn test_event_handler_receives_event_object() { + let result = eval_with_doc( + r#"
"#, + r#" + var eventType = ""; + var d = document.getElementById("d"); + d.addEventListener("myevent", function(e) { + eventType = e.type; + }); + d.dispatchEvent(new Event("myevent")); + eventType + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "myevent"), + v => panic!("expected 'myevent', got {v:?}"), + } + } + + #[test] + fn test_event_target_set_correctly() { + let result = eval_with_doc( + r#"
"#, + r#" + var targetTag = ""; + var d = document.getElementById("d"); + d.addEventListener("click", function(e) { + targetTag = e.target.tagName; + }); + d.dispatchEvent(new Event("click")); + targetTag + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "DIV"), + v => panic!("expected 'DIV', got {v:?}"), + } + } + + #[test] + fn test_event_bubbling() { + let result = eval_with_doc( + r#"
"#, + r#" + var order = ""; + var parent = document.getElementById("parent"); + var child = document.getElementById("child"); + parent.addEventListener("click", function(e) { + order = order + "parent"; + }); + child.addEventListener("click", function(e) { + order = order + "child,"; + }); + child.dispatchEvent(new Event("click", { bubbles: true })); + order + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "child,parent"), + v => panic!("expected 'child,parent', got {v:?}"), + } + } + + #[test] + fn test_event_no_bubbling_by_default() { + let result = eval_with_doc( + r#"
"#, + r#" + var parentCalled = false; + var parent = document.getElementById("parent"); + var child = document.getElementById("child"); + parent.addEventListener("test", function(e) { + parentCalled = true; + }); + child.dispatchEvent(new Event("test")); + parentCalled + "#, + ) + .unwrap(); + assert!(matches!(result, Value::Boolean(false))); + } + + #[test] + fn test_event_capture_phase() { + let result = eval_with_doc( + r#"
"#, + r#" + var order = ""; + var parent = document.getElementById("parent"); + var child = document.getElementById("child"); + parent.addEventListener("click", function(e) { + order = order + "capture,"; + }, true); + parent.addEventListener("click", function(e) { + order = order + "bubble"; + }); + child.addEventListener("click", function(e) { + order = order + "target,"; + }); + child.dispatchEvent(new Event("click", { bubbles: true })); + order + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "capture,target,bubble"), + v => panic!("expected 'capture,target,bubble', got {v:?}"), + } + } + + #[test] + fn test_stop_propagation() { + let result = eval_with_doc( + r#"
"#, + r#" + var parentCalled = false; + var parent = document.getElementById("parent"); + var child = document.getElementById("child"); + parent.addEventListener("click", function(e) { + parentCalled = true; + }); + child.addEventListener("click", function(e) { + e.stopPropagation(); + }); + child.dispatchEvent(new Event("click", { bubbles: true })); + parentCalled + "#, + ) + .unwrap(); + assert!(matches!(result, Value::Boolean(false))); + } + + #[test] + fn test_stop_immediate_propagation() { + let result = eval_with_doc( + r#"
"#, + r#" + var count = 0; + var d = document.getElementById("d"); + d.addEventListener("click", function(e) { + count = count + 1; + e.stopImmediatePropagation(); + }); + d.addEventListener("click", function(e) { + count = count + 1; + }); + d.dispatchEvent(new Event("click")); + count + "#, + ) + .unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 1.0), + v => panic!("expected 1, got {v:?}"), + } + } + + #[test] + fn test_prevent_default() { + let result = eval_with_doc( + r#"
"#, + r#" + var d = document.getElementById("d"); + d.addEventListener("click", function(e) { + e.preventDefault(); + }); + var result = d.dispatchEvent(new Event("click", { cancelable: true })); + result + "#, + ) + .unwrap(); + // dispatchEvent returns false when preventDefault was called. + assert!(matches!(result, Value::Boolean(false))); + } + + #[test] + fn test_prevent_default_not_cancelable() { + let result = eval_with_doc( + r#"
"#, + r#" + var d = document.getElementById("d"); + d.addEventListener("click", function(e) { + e.preventDefault(); + }); + var result = d.dispatchEvent(new Event("click")); + result + "#, + ) + .unwrap(); + // Non-cancelable: preventDefault has no effect, returns true. + assert!(matches!(result, Value::Boolean(true))); + } + + #[test] + fn test_remove_event_listener() { + let result = eval_with_doc( + r#"
"#, + r#" + var count = 0; + var d = document.getElementById("d"); + var handler = function(e) { count = count + 1; }; + d.addEventListener("click", handler); + d.dispatchEvent(new Event("click")); + d.removeEventListener("click", handler); + d.dispatchEvent(new Event("click")); + count + "#, + ) + .unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 1.0), + v => panic!("expected 1, got {v:?}"), + } + } + + #[test] + fn test_once_option() { + let result = eval_with_doc( + r#"
"#, + r#" + var count = 0; + var d = document.getElementById("d"); + d.addEventListener("click", function(e) { + count = count + 1; + }, { once: true }); + d.dispatchEvent(new Event("click")); + d.dispatchEvent(new Event("click")); + count + "#, + ) + .unwrap(); + match result { + Value::Number(n) => assert_eq!(n, 1.0), + v => panic!("expected 1, got {v:?}"), + } + } + + #[test] + fn test_dispatch_event_returns_true_normally() { + let result = eval_with_doc( + r#"
"#, + r#" + var d = document.getElementById("d"); + d.dispatchEvent(new Event("click")) + "#, + ) + .unwrap(); + assert!(matches!(result, Value::Boolean(true))); + } + + #[test] + fn test_event_current_target() { + let result = eval_with_doc( + r#"
"#, + r#" + var currentTag = ""; + var parent = document.getElementById("parent"); + var child = document.getElementById("child"); + parent.addEventListener("click", function(e) { + currentTag = e.currentTarget.tagName; + }); + child.dispatchEvent(new Event("click", { bubbles: true })); + currentTag + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "DIV"), + v => panic!("expected 'DIV', got {v:?}"), + } + } + + #[test] + fn test_multiple_listeners_same_type() { + let result = eval_with_doc( + r#"
"#, + r#" + var order = ""; + var d = document.getElementById("d"); + d.addEventListener("click", function(e) { order = order + "a,"; }); + d.addEventListener("click", function(e) { order = order + "b"; }); + d.dispatchEvent(new Event("click")); + order + "#, + ) + .unwrap(); + match result { + Value::String(s) => assert_eq!(s, "a,b"), + v => panic!("expected 'a,b', got {v:?}"), + } + } + + #[test] + fn test_capture_option_object() { + let result = eval_with_doc( + r#"
"#, + r#" + var capturePhase = false; + var parent = document.getElementById("parent"); + var child = document.getElementById("child"); + parent.addEventListener("click", function(e) { + capturePhase = (e.eventPhase === 1); + }, { capture: true }); + child.dispatchEvent(new Event("click", { bubbles: true })); + capturePhase + "#, + ) + .unwrap(); + assert!(matches!(result, Value::Boolean(true))); + } + + #[test] + fn test_document_add_event_listener() { + let result = eval_with_doc( + r#"
"#, + r#" + var docCalled = false; + document.addEventListener("click", function(e) { + docCalled = true; + }); + var d = document.getElementById("d"); + d.dispatchEvent(new Event("click", { bubbles: true })); + docCalled + "#, + ) + .unwrap(); + assert!(matches!(result, Value::Boolean(true))); + } } diff --git a/crates/js/src/vm.rs b/crates/js/src/vm.rs index b8f0b32..1ca8751 100644 --- a/crates/js/src/vm.rs +++ b/crates/js/src/vm.rs @@ -221,12 +221,22 @@ impl ConsoleOutput for StdConsoleOutput { } } +/// A single event listener registered on a DOM node. +pub struct EventListener { + pub event_type: String, + pub callback: GcRef, + pub capture: bool, + pub once: bool, +} + /// Bridge between JS and the DOM. Holds a shared document and a cache /// mapping `NodeId` indices to their JS wrapper `GcRef` so that the same /// DOM node always returns the same JS object (identity). pub struct DomBridge { pub document: RefCell, pub node_wrappers: RefCell>, + /// Event listeners keyed by NodeId index. + pub event_listeners: RefCell>>, } /// Context passed to native functions, providing GC access and `this` binding. @@ -829,9 +839,11 @@ impl Vm { let bridge = Rc::new(DomBridge { document: RefCell::new(doc), node_wrappers: RefCell::new(HashMap::new()), + event_listeners: RefCell::new(HashMap::new()), }); self.dom_bridge = Some(bridge); crate::dom_bridge::init_document_object(self); + crate::dom_bridge::init_event_system(self); } /// Set an instruction limit. The VM will return a RuntimeError after @@ -1006,6 +1018,25 @@ impl Vm { } return Ok(Value::Object(promise)); } + + // Check for event dispatch marker. + let is_event_dispatch = matches!( + gc_get_property(&self.gc, *r, "__event_dispatch__"), + Value::Boolean(true) + ); + if is_event_dispatch { + let target_idx = match gc_get_property(&self.gc, *r, "__target_id__") { + Value::Number(n) => n as usize, + _ => return Ok(Value::Boolean(true)), + }; + let evt_ref = match gc_get_property(&self.gc, *r, "__event_ref__") { + Value::Object(er) => er, + _ => return Ok(Value::Boolean(true)), + }; + return Ok(crate::dom_bridge::run_event_dispatch( + self, target_idx, evt_ref, + )); + } } Ok(result) @@ -2023,6 +2054,12 @@ impl Vm { for &wrapper_ref in bridge.node_wrappers.borrow().values() { roots.push(wrapper_ref); } + // Event listener callbacks must also be GC roots. + for listeners in bridge.event_listeners.borrow().values() { + for listener in listeners { + roots.push(listener.callback); + } + } } roots } @@ -2651,6 +2688,43 @@ impl Vm { Value::Object(promise); continue; } + + // Check for event dispatch marker. + let is_event_dispatch = matches!( + gc_get_property(&self.gc, *r, "__event_dispatch__"), + Value::Boolean(true) + ); + if is_event_dispatch { + let target_idx = match gc_get_property( + &self.gc, + *r, + "__target_id__", + ) { + Value::Number(n) => n as usize, + _ => { + self.registers[base + dst as usize] = + Value::Boolean(true); + continue; + } + }; + let evt_ref = match gc_get_property( + &self.gc, + *r, + "__event_ref__", + ) { + Value::Object(er) => er, + _ => { + self.registers[base + dst as usize] = + Value::Boolean(true); + continue; + } + }; + let result = crate::dom_bridge::run_event_dispatch( + self, target_idx, evt_ref, + ); + self.registers[base + dst as usize] = result; + continue; + } } self.registers[base + dst as usize] = val; } @@ -3413,6 +3487,11 @@ impl Vm { pub fn set_global(&mut self, name: &str, val: Value) { self.globals.insert(name.to_string(), val); } + + /// Remove a global variable. + pub fn remove_global(&mut self, name: &str) { + self.globals.remove(name); + } } impl Default for Vm { -- 2.51.2