//! Managed subscriptions and occurrence-specific propagation. A validated VM //! function sequences calls, so all handlers share one execution budget. use crate::{block, mount::Mount, ui_bridge::attach_desktop_type}; use endor_core::{ ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef, Value, Vm, VmError, WeakObjectRef, }; const MAX_DELIVERIES: usize = 10_000; fn invalid(message: &'static str) -> VmError { VmError::InvalidArgument(message) } pub fn validate_handler( store: &endor_core::ObjectStore, function: ObjectRef, ) -> Result<(), VmError> { if !store.object(function)?.payload().is_function() { return Err(invalid("event handler must be a Function")); } let arity = store .call_arity(function)? .ok_or(invalid("event handler requires positional metadata"))?; if !arity.accepts(0) && !arity.accepts(1) { return Err(invalid( "event handler must accept zero arguments or one Event", )); } Ok(()) } #[derive(Debug, Clone, Default)] pub struct Event { dispatch: Option, group: usize, stopped_at: Option, immediate: bool, prevented: bool, } impl ObjectPayload for Event { fn reference_assignment_only(&self) -> bool { true } fn as_any(&self) -> &dyn std::any::Any { self } fn kind_name(&self) -> &'static str { "tessera-event" } fn allows_attributes(&self) -> bool { true } } #[derive(Debug, Clone)] pub struct Connection { pub handler: Option, pub kind: String, pub capture: bool, } impl ObjectPayload for Connection { fn reference_assignment_only(&self) -> bool { true } fn as_any(&self) -> &dyn std::any::Any { self } fn kind_name(&self) -> &'static str { "tessera-event-connection" } fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { if let Some(handler) = self.handler { visit(handler); } } } /// A token never retains its target or callback. The Block owns the connection. #[derive(Debug)] pub struct Subscription { pub connection: WeakObjectRef, } impl ObjectPayload for Subscription { fn reference_assignment_only(&self) -> bool { true } fn as_any(&self) -> &dyn std::any::Any { self } fn kind_name(&self) -> &'static str { "tessera-subscription" } } #[derive(Debug, Clone, Copy)] enum Method { Stop, StopImmediate, Prevent, Prevented, Disconnect, Connected, } impl NativeCallable for Method { fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let [Value::Object(owner)] = args else { return Err(invalid("event method receiver")); }; let result = match self { Self::Disconnect | Self::Connected => { let weak = cx .store() .payload::(*owner, "Subscription")? .connection; let connection = cx.upgrade(weak)?; let connected = if let Some(connection) = connection { if cx .store() .object(connection)? .payload() .as_any() .is::() { let mut data = cx .store() .payload::(connection, "Binding")? .clone(); let connected = data.compute.is_some(); if matches!(self, Self::Disconnect) { data.compute = None; cx.replace_payload(connection, data)?; } connected } else { let mut data = cx .store() .payload::(connection, "Connection")? .clone(); let connected = data.handler.is_some(); if matches!(self, Self::Disconnect) { data.handler = None; cx.replace_payload(connection, data)?; } connected } } else { false }; if matches!(self, Self::Disconnect) { Value::Nil } else { Value::Bool(connected) } } _ => { let mut event = cx.store().payload::(*owner, "Event")?.clone(); if matches!(self, Self::Prevented) { Value::Bool(event.prevented) } else { match self { Self::Stop | Self::StopImmediate => { event.stopped_at = Some(event.group); event.immediate |= matches!(self, Self::StopImmediate); } Self::Prevent => event.prevented = true, _ => unreachable!(), } cx.replace_payload(*owner, event)?; Value::Nil } } }; Ok(NativeResult::Return(result)) } } #[derive(Debug)] pub struct Constructor { pub signature: Option, } impl NativeCallable for Constructor { fn signature(&self) -> Option { self.signature.clone() } fn arity(&self) -> Option { Some(endor_core::CallArity::range(2, usize::MAX)) } fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let [Value::Object(definition), Value::Text(kind), values @ ..] = args else { return Err(invalid("Event requires an event kind")); }; let fields = cx .store() .payload::(*definition, "Type")? .fields() .to_vec(); if fields.len() != values.len() { return Err(invalid("incorrect Event field arguments")); } cx.store() .check_class_field_arguments(*definition, values)?; let event = new_event(cx, *definition, kind)?; for (field, value) in fields.iter().zip(values) { cx.set_attr(event, &field.name, value.clone())?; } Ok(NativeResult::Return(Value::Object(event))) } } pub fn new_event( cx: &mut NativeContext<'_>, definition: ObjectRef, kind: &str, ) -> Result { if kind.is_empty() || kind.len() > 128 { return Err(invalid("event kind must contain 1..128 UTF-8 bytes")); } let event = cx.allocate(Event::default())?; cx.attach_type(event, definition)?; cx.set_attr(event, "kind", Value::Text(kind.into()))?; for name in ["target", "mount", "currentTarget", "currentMount"] { cx.set_attr(event, name, Value::Nil)?; } cx.set_attr(event, "phase", Value::Text("none".into()))?; Ok(event) } #[derive(Debug)] struct TypedMethod { method: Method, signature: endor_core::CallSignature, } impl NativeCallable for TypedMethod { fn signature(&self) -> Option { Some(self.signature.clone()) } fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { self.method.invoke(cx, args) } } pub fn install( vm: &mut Vm, roots: &mut Vec, event_type: ObjectRef, token_type: ObjectRef, function_type: ObjectRef, ) -> Result<(), VmError> { for (definition, name, method) in [ (event_type, "stopPropagation", Method::Stop), ( event_type, "stopImmediatePropagation", Method::StopImmediate, ), (event_type, "preventDefault", Method::Prevent), (event_type, "isDefaultPrevented", Method::Prevented), (token_type, "disconnect", Method::Disconnect), (token_type, "isConnected", Method::Connected), ] { let query = matches!(method, Method::Prevented | Method::Connected); let function = vm.allocate(ContextNativeFunction::new( name, TypedMethod { method, signature: endor_core::CallSignature::exact( vec![endor_core::AnnotationType::Nominal(definition)], if query { endor_core::AnnotationType::Bool } else { endor_core::AnnotationType::Unit }, if query { endor_core::CallEffect::ReadOnly } else { endor_core::CallEffect::Mutating }, ), }, vec![], ))?; roots.push(function); vm.attach_type(function, function_type)?; vm.define_method(definition, name, function)?; } Ok(()) } #[derive(Debug)] enum Handler { Token(ObjectRef), Legacy(ObjectRef), ObjectMethod, } #[derive(Debug)] struct Delivery { event: ObjectRef, owner: ObjectRef, mount: Option, group: usize, phase: &'static str, handler: Handler, } impl NativeCallable for Delivery { fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { let mut event = cx.store().payload::(self.event, "Event")?.clone(); if event.immediate || event.stopped_at.is_some_and(|group| group != self.group) { return Ok(NativeResult::Return(Value::Nil)); } let (function, mut arguments) = match self.handler { Handler::Token(token) => { let Some(handler) = cx .store() .payload::(token, "Connection")? .handler else { return Ok(NativeResult::Return(Value::Nil)); }; (handler, vec![]) } Handler::Legacy(function) => (function, vec![]), Handler::ObjectMethod => { let Some(definition) = cx.store().type_of(self.owner)? else { return Err(invalid("untyped event target")); }; let function = cx .store() .lookup_method(definition, "onEvent")? .ok_or(invalid("Block has no onEvent method"))?; (function, vec![Value::Object(self.owner)]) } }; event.group = self.group; cx.replace_payload(self.event, event)?; cx.set_attr(self.event, "phase", Value::Text(self.phase.into()))?; cx.set_attr(self.event, "currentTarget", Value::Object(self.owner))?; cx.set_attr( self.event, "currentMount", self.mount.map(Value::Object).unwrap_or(Value::Nil), )?; if matches!(self.handler, Handler::ObjectMethod) || cx .store() .call_arity(function)? .is_some_and(|arity| arity.accepts(1)) { arguments.push(Value::Object(self.event)); } Ok(NativeResult::Invoke { function, arguments, }) } } #[derive(Debug)] struct Finish(ObjectRef); impl NativeCallable for Finish { fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { let mut event = cx.store().payload::(self.0, "Event")?.clone(); event.dispatch = None; cx.replace_payload(self.0, event)?; cx.set_attr(self.0, "phase", Value::Text("none".into()))?; cx.set_attr(self.0, "currentTarget", Value::Nil)?; cx.set_attr(self.0, "currentMount", Value::Nil)?; Ok(NativeResult::Return(Value::Object(self.0))) } } /// Snapshot the selected occurrence's route and listener order. Disconnection /// before a listener's turn skips it; newly registered listeners wait for the /// next dispatch. A code event without a Mount visits only its target object. pub fn dispatch( cx: &mut NativeContext<'_>, owner: ObjectRef, event_ref: ObjectRef, mount: Option, desktop: ObjectRef, ) -> Result { let mut event = cx.store().payload::(event_ref, "Event")?.clone(); if let Some(weak) = event.dispatch && cx.upgrade(weak)?.is_some() { return Err(invalid("Event is already being dispatched")); } let Value::Text(kind) = cx.store().get_attr(event_ref, "kind")? else { return Err(invalid("Event kind must be text")); }; if kind.is_empty() || kind.len() > 128 { return Err(invalid("invalid event kind")); } let mut route = vec![(owner, mount)]; if let Some(mount) = mount { let target = cx.store().payload::(mount, "Mount")?.clone(); if !target.mounted || target.block != owner { return Err(invalid("event context does not belong to this Block")); } let mut parent = target.parent; let mut seen = std::collections::HashSet::from([mount]); while let Some(weak) = parent { let ancestor = cx .upgrade(weak)? .ok_or(invalid("event route has expired"))?; if !seen.insert(ancestor) { return Err(invalid("cyclic event route")); } let data = cx.store().payload::(ancestor, "Mount")?; if !data.mounted { return Err(invalid("event route is detached")); } route.push((data.block, Some(ancestor))); parent = data.parent; } if let Some(weak) = target.page { let page = cx.upgrade(weak)?.ok_or(invalid("event Page has expired"))?; route.push((page, None)); } } let guard = cx.new_object()?; let mut calls = Vec::new(); let mut group = 0; // Each occurrence is its own propagation group, even for shared Blocks. let traversal = route .iter() .enumerate() .rev() .map(|(index, entry)| (index, entry, true)) .chain( route .iter() .enumerate() .map(|(index, entry)| (index, entry, false)), ); for (index, &(block, current_mount), capture) in traversal { let (_, data) = block::resolve(cx.store(), block)?; let mut handlers = Vec::new(); for token in &data.subscriptions { let token_data = cx.store().payload::(*token, "Connection")?; if token_data.handler.is_some() && token_data.kind == kind && token_data.capture == capture { handlers.push(Handler::Token(*token)); } } if !capture { handlers.insert(0, Handler::ObjectMethod); if kind == "click" && let Some(handler) = data.click { handlers.push(Handler::Legacy(handler)); } } for handler in handlers { if calls.len() >= MAX_DELIVERIES { return Err(invalid("event delivery budget exceeded")); } let mut captures = vec![event_ref, guard, block]; if let Some(mount) = current_mount { captures.push(mount); } match handler { Handler::Token(token) | Handler::Legacy(token) => captures.push(token), Handler::ObjectMethod => {} } let function = cx.allocate(ContextNativeFunction::new( "event delivery", Delivery { event: event_ref, owner: block, mount: current_mount, group, phase: if index == 0 { "target" } else if capture { "capture" } else { "bubble" }, handler, }, captures, ))?; attach_desktop_type(cx, function, "Function", desktop)?; calls.push(function); } // Capture and bubble at the target are one group: stopPropagation at // target capture still permits other target listeners. if index != 0 || !capture { group += 1; } } let finish = cx.allocate(ContextNativeFunction::new( "event finish", Finish(event_ref), vec![event_ref, guard], ))?; calls.push(finish); let driver = crate::vm_sequence::build(cx, calls, "", desktop)?; event.dispatch = Some(cx.store().downgrade(guard)?); event.group = 0; event.stopped_at = None; event.immediate = false; event.prevented = false; cx.replace_payload(event_ref, event)?; cx.set_attr(event_ref, "target", Value::Object(owner))?; cx.set_attr( event_ref, "mount", mount.map(Value::Object).unwrap_or(Value::Nil), )?; Ok(NativeResult::Invoke { function: driver, arguments: vec![], }) }