Skip to main content

hypen_server/
module.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6
7#[cfg(feature = "async")]
8use std::future::Future;
9#[cfg(feature = "async")]
10use std::pin::Pin;
11
12use crate::context::GlobalContext;
13use crate::error::{Result, SdkError};
14use crate::state::{State, StateContainer};
15
16/// A boxed, pinned, Send future — the return type of async handlers.
17#[cfg(feature = "async")]
18pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
19
20// ---------------------------------------------------------------------------
21// Callback signatures
22// ---------------------------------------------------------------------------
23
24/// Sync lifecycle callback: borrows state, may read global context.
25type SyncLifecycleFn<S> = Box<dyn Fn(&S, Option<&GlobalContext>) + Send + Sync>;
26
27/// Sync action handler: takes mutable state, optional raw JSON payload,
28/// and optional global context.
29type SyncActionFn<S> = Box<dyn Fn(&mut S, Option<&Value>, Option<&GlobalContext>) + Send + Sync>;
30
31/// Async lifecycle callback: takes owned state, returns owned state after `.await`.
32#[cfg(feature = "async")]
33type AsyncLifecycleFn<S> = Box<dyn Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
34
35/// Async action handler: takes owned state, optional raw JSON payload,
36/// and optional global context. Returns owned state after `.await`.
37#[cfg(feature = "async")]
38type AsyncActionFn<S> =
39    Box<dyn Fn(S, Option<Value>, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync>;
40
41/// Error handler: receives the error context and returns whether the error was handled.
42type ErrorHandler = Box<dyn Fn(&ErrorContext) -> ErrorResult + Send + Sync>;
43
44/// Coerce a raw JSON action payload into the handler's declared type `A`.
45///
46/// Shared by `on_action` and `on_action_async`. The tricky case is
47/// `on_action::<()>`: the DOM renderer always dispatches a payload (a
48/// `MouseEvent` snapshot like `{type: "click", clientX, …}`) even when the
49/// DSL author wrote `.onClick(@actions.X)` with no explicit args. A strict
50/// `from_value::<()>(payload)` rejects anything that isn't `null`, so the
51/// handler would silently never fire.
52///
53/// Strategy: try the typed payload first (happy path for `on_action::<MyT>`
54/// when the payload matches), and if that fails, fall back to `Null` — which
55/// succeeds for `A = ()` and any `A: Default` via `#[serde(default)]`. Only
56/// warn when *both* paths fail, since that almost always means a typo in
57/// either the handler's declared type or the DSL dispatch site.
58fn deserialize_action_payload<A: DeserializeOwned>(
59    action_name: &str,
60    raw: Option<&Value>,
61) -> Option<A> {
62    match raw {
63        Some(v) => match serde_json::from_value::<A>(v.clone()) {
64            Ok(a) => Some(a),
65            Err(primary_err) => match serde_json::from_value::<A>(Value::Null) {
66                Ok(a) => Some(a),
67                Err(_) => {
68                    eprintln!(
69                        "[hypen-server] action {:?} payload did not deserialize into declared type: {} (payload was {})",
70                        action_name,
71                        primary_err,
72                        v,
73                    );
74                    None
75                }
76            },
77        },
78        None => serde_json::from_value::<A>(Value::Null).ok(),
79    }
80}
81
82// One slot per concept (sync or async). The `Async` variant is cfg-gated so
83// the enum has just one arm when the feature is off; sync dispatch paths
84// only fire on `Sync`, async paths fire on either. This preserves the
85// original behavior where an async-only handler is invisible to sync callers.
86
87pub(crate) enum LifecycleHandler<S> {
88    Sync(SyncLifecycleFn<S>),
89    #[cfg(feature = "async")]
90    Async(AsyncLifecycleFn<S>),
91}
92
93pub(crate) enum ActionHandler<S> {
94    Sync(SyncActionFn<S>),
95    #[cfg(feature = "async")]
96    Async(AsyncActionFn<S>),
97}
98
99/// Context provided to error handlers.
100pub struct ErrorContext {
101    pub error: SdkError,
102    pub action_name: Option<String>,
103    pub lifecycle: Option<String>,
104}
105
106/// Result from an error handler.
107pub struct ErrorResult {
108    /// If true, the error is considered handled and won't propagate.
109    pub handled: bool,
110}
111
112/// Session information passed to disconnect/reconnect/expire handlers.
113pub use crate::remote::SessionInfo;
114
115/// Disconnect handler: fires when the last connection for a session drops.
116type DisconnectFn<S> = Box<dyn Fn(&S, &SessionInfo) + Send + Sync>;
117/// Reconnect handler: fires when a client resumes a suspended session.
118/// Receives the current state, session info, and the saved state snapshot.
119/// The handler should mutate `state` via the mutable ref if it wants to
120/// restore — by default the caller applies `saved_state` when the handler
121/// does not set `restored` to true.
122type ReconnectFn<S> = Box<dyn Fn(&mut S, &SessionInfo, &serde_json::Value) + Send + Sync>;
123/// Expire handler: fires when a suspended session's TTL elapses.
124type ExpireFn = Box<dyn Fn(&SessionInfo) + Send + Sync>;
125
126// ---------------------------------------------------------------------------
127// ModuleDefinition
128// ---------------------------------------------------------------------------
129
130/// An immutable, built module definition.
131///
132/// Created via `ModuleBuilder::build()`. Contains all state, handlers, and
133/// UI configuration needed to instantiate a running module.
134pub struct ModuleDefinition<S: State> {
135    pub(crate) name: String,
136    pub(crate) initial_state: S,
137    pub(crate) ui_source: Option<String>,
138    pub(crate) ui_file: Option<String>,
139    pub(crate) action_handlers: HashMap<String, ActionHandler<S>>,
140    pub(crate) on_created: Option<LifecycleHandler<S>>,
141    pub(crate) on_activated: Option<LifecycleHandler<S>>,
142    pub(crate) on_deactivated: Option<LifecycleHandler<S>>,
143    pub(crate) on_destroyed: Option<LifecycleHandler<S>>,
144    #[allow(dead_code)]
145    pub(crate) on_error: Option<ErrorHandler>,
146    pub(crate) on_disconnect: Option<DisconnectFn<S>>,
147    pub(crate) on_reconnect: Option<ReconnectFn<S>>,
148    pub(crate) on_expire: Option<ExpireFn>,
149    pub(crate) persist: bool,
150    pub(crate) resource_map: indexmap::IndexMap<String, String>,
151}
152
153impl<S: State> ModuleDefinition<S> {
154    pub fn name(&self) -> &str {
155        &self.name
156    }
157
158    pub fn action_names(&self) -> Vec<String> {
159        self.action_handlers.keys().cloned().collect()
160    }
161
162    pub fn ui_source(&self) -> Option<&str> {
163        self.ui_source.as_deref()
164    }
165
166    pub fn is_persistent(&self) -> bool {
167        self.persist
168    }
169}
170
171// ---------------------------------------------------------------------------
172// ModuleBuilder (fluent API)
173// ---------------------------------------------------------------------------
174
175/// Fluent builder for constructing a `ModuleDefinition`.
176///
177/// # Example
178///
179/// ```rust,ignore
180/// use hypen_server::prelude::*;
181/// use serde::{Deserialize, Serialize};
182///
183/// #[derive(Clone, Default, Serialize, Deserialize)]
184/// struct Counter { count: i32 }
185///
186/// #[derive(Deserialize)]
187/// struct AddPayload { amount: i32 }
188///
189/// let module = ModuleBuilder::new("Counter")
190///     .state(Counter { count: 0 })
191///     .ui(r#"Column { Text("Count: @{state.count}") }"#)
192///     .on_action::<()>("increment", |state, _, _ctx| {
193///         state.count += 1;
194///     })
195///     .on_action::<AddPayload>("add", |state, payload, _ctx| {
196///         state.count += payload.amount;
197///     })
198///     .build();
199/// ```
200pub struct ModuleBuilder<S: State> {
201    name: String,
202    initial_state: Option<S>,
203    ui_source: Option<String>,
204    ui_file: Option<String>,
205    action_handlers: HashMap<String, ActionHandler<S>>,
206    on_created: Option<LifecycleHandler<S>>,
207    on_activated: Option<LifecycleHandler<S>>,
208    on_deactivated: Option<LifecycleHandler<S>>,
209    on_destroyed: Option<LifecycleHandler<S>>,
210    on_error: Option<ErrorHandler>,
211    on_disconnect: Option<DisconnectFn<S>>,
212    on_reconnect: Option<ReconnectFn<S>>,
213    on_expire: Option<ExpireFn>,
214    persist: bool,
215    resource_map: indexmap::IndexMap<String, String>,
216}
217
218impl<S: State> ModuleBuilder<S> {
219    pub fn new(name: impl Into<String>) -> Self {
220        Self {
221            name: name.into(),
222            initial_state: None,
223            ui_source: None,
224            ui_file: None,
225            action_handlers: HashMap::new(),
226            on_created: None,
227            on_activated: None,
228            on_deactivated: None,
229            on_destroyed: None,
230            on_error: None,
231            on_disconnect: None,
232            on_reconnect: None,
233            on_expire: None,
234            persist: false,
235            resource_map: indexmap::IndexMap::new(),
236        }
237    }
238
239    /// Set the initial state for this module.
240    pub fn state(mut self, initial: S) -> Self {
241        self.initial_state = Some(initial);
242        self
243    }
244
245    /// Set the Hypen DSL template as an inline string.
246    ///
247    /// ```rust,ignore
248    /// .ui(r#"
249    ///     Column {
250    ///         Text("Count: @{state.count}")
251    ///         Button("@actions.increment") { Text("+") }
252    ///     }
253    /// "#)
254    /// ```
255    pub fn ui(mut self, source: impl Into<String>) -> Self {
256        self.ui_source = Some(source.into());
257        self
258    }
259
260    /// Load the Hypen DSL template from a file path.
261    ///
262    /// The file will be read when the module is instantiated.
263    pub fn ui_file(mut self, path: impl Into<String>) -> Self {
264        self.ui_file = Some(path.into());
265        self
266    }
267
268    /// Register an action handler with a typed payload.
269    ///
270    /// The type parameter `A` determines how the action payload is
271    /// deserialized. Use `()` for actions that don't carry a payload.
272    ///
273    /// # Examples
274    ///
275    /// ```rust,ignore
276    /// // No payload — use ()
277    /// .on_action::<()>("increment", |state, _, _ctx| {
278    ///     state.count += 1;
279    /// })
280    ///
281    /// // Typed payload — any Deserialize type
282    /// #[derive(Deserialize)]
283    /// struct SetValue { value: i32 }
284    ///
285    /// .on_action::<SetValue>("set_value", |state, payload, _ctx| {
286    ///     state.count = payload.value;
287    /// })
288    ///
289    /// // Raw JSON access
290    /// .on_action::<serde_json::Value>("raw", |state, raw, _ctx| {
291    ///     if let Some(n) = raw.as_i64() { state.count = n as i32; }
292    /// })
293    /// ```
294    pub fn on_action<A>(
295        mut self,
296        name: impl Into<String>,
297        handler: impl Fn(&mut S, A, Option<&GlobalContext>) + Send + Sync + 'static,
298    ) -> Self
299    where
300        A: DeserializeOwned + 'static,
301    {
302        let name = name.into();
303        let action_name = name.clone();
304        let wrapped: SyncActionFn<S> = Box::new(move |state, raw, ctx| {
305            let action = deserialize_action_payload::<A>(&action_name, raw);
306            if let Some(action) = action {
307                handler(state, action, ctx);
308            }
309        });
310        self.action_handlers
311            .insert(name, ActionHandler::Sync(wrapped));
312        self
313    }
314
315    /// Called when the module is first mounted.
316    pub fn on_created<F>(mut self, handler: F) -> Self
317    where
318        F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
319    {
320        self.on_created = Some(LifecycleHandler::Sync(Box::new(handler)));
321        self
322    }
323
324    /// Called when the module is destroyed/unmounted.
325    pub fn on_destroyed<F>(mut self, handler: F) -> Self
326    where
327        F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
328    {
329        self.on_destroyed = Some(LifecycleHandler::Sync(Box::new(handler)));
330        self
331    }
332
333    /// Called every time the module becomes the active route.
334    ///
335    /// Unlike [`on_created`](Self::on_created) (which fires once per
336    /// instance), `on_activated` fires on every mount — including when
337    /// a persisted instance is restored from the [`ManagedRouter`]
338    /// cache. Use this for "refresh on entry" work like reading the
339    /// current path params.
340    pub fn on_activated<F>(mut self, handler: F) -> Self
341    where
342        F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
343    {
344        self.on_activated = Some(LifecycleHandler::Sync(Box::new(handler)));
345        self
346    }
347
348    /// Called every time the module loses the active route — paired
349    /// with [`on_activated`](Self::on_activated). Fires before either
350    /// `on_destroyed` (when not persisting) or the persist cache write.
351    pub fn on_deactivated<F>(mut self, handler: F) -> Self
352    where
353        F: Fn(&S, Option<&GlobalContext>) + Send + Sync + 'static,
354    {
355        self.on_deactivated = Some(LifecycleHandler::Sync(Box::new(handler)));
356        self
357    }
358
359    /// Register an error handler for this module.
360    pub fn on_error<F>(mut self, handler: F) -> Self
361    where
362        F: Fn(&ErrorContext) -> ErrorResult + Send + Sync + 'static,
363    {
364        self.on_error = Some(Box::new(handler));
365        self
366    }
367
368    /// Called when the last WebSocket connection for a session drops
369    /// and the session is about to be suspended. The handler receives
370    /// the current state (read-only) and session information.
371    pub fn on_disconnect<F>(mut self, handler: F) -> Self
372    where
373        F: Fn(&S, &SessionInfo) + Send + Sync + 'static,
374    {
375        self.on_disconnect = Some(Box::new(handler));
376        self
377    }
378
379    /// Called when a client reconnects to a suspended session within the
380    /// TTL window. The handler receives a mutable reference to the
381    /// current (fresh) state, the session info, and the saved-state JSON.
382    /// If the handler wants to restore the saved state, it should
383    /// deserialize and write it into `state`; otherwise the caller will
384    /// apply the saved state automatically.
385    pub fn on_reconnect<F>(mut self, handler: F) -> Self
386    where
387        F: Fn(&mut S, &SessionInfo, &serde_json::Value) + Send + Sync + 'static,
388    {
389        self.on_reconnect = Some(Box::new(handler));
390        self
391    }
392
393    /// Called when a suspended session's TTL elapses without a reconnect.
394    /// The module is destroyed immediately after this handler returns.
395    pub fn on_expire<F>(mut self, handler: F) -> Self
396    where
397        F: Fn(&SessionInfo) + Send + Sync + 'static,
398    {
399        self.on_expire = Some(Box::new(handler));
400        self
401    }
402
403    /// Register a single SVG resource by name.
404    ///
405    /// ```rust,ignore
406    /// .resource("heart", r#"<svg viewBox="0 0 24 24"><path d="M20.84..."/></svg>"#)
407    /// ```
408    pub fn resource(mut self, name: impl Into<String>, svg: impl Into<String>) -> Self {
409        self.resource_map.insert(name.into(), svg.into());
410        self
411    }
412
413    /// Register multiple SVG resources from a map.
414    pub fn resources(mut self, map: indexmap::IndexMap<String, String>) -> Self {
415        self.resource_map.extend(map);
416        self
417    }
418
419    /// Load SVG resources from all `.svg` files in a directory.
420    ///
421    /// Each file becomes a resource named after its filename (without extension).
422    /// For example, `heart.svg` → `@resources.heart`.
423    pub fn resources_dir(mut self, path: impl AsRef<std::path::Path>) -> Self {
424        if let Ok(entries) = std::fs::read_dir(path.as_ref()) {
425            for entry in entries.flatten() {
426                let p = entry.path();
427                if p.extension().and_then(|e| e.to_str()) == Some("svg") {
428                    let name = p
429                        .file_stem()
430                        .and_then(|s| s.to_str())
431                        .unwrap_or("")
432                        .to_string();
433                    if let Ok(svg) = std::fs::read_to_string(&p) {
434                        self.resource_map.insert(name, svg);
435                    }
436                }
437            }
438        } else {
439            eprintln!(
440                "Warning: could not read resources dir: {}",
441                path.as_ref().display()
442            );
443        }
444        self
445    }
446
447    /// Load SVG resources from a JSON file (name → SVG string map).
448    ///
449    /// ```json
450    /// { "heart": "<svg>...</svg>", "search": "<svg>...</svg>" }
451    /// ```
452    pub fn resources_file(mut self, path: impl AsRef<std::path::Path>) -> Self {
453        match std::fs::read_to_string(path.as_ref()) {
454            Ok(json) => {
455                if let Ok(map) = serde_json::from_str::<indexmap::IndexMap<String, String>>(&json) {
456                    self.resource_map.extend(map);
457                } else {
458                    eprintln!(
459                        "Warning: could not parse resources file {}: expected {{name: svg}} map",
460                        path.as_ref().display()
461                    );
462                }
463            }
464            Err(e) => eprintln!(
465                "Warning: could not read resources file {}: {}",
466                path.as_ref().display(),
467                e
468            ),
469        }
470        self
471    }
472
473    pub fn persist(mut self) -> Self {
474        self.persist = true;
475        self
476    }
477
478    /// Register an async action handler with a typed payload.
479    ///
480    /// The handler takes **owned** state, performs async work, and returns
481    /// the (possibly mutated) state. This avoids holding `&mut` across
482    /// `.await` points.
483    ///
484    /// # Example
485    ///
486    /// ```rust,ignore
487    /// .on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
488    ///     Box::pin(async move {
489    ///         state.count += payload.amount;
490    ///         state
491    ///     })
492    /// })
493    /// ```
494    #[cfg(feature = "async")]
495    pub fn on_action_async<A>(
496        mut self,
497        name: impl Into<String>,
498        handler: impl Fn(S, A, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
499    ) -> Self
500    where
501        A: DeserializeOwned + Send + 'static,
502    {
503        let name = name.into();
504        let action_name = name.clone();
505        let wrapped: AsyncActionFn<S> = Box::new(move |state, raw, ctx| {
506            let action = deserialize_action_payload::<A>(&action_name, raw.as_ref());
507            if let Some(action) = action {
508                handler(state, action, ctx)
509            } else {
510                Box::pin(async move { state })
511            }
512        });
513        self.action_handlers
514            .insert(name, ActionHandler::Async(wrapped));
515        self
516    }
517
518    /// Register an async `on_created` lifecycle handler.
519    ///
520    /// Takes owned state and returns it after async work.
521    ///
522    /// ```rust,ignore
523    /// .on_created_async(|mut state, _ctx| {
524    ///     Box::pin(async move {
525    ///         // fetch data, initialize, etc.
526    ///         state
527    ///     })
528    /// })
529    /// ```
530    #[cfg(feature = "async")]
531    pub fn on_created_async(
532        mut self,
533        handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
534    ) -> Self {
535        self.on_created = Some(LifecycleHandler::Async(Box::new(handler)));
536        self
537    }
538
539    /// Register an async `on_destroyed` lifecycle handler.
540    ///
541    /// Takes owned state and returns it after async cleanup.
542    ///
543    /// ```rust,ignore
544    /// .on_destroyed_async(|state, _ctx| {
545    ///     Box::pin(async move {
546    ///         // cleanup, flush logs, etc.
547    ///         state
548    ///     })
549    /// })
550    /// ```
551    #[cfg(feature = "async")]
552    pub fn on_destroyed_async(
553        mut self,
554        handler: impl Fn(S, Option<Arc<GlobalContext>>) -> BoxFuture<S> + Send + Sync + 'static,
555    ) -> Self {
556        self.on_destroyed = Some(LifecycleHandler::Async(Box::new(handler)));
557        self
558    }
559
560    /// Consume the builder and produce an immutable `ModuleDefinition`.
561    pub fn build(self) -> ModuleDefinition<S> {
562        let initial_state = self
563            .initial_state
564            .expect("ModuleBuilder::state() must be called before build()");
565
566        ModuleDefinition {
567            name: self.name,
568            initial_state,
569            ui_source: self.ui_source,
570            ui_file: self.ui_file,
571            action_handlers: self.action_handlers,
572            on_created: self.on_created,
573            on_activated: self.on_activated,
574            on_deactivated: self.on_deactivated,
575            on_destroyed: self.on_destroyed,
576            on_error: self.on_error,
577            on_disconnect: self.on_disconnect,
578            on_reconnect: self.on_reconnect,
579            on_expire: self.on_expire,
580            persist: self.persist,
581            resource_map: self.resource_map,
582        }
583    }
584}
585
586// ---------------------------------------------------------------------------
587// ModuleInstance (running module)
588// ---------------------------------------------------------------------------
589
590/// A running module instance with live state.
591///
592/// Created from a `ModuleDefinition` when the module is mounted.
593/// Wraps a `hypen_engine::Engine` and manages state synchronization.
594pub struct ModuleInstance<S: State> {
595    definition: Arc<ModuleDefinition<S>>,
596    /// Arc-wrapped so engine-side action handlers (registered via
597    /// `engine.on_action(...)` in `new` / `new_with_components`) can
598    /// capture and mutate the state without holding the engine mutex.
599    state: Arc<Mutex<StateContainer<S>>>,
600    engine: Mutex<hypen_engine::Engine>,
601    mounted: Mutex<bool>,
602    /// IR parsed from the module's UI source at construction time, held
603    /// until the first `mount()` / `mount_async()` call. Rendering on
604    /// `mount()` rather than `new()` lets callers wire `on_patches(cb)`
605    /// in between and so capture the initial Create batch — without it
606    /// renderers must re-implement the parse/render dance themselves.
607    pending_ir: Mutex<Option<hypen_engine::ir::IRNode>>,
608    global_context: Option<Arc<GlobalContext>>,
609}
610
611impl<S: State> ModuleInstance<S> {
612    /// Create a new module instance from a definition.
613    pub fn new(
614        definition: Arc<ModuleDefinition<S>>,
615        global_context: Option<Arc<GlobalContext>>,
616    ) -> Result<Self> {
617        let state_container = StateContainer::new(definition.initial_state.clone())?;
618        let mut engine = hypen_engine::Engine::new();
619
620        // Set up the engine module metadata
621        let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
622            .with_actions(definition.action_names())
623            .with_persist(definition.persist);
624
625        let initial_json = state_container.to_json()?;
626        let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
627        engine.set_module(engine_module);
628
629        // Register resources (name → raw SVG)
630        for (name, svg) in &definition.resource_map {
631            engine.register_resource(name, svg);
632        }
633
634        // Parse UI now so any syntax errors surface at construction;
635        // defer the actual `render_ir_node` to `mount()` so callers can
636        // wire `on_patches` and capture the initial Create batch.
637        let pending_ir = if let Some(ref source) = definition.ui_source {
638            Some(Self::parse_ui_source(source)?)
639        } else if let Some(ref path) = definition.ui_file {
640            let source = Self::read_ui_file(path)?;
641            Some(Self::parse_ui_source(&source)?)
642        } else {
643            None
644        };
645
646        let state = Arc::new(Mutex::new(state_container));
647        Self::register_action_handlers_with_engine(
648            &mut engine,
649            Arc::clone(&definition),
650            Arc::clone(&state),
651            global_context.clone(),
652        );
653
654        Ok(Self {
655            definition,
656            state,
657            engine: Mutex::new(engine),
658            mounted: Mutex::new(false),
659            pending_ir: Mutex::new(pending_ir),
660            global_context,
661        })
662    }
663
664    /// Create a new module instance with a component registry for resolving
665    /// child components in the UI template.
666    ///
667    /// This enables DSL like `Column { Feed {} }` where `Feed` is registered
668    /// in the `ComponentRegistry`. Without a registry, the engine cannot resolve
669    /// custom component references in the template.
670    ///
671    /// # Example
672    ///
673    /// ```rust,ignore
674    /// let mut registry = ComponentRegistry::new();
675    /// registry.register("Card", r#"Column { Text("Card") }"#, None);
676    ///
677    /// let instance = ModuleInstance::new_with_components(
678    ///     Arc::new(def),
679    ///     Some(ctx),
680    ///     &registry,
681    /// ).unwrap();
682    /// ```
683    pub fn new_with_components(
684        definition: Arc<ModuleDefinition<S>>,
685        global_context: Option<Arc<GlobalContext>>,
686        components: &crate::discovery::ComponentRegistry,
687    ) -> Result<Self> {
688        let state_container = StateContainer::new(definition.initial_state.clone())?;
689        let mut engine = hypen_engine::Engine::new();
690
691        // Set up the engine module metadata
692        let module_meta = hypen_engine::lifecycle::Module::new(&definition.name)
693            .with_actions(definition.action_names())
694            .with_persist(definition.persist);
695
696        let initial_json = state_container.to_json()?;
697        let engine_module = hypen_engine::ModuleInstance::new(module_meta, initial_json);
698        engine.set_module(engine_module);
699
700        // Register resources (name → raw SVG) so the engine can resolve
701        // `@resources.xxx` references into concrete icon path data. Without
702        // this, Icon create patches carry a literal "@resources.xxx" string
703        // in props and renderers show nothing / a fallback glyph.
704        for (name, svg) in &definition.resource_map {
705            engine.register_resource(name, svg);
706        }
707
708        // Register component resolver so the engine can resolve child components
709        let entries: Vec<(String, String, String)> = components
710            .all()
711            .iter()
712            .map(|e| {
713                (
714                    e.name.clone(),
715                    e.source.clone(),
716                    e.path
717                        .as_ref()
718                        .map(|p| p.to_string_lossy().to_string())
719                        .unwrap_or_default(),
720                )
721            })
722            .collect();
723
724        engine.set_component_resolver(move |name, _ctx_path| {
725            entries
726                .iter()
727                .find(|(n, _, _)| n == name)
728                .map(|(_, source, path)| hypen_engine::ir::ResolvedComponent {
729                    source: source.clone(),
730                    path: path.clone(),
731                    passthrough: false,
732                    lazy: false,
733                })
734        });
735
736        // Parse UI now so any syntax errors surface at construction;
737        // defer the actual `render_ir_node` to `mount()`.
738        let pending_ir = if let Some(ref source) = definition.ui_source {
739            Some(Self::parse_ui_source(source)?)
740        } else if let Some(ref path) = definition.ui_file {
741            let source = Self::read_ui_file(path)?;
742            Some(Self::parse_ui_source(&source)?)
743        } else {
744            None
745        };
746
747        let state = Arc::new(Mutex::new(state_container));
748        Self::register_action_handlers_with_engine(
749            &mut engine,
750            Arc::clone(&definition),
751            Arc::clone(&state),
752            global_context.clone(),
753        );
754
755        Ok(Self {
756            definition,
757            state,
758            engine: Mutex::new(engine),
759            mounted: Mutex::new(false),
760            pending_ir: Mutex::new(pending_ir),
761            global_context,
762        })
763    }
764
765    /// Register all sync action handlers from the definition with the engine
766    /// via [`Engine::on_action`]. Each registration captures the per-instance
767    /// state and global context Arcs and runs the typed handler when fired.
768    /// Async handlers are not registered here — they're invoked directly from
769    /// [`dispatch_action_async`].
770    fn register_action_handlers_with_engine(
771        engine: &mut hypen_engine::Engine,
772        definition: Arc<ModuleDefinition<S>>,
773        state: Arc<Mutex<StateContainer<S>>>,
774        global_context: Option<Arc<GlobalContext>>,
775    ) {
776        for (action_name, handler) in definition.action_handlers.iter() {
777            // Skip async-only handlers — the engine's action dispatcher is
778            // sync, so async handlers are still invoked directly from
779            // `dispatch_action_async`.
780            #[cfg(feature = "async")]
781            if matches!(handler, ActionHandler::Async(_)) {
782                continue;
783            }
784            // Sync handlers route through the engine. The closure captures
785            // Arcs of the definition (to look up the typed handler), the
786            // state (to mutate), and the global context. The user-visible
787            // `dispatch_action` calls `engine.dispatch_action(...)`, which
788            // fires this closure inside the engine's action-scope latch.
789            let definition = Arc::clone(&definition);
790            let state = Arc::clone(&state);
791            let global_context = global_context.clone();
792            let action_name_owned = action_name.clone();
793            engine.on_action(action_name.clone(), move |action| {
794                if let Some(ActionHandler::Sync(handler)) =
795                    definition.action_handlers.get(&action_name_owned)
796                {
797                    let ctx = global_context.as_deref();
798                    let mut state_guard = state.lock().unwrap();
799                    handler(state_guard.get_mut(), action.payload.as_ref(), ctx);
800                }
801            });
802            // Suppress unused-variable warning when the `async` cfg branch
803            // above is the only consumer.
804            let _ = handler;
805        }
806    }
807
808    /// Parse `source` into an `IRNode`. Used by `new` / `new_with_components`
809    /// to prepare the initial render at construction time without firing it
810    /// — the actual `engine.render_ir_node` call is deferred to `mount()`
811    /// so callers can wire `on_patches` first.
812    fn parse_ui_source(source: &str) -> Result<hypen_engine::ir::IRNode> {
813        let doc = hypen_parser::parse_document(source).map_err(|e| {
814            SdkError::Engine(hypen_engine::EngineError::ParseError {
815                source: source.chars().take(80).collect(),
816                message: format!("{e:?}"),
817            })
818        })?;
819        let component = doc
820            .components
821            .first()
822            .ok_or_else(|| SdkError::Component("No component found in UI source".to_string()))?;
823        Ok(hypen_engine::ast_to_ir_node(component))
824    }
825
826    /// Read a UI file from disk into source text.
827    fn read_ui_file(path: &str) -> Result<String> {
828        std::fs::read_to_string(path)
829            .map_err(|e| SdkError::Component(format!("Failed to read UI file '{path}': {e}")))
830    }
831
832    /// Take whatever IR was parsed at construction time and feed it to the
833    /// engine. Idempotent — once consumed the slot is empty, so subsequent
834    /// `mount()` calls don't double-render.
835    fn flush_initial_render(&self) {
836        let ir = {
837            let mut slot = self.pending_ir.lock().unwrap();
838            slot.take()
839        };
840        if let Some(ir) = ir {
841            let mut engine = self.engine.lock().unwrap();
842            engine.render_ir_node(&ir);
843        }
844    }
845
846    /// Mount the module: fire the initial render, then run `on_created`
847    /// (sync only).
848    ///
849    /// The initial render is deferred from construction to `mount()` so
850    /// callers can wire `on_patches(cb)` in between and capture the
851    /// initial Create batch. Subsequent `mount()` calls are no-ops; the
852    /// pending IR slot is consumed on the first call.
853    ///
854    /// Async-only lifecycle handlers are silently skipped here — call
855    /// [`mount_async`](Self::mount_async) to invoke them.
856    pub fn mount(&self) {
857        let mut mounted = self.mounted.lock().unwrap();
858        if !*mounted {
859            *mounted = true;
860            self.flush_initial_render();
861            if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_created {
862                let state = self.state.lock().unwrap();
863                let ctx = self.global_context.as_deref();
864                handler(state.get(), ctx);
865            }
866        }
867    }
868
869    /// Fire `on_activated` (sync only). Called by [`ManagedRouter`] every
870    /// time the module gains the active route slot — including persist
871    /// cache restores. Independent of [`mount`](Self::mount); does not
872    /// flip `mounted`.
873    pub fn activate(&self) {
874        if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_activated {
875            let state = self.state.lock().unwrap();
876            let ctx = self.global_context.as_deref();
877            handler(state.get(), ctx);
878        }
879    }
880
881    /// Fire `on_deactivated` (sync only). Paired with
882    /// [`activate`](Self::activate); fires before `on_destroyed` or
883    /// before the persist-cache write.
884    pub fn deactivate(&self) {
885        if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_deactivated {
886            let state = self.state.lock().unwrap();
887            let ctx = self.global_context.as_deref();
888            handler(state.get(), ctx);
889        }
890    }
891
892    /// Unmount the module (triggers `on_destroyed` if it was registered as sync).
893    ///
894    /// Async-only lifecycle handlers are silently skipped here — call
895    /// [`unmount_async`](Self::unmount_async) to invoke them.
896    pub fn unmount(&self) {
897        let mut mounted = self.mounted.lock().unwrap();
898        if *mounted {
899            if let Some(LifecycleHandler::Sync(ref handler)) = self.definition.on_destroyed {
900                let state = self.state.lock().unwrap();
901                let ctx = self.global_context.as_deref();
902                handler(state.get(), ctx);
903            }
904            *mounted = false;
905        }
906    }
907
908    /// Dispatch an action by name with an optional payload.
909    ///
910    /// Only sync handlers run here. Actions registered as async-only return
911    /// `ActionNotFound` — call [`dispatch_action_async`](Self::dispatch_action_async)
912    /// for async handlers.
913    ///
914    /// The dispatch routes through `engine.dispatch_action(...)`, which fires
915    /// the per-action closure registered in `register_action_handlers_with_engine`
916    /// during construction. That closure runs the typed handler against the
917    /// per-instance state. After dispatch returns, this method diffs the
918    /// state against its pre-handler snapshot and pushes any changed paths
919    /// to the engine via `engine.update_state(None, patch)`.
920    pub fn dispatch_action(&self, name: impl Into<String>, payload: Option<Value>) -> Result<()> {
921        let name = name.into();
922
923        // `__hypen_bind` is the engine-level reserved action used by renderers
924        // for two-way binding. It carries `{path, value}` and writes the value
925        // back into module state at the dotted path. No user handler is
926        // involved — see ENGINE_CONTRACT.md §13.
927        if name == "__hypen_bind" {
928            return self.handle_bind_action(payload);
929        }
930
931        // Reject async-only handlers up front (they can't be invoked from
932        // a sync engine.on_action callback). Sync handlers are registered
933        // on the engine in `register_action_handlers_with_engine`, so an
934        // unknown action that has no engine handler also surfaces here as
935        // `ActionNotFound` — but we let `engine.dispatch_action` produce
936        // that error itself for consistency.
937        #[cfg(feature = "async")]
938        if matches!(
939            self.definition.action_handlers.get(&name),
940            Some(ActionHandler::Async(_))
941        ) {
942            return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
943                name,
944            )));
945        }
946
947        // Snapshot state before handler runs (the engine fires the registered
948        // closure synchronously inside dispatch_action; the closure mutates
949        // our state in place via the captured Arc).
950        {
951            let mut state = self.state.lock().unwrap();
952            state.take_snapshot()?;
953        }
954
955        // Build the action and dispatch through the engine. The engine's
956        // action-scope latch is set during the call, and the registered
957        // closure runs the typed handler against `self.state` (via the
958        // captured Arc).
959        let mut action = hypen_engine::dispatch::Action::new(name.clone());
960        if let Some(p) = payload {
961            action = action.with_payload(p);
962        }
963        {
964            let mut engine = self.engine.lock().unwrap();
965            engine.dispatch_action(action).map_err(SdkError::Engine)?;
966        }
967
968        // Diff state and notify engine of changes
969        self.sync_state_to_engine()?;
970
971        Ok(())
972    }
973
974    /// Get a clone of the current state.
975    pub fn get_state(&self) -> S {
976        self.state.lock().unwrap().get().clone()
977    }
978
979    /// Get the current state as JSON.
980    pub fn get_state_json(&self) -> Result<Value> {
981        self.state.lock().unwrap().to_json()
982    }
983
984    /// Set the render callback for receiving patches.
985    pub fn on_patches<F>(&self, callback: F)
986    where
987        F: Fn(&[hypen_engine::Patch]) + Send + Sync + 'static,
988    {
989        let mut engine = self.engine.lock().unwrap();
990        engine.set_render_callback(callback);
991    }
992
993    /// Check if the module is currently mounted.
994    pub fn is_mounted(&self) -> bool {
995        *self.mounted.lock().unwrap()
996    }
997
998    /// Name of this module.
999    pub fn name(&self) -> &str {
1000        &self.definition.name
1001    }
1002
1003    /// Mount the module asynchronously. Runs whichever variant of
1004    /// `on_created` was registered (sync or async).
1005    #[cfg(feature = "async")]
1006    pub async fn mount_async(&self) {
1007        {
1008            let mut mounted = self.mounted.lock().unwrap();
1009            if *mounted {
1010                return;
1011            }
1012            *mounted = true;
1013        }
1014
1015        // Same deferred-render contract as `mount()`: callers wire
1016        // `on_patches` between `instantiate` and `mount_async` so the
1017        // initial Create batch isn't dropped.
1018        self.flush_initial_render();
1019
1020        match &self.definition.on_created {
1021            Some(LifecycleHandler::Async(handler)) => {
1022                let current_state = self.state.lock().unwrap().get().clone();
1023                let ctx = self.global_context.clone();
1024                let new_state = handler(current_state, ctx).await;
1025                *self.state.lock().unwrap().get_mut() = new_state;
1026            }
1027            Some(LifecycleHandler::Sync(handler)) => {
1028                let state = self.state.lock().unwrap();
1029                let ctx = self.global_context.as_deref();
1030                handler(state.get(), ctx);
1031            }
1032            None => {}
1033        }
1034    }
1035
1036    /// Unmount the module asynchronously. Runs whichever variant of
1037    /// `on_destroyed` was registered (sync or async).
1038    #[cfg(feature = "async")]
1039    pub async fn unmount_async(&self) {
1040        {
1041            let mounted = self.mounted.lock().unwrap();
1042            if !*mounted {
1043                return;
1044            }
1045        }
1046
1047        match &self.definition.on_destroyed {
1048            Some(LifecycleHandler::Async(handler)) => {
1049                let current_state = self.state.lock().unwrap().get().clone();
1050                let ctx = self.global_context.clone();
1051                let new_state = handler(current_state, ctx).await;
1052                *self.state.lock().unwrap().get_mut() = new_state;
1053            }
1054            Some(LifecycleHandler::Sync(handler)) => {
1055                let state = self.state.lock().unwrap();
1056                let ctx = self.global_context.as_deref();
1057                handler(state.get(), ctx);
1058            }
1059            None => {}
1060        }
1061
1062        *self.mounted.lock().unwrap() = false;
1063    }
1064
1065    /// Dispatch an action asynchronously. Runs whichever variant of the
1066    /// handler was registered (sync or async).
1067    #[cfg(feature = "async")]
1068    pub async fn dispatch_action_async(
1069        &self,
1070        name: impl Into<String>,
1071        payload: Option<Value>,
1072    ) -> Result<()> {
1073        let name = name.into();
1074
1075        // `__hypen_bind` is synchronous regardless of async dispatch — see
1076        // the comment in `dispatch_action`.
1077        if name == "__hypen_bind" {
1078            return self.handle_bind_action(payload);
1079        }
1080
1081        // Snapshot state before handler runs
1082        {
1083            let mut state = self.state.lock().unwrap();
1084            state.take_snapshot()?;
1085        }
1086
1087        match self.definition.action_handlers.get(&name) {
1088            Some(ActionHandler::Async(handler)) => {
1089                let current_state = self.state.lock().unwrap().get().clone();
1090                let ctx = self.global_context.clone();
1091                let new_state = handler(current_state, payload, ctx).await;
1092                *self.state.lock().unwrap().get_mut() = new_state;
1093            }
1094            Some(ActionHandler::Sync(handler)) => {
1095                let ctx = self.global_context.as_deref();
1096                let mut state = self.state.lock().unwrap();
1097                handler(state.get_mut(), payload.as_ref(), ctx);
1098            }
1099            None => {
1100                return Err(SdkError::Engine(hypen_engine::EngineError::ActionNotFound(
1101                    name,
1102                )));
1103            }
1104        }
1105
1106        self.sync_state_to_engine()?;
1107        Ok(())
1108    }
1109
1110    /// Internal: handle a `__hypen_bind` action from a renderer.
1111    ///
1112    /// Parses `{path, value}` from the payload, snapshots the current state,
1113    /// applies the bind via JSON round-trip through `S` (so a path that
1114    /// references a field not present on `S` is rejected with
1115    /// [`SdkError::StateSerde`]), and syncs the resulting state back to the
1116    /// engine. See ENGINE_CONTRACT.md §13 for the cross-SDK contract.
1117    fn handle_bind_action(&self, payload: Option<Value>) -> Result<()> {
1118        let payload = payload.ok_or_else(|| SdkError::ActionPayload {
1119            action: "__hypen_bind".into(),
1120            message: "missing payload".into(),
1121        })?;
1122        let obj = payload.as_object().ok_or_else(|| SdkError::ActionPayload {
1123            action: "__hypen_bind".into(),
1124            message: "payload must be an object".into(),
1125        })?;
1126        let path = obj
1127            .get("path")
1128            .and_then(|p| p.as_str())
1129            .ok_or_else(|| SdkError::ActionPayload {
1130                action: "__hypen_bind".into(),
1131                message: "missing 'path' string field".into(),
1132            })?
1133            .to_string();
1134        let value = obj.get("value").cloned().unwrap_or(Value::Null);
1135
1136        {
1137            let mut state = self.state.lock().unwrap();
1138            state.take_snapshot()?;
1139            let new_typed: S = crate::state::apply_bind(state.get(), &path, value)?;
1140            *state.get_mut() = new_typed;
1141        }
1142
1143        self.sync_state_to_engine()
1144    }
1145
1146    /// Internal: compare state against pre-handler snapshot and push changes
1147    /// to the engine.
1148    ///
1149    /// `ModuleInstance` installs its module via `engine.set_module(...)`, which
1150    /// puts it in the engine's primary slot. The corresponding write path
1151    /// uses `None` as the scope, not `Some(self.definition.name)` — passing a
1152    /// non-empty scope routes through `EngineCore::update_state`'s named-module
1153    /// lookup, finds nothing, and silently no-ops the entire update (no dirty
1154    /// marking, no patches emitted). The previous comment here incorrectly
1155    /// claimed `effective_scope` rescued this; `effective_scope` is a read-side
1156    /// filter applied during reconciliation, not a write-side router on
1157    /// `update_state`.
1158    fn sync_state_to_engine(&self) -> Result<()> {
1159        let state = self.state.lock().unwrap();
1160        let paths = state.changed_paths()?;
1161
1162        if !paths.is_empty() {
1163            let patch = state.diff_patch()?;
1164            drop(state); // release lock before engine lock
1165
1166            let mut engine = self.engine.lock().unwrap();
1167            engine.update_state(None, patch);
1168        }
1169
1170        Ok(())
1171    }
1172}
1173
1174/// Create a nested module instance and register its state in the GlobalContext.
1175///
1176/// This creates a `ModuleInstance` from the given definition, registers its
1177/// initial state in the `GlobalContext` under the module's lowercase name,
1178/// and mounts the instance.
1179///
1180/// This is the Rust equivalent of the TypeScript SDK's `createNestedModuleInstances()`.
1181///
1182/// # Example
1183///
1184/// ```rust,ignore
1185/// use hypen_server::prelude::*;
1186///
1187/// let ctx = Arc::new(GlobalContext::new());
1188/// let def = Arc::new(ModuleBuilder::<MyState>::new("Feed")
1189///     .state(MyState::default())
1190///     .build());
1191///
1192/// let instance = create_nested_instance(def, ctx.clone()).unwrap();
1193/// assert!(ctx.has_module("feed"));
1194/// ```
1195pub fn create_nested_instance<S: State>(
1196    definition: Arc<ModuleDefinition<S>>,
1197    context: Arc<GlobalContext>,
1198) -> Result<ModuleInstance<S>> {
1199    let instance = ModuleInstance::new(definition, Some(context.clone()))?;
1200    let name = instance.name().to_lowercase();
1201    let state_json = instance.get_state_json()?;
1202    context.register_module_state(&name, state_json);
1203    instance.mount();
1204    Ok(instance)
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::*;
1210    use serde::{Deserialize, Serialize};
1211    use std::sync::atomic::{AtomicI32, Ordering};
1212
1213    #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1214    struct TestState {
1215        count: i32,
1216        name: String,
1217    }
1218
1219    #[test]
1220    fn test_module_builder_action() {
1221        let def = ModuleBuilder::<TestState>::new("Test")
1222            .state(TestState {
1223                count: 0,
1224                name: "Alice".into(),
1225            })
1226            .on_action::<()>("increment", |state, _, _ctx| {
1227                state.count += 1;
1228            })
1229            .build();
1230
1231        assert_eq!(def.name(), "Test");
1232        assert!(def.action_names().contains(&"increment".to_string()));
1233    }
1234
1235    #[test]
1236    fn test_module_builder_with_ui() {
1237        let def = ModuleBuilder::<TestState>::new("Test")
1238            .state(TestState::default())
1239            .ui(r#"Column { Text("Hello") }"#)
1240            .build();
1241
1242        assert_eq!(def.ui_source(), Some(r#"Column { Text("Hello") }"#));
1243    }
1244
1245    #[test]
1246    fn test_module_instance_dispatch() {
1247        let def = ModuleBuilder::<TestState>::new("Test")
1248            .state(TestState {
1249                count: 0,
1250                name: "Alice".into(),
1251            })
1252            .on_action::<()>("increment", |state, _, _ctx| {
1253                state.count += 1;
1254            })
1255            .on_action::<String>("set_name", |state, name, _ctx| {
1256                state.name = name;
1257            })
1258            .build();
1259
1260        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1261        instance.mount();
1262
1263        instance.dispatch_action("increment", None).unwrap();
1264        assert_eq!(instance.get_state().count, 1);
1265
1266        instance.dispatch_action("increment", None).unwrap();
1267        assert_eq!(instance.get_state().count, 2);
1268
1269        instance
1270            .dispatch_action("set_name", Some(serde_json::json!("Bob")))
1271            .unwrap();
1272        assert_eq!(instance.get_state().name, "Bob");
1273    }
1274
1275    #[test]
1276    fn test_module_lifecycle() {
1277        let created = Arc::new(AtomicI32::new(0));
1278        let destroyed = Arc::new(AtomicI32::new(0));
1279
1280        let created_clone = created.clone();
1281        let destroyed_clone = destroyed.clone();
1282
1283        let def = ModuleBuilder::<TestState>::new("Test")
1284            .state(TestState::default())
1285            .on_created(move |_state, _ctx| {
1286                created_clone.fetch_add(1, Ordering::SeqCst);
1287            })
1288            .on_destroyed(move |_state, _ctx| {
1289                destroyed_clone.fetch_add(1, Ordering::SeqCst);
1290            })
1291            .build();
1292
1293        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1294
1295        assert_eq!(created.load(Ordering::SeqCst), 0);
1296        instance.mount();
1297        assert_eq!(created.load(Ordering::SeqCst), 1);
1298
1299        // Mounting again should be idempotent
1300        instance.mount();
1301        assert_eq!(created.load(Ordering::SeqCst), 1);
1302
1303        instance.unmount();
1304        assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1305
1306        // Unmounting again should be idempotent
1307        instance.unmount();
1308        assert_eq!(destroyed.load(Ordering::SeqCst), 1);
1309    }
1310
1311    #[test]
1312    fn test_module_unknown_action() {
1313        let def = ModuleBuilder::<TestState>::new("Test")
1314            .state(TestState::default())
1315            .build();
1316
1317        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1318        let result = instance.dispatch_action("nonexistent", None);
1319        assert!(result.is_err());
1320    }
1321
1322    #[test]
1323    fn test_module_persist_flag() {
1324        let def = ModuleBuilder::<TestState>::new("Test")
1325            .state(TestState::default())
1326            .persist()
1327            .build();
1328
1329        assert!(def.is_persistent());
1330    }
1331
1332    #[test]
1333    fn test_module_typed_payload() {
1334        #[derive(Deserialize)]
1335        struct AddPayload {
1336            amount: i32,
1337        }
1338
1339        let def = ModuleBuilder::<TestState>::new("TypedTest")
1340            .state(TestState {
1341                count: 10,
1342                name: "test".into(),
1343            })
1344            .on_action::<AddPayload>("add", |state, payload, _ctx| {
1345                state.count += payload.amount;
1346            })
1347            .on_action::<()>("reset", |state, _, _ctx| {
1348                state.count = 0;
1349            })
1350            .build();
1351
1352        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1353        instance.mount();
1354
1355        instance
1356            .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1357            .unwrap();
1358        assert_eq!(instance.get_state().count, 15);
1359
1360        instance.dispatch_action("reset", None).unwrap();
1361        assert_eq!(instance.get_state().count, 0);
1362    }
1363
1364    #[test]
1365    fn test_module_multiple_typed_actions() {
1366        #[derive(Deserialize)]
1367        struct AddPayload {
1368            amount: i32,
1369        }
1370
1371        #[derive(Deserialize)]
1372        struct MultiplyPayload {
1373            factor: i32,
1374        }
1375
1376        let def = ModuleBuilder::<TestState>::new("Mixed")
1377            .state(TestState {
1378                count: 10,
1379                name: "test".into(),
1380            })
1381            .on_action::<()>("reset", |state, _, _ctx| {
1382                state.count = 0;
1383            })
1384            .on_action::<AddPayload>("add", |state, payload, _ctx| {
1385                state.count += payload.amount;
1386            })
1387            .on_action::<MultiplyPayload>("multiply", |state, payload, _ctx| {
1388                state.count *= payload.factor;
1389            })
1390            .build();
1391
1392        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1393        instance.mount();
1394
1395        instance.dispatch_action("reset", None).unwrap();
1396        assert_eq!(instance.get_state().count, 0);
1397
1398        instance
1399            .dispatch_action("add", Some(serde_json::json!({"amount": 5})))
1400            .unwrap();
1401        assert_eq!(instance.get_state().count, 5);
1402
1403        instance
1404            .dispatch_action("multiply", Some(serde_json::json!({"factor": 3})))
1405            .unwrap();
1406        assert_eq!(instance.get_state().count, 15);
1407    }
1408
1409    #[test]
1410    #[should_panic(expected = "ModuleBuilder::state() must be called before build()")]
1411    fn test_module_builder_panics_without_state() {
1412        let _def = ModuleBuilder::<TestState>::new("Test").build();
1413    }
1414
1415    #[test]
1416    fn test_module_invalid_ui_source() {
1417        let def = ModuleBuilder::<TestState>::new("Test")
1418            .state(TestState::default())
1419            .ui("this is not valid {{{{ hypen")
1420            .build();
1421
1422        let result = ModuleInstance::new(Arc::new(def), None);
1423        assert!(result.is_err());
1424    }
1425
1426    #[test]
1427    fn test_module_payload_type_mismatch_is_noop() {
1428        #[derive(Deserialize)]
1429        struct Expected {
1430            #[allow(dead_code)]
1431            value: i32,
1432        }
1433
1434        let def = ModuleBuilder::<TestState>::new("Test")
1435            .state(TestState {
1436                count: 42,
1437                name: "test".into(),
1438            })
1439            .on_action::<Expected>("set", |state, payload, _ctx| {
1440                state.count = payload.value;
1441            })
1442            .build();
1443
1444        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1445        instance.mount();
1446
1447        // Send wrong payload shape — handler should silently skip
1448        instance
1449            .dispatch_action("set", Some(serde_json::json!("wrong type")))
1450            .unwrap();
1451        assert_eq!(instance.get_state().count, 42); // unchanged
1452    }
1453
1454    #[test]
1455    fn test_module_duplicate_action_last_wins() {
1456        let def = ModuleBuilder::<TestState>::new("Test")
1457            .state(TestState {
1458                count: 0,
1459                name: "test".into(),
1460            })
1461            .on_action::<()>("act", |state, _, _ctx| {
1462                state.count += 1;
1463            })
1464            .on_action::<()>("act", |state, _, _ctx| {
1465                state.count += 100;
1466            })
1467            .build();
1468
1469        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1470        instance.dispatch_action("act", None).unwrap();
1471        assert_eq!(instance.get_state().count, 100); // second handler wins
1472    }
1473
1474    #[test]
1475    fn test_module_ui_file() {
1476        let dir = std::env::temp_dir().join("hypen_test_ui_file");
1477        let _ = std::fs::remove_dir_all(&dir);
1478        std::fs::create_dir_all(&dir).unwrap();
1479
1480        let path = dir.join("counter.hypen");
1481        std::fs::write(&path, r#"Column { Text("Hello") }"#).unwrap();
1482
1483        let def = ModuleBuilder::<TestState>::new("Test")
1484            .state(TestState::default())
1485            .ui_file(path.to_str().unwrap())
1486            .build();
1487
1488        let instance = ModuleInstance::new(Arc::new(def), None);
1489        assert!(instance.is_ok());
1490
1491        let _ = std::fs::remove_dir_all(&dir);
1492    }
1493
1494    #[test]
1495    fn test_module_ui_file_not_found() {
1496        let def = ModuleBuilder::<TestState>::new("Test")
1497            .state(TestState::default())
1498            .ui_file("/tmp/hypen_no_such_file.hypen")
1499            .build();
1500
1501        let result = ModuleInstance::new(Arc::new(def), None);
1502        assert!(result.is_err());
1503    }
1504
1505    #[test]
1506    fn test_module_dispatch_without_mount() {
1507        let def = ModuleBuilder::<TestState>::new("Test")
1508            .state(TestState {
1509                count: 0,
1510                name: "test".into(),
1511            })
1512            .on_action::<()>("inc", |state, _, _ctx| {
1513                state.count += 1;
1514            })
1515            .build();
1516
1517        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1518        // Don't mount — dispatch should still work
1519        instance.dispatch_action("inc", None).unwrap();
1520        assert_eq!(instance.get_state().count, 1);
1521    }
1522
1523    #[test]
1524    fn test_module_raw_json_action() {
1525        let def = ModuleBuilder::<TestState>::new("RawTest")
1526            .state(TestState {
1527                count: 0,
1528                name: "test".into(),
1529            })
1530            .on_action::<Value>("set_count", |state, payload, _ctx| {
1531                if let Some(n) = payload.as_i64() {
1532                    state.count = n as i32;
1533                }
1534            })
1535            .build();
1536
1537        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1538        instance.mount();
1539
1540        instance
1541            .dispatch_action("set_count", Some(serde_json::json!(42)))
1542            .unwrap();
1543        assert_eq!(instance.get_state().count, 42);
1544    }
1545
1546    #[test]
1547    fn test_nested_module_registers_in_context() {
1548        let ctx = Arc::new(GlobalContext::new());
1549
1550        let def = Arc::new(
1551            ModuleBuilder::<TestState>::new("Feed")
1552                .state(TestState {
1553                    count: 0,
1554                    name: "feed".into(),
1555                })
1556                .build(),
1557        );
1558
1559        let instance = create_nested_instance(def, ctx.clone()).unwrap();
1560
1561        // Should be registered in context under lowercase name
1562        assert!(ctx.has_module("feed"));
1563        let state = ctx.get_module_state("feed").unwrap();
1564        assert_eq!(state["name"], "feed");
1565
1566        instance.unmount();
1567    }
1568
1569    #[test]
1570    fn test_nested_module_actions_work() {
1571        let ctx = Arc::new(GlobalContext::new());
1572
1573        let def = Arc::new(
1574            ModuleBuilder::<TestState>::new("Counter")
1575                .state(TestState {
1576                    count: 0,
1577                    name: String::new(),
1578                })
1579                .on_action::<()>("increment", |state, _, _| {
1580                    state.count += 1;
1581                })
1582                .build(),
1583        );
1584
1585        let instance = create_nested_instance(def, ctx.clone()).unwrap();
1586        instance.dispatch_action("increment", None).unwrap();
1587        assert_eq!(instance.get_state().count, 1);
1588
1589        instance.unmount();
1590    }
1591
1592    #[test]
1593    fn test_multiple_nested_modules() {
1594        let ctx = Arc::new(GlobalContext::new());
1595
1596        let feed_def = Arc::new(
1597            ModuleBuilder::<TestState>::new("Feed")
1598                .state(TestState {
1599                    count: 0,
1600                    name: "feed".into(),
1601                })
1602                .build(),
1603        );
1604        let cart_def = Arc::new(
1605            ModuleBuilder::<TestState>::new("Cart")
1606                .state(TestState {
1607                    count: 5,
1608                    name: "cart".into(),
1609                })
1610                .build(),
1611        );
1612
1613        let _feed = create_nested_instance(feed_def, ctx.clone()).unwrap();
1614        let _cart = create_nested_instance(cart_def, ctx.clone()).unwrap();
1615
1616        assert!(ctx.has_module("feed"));
1617        assert!(ctx.has_module("cart"));
1618        assert_eq!(ctx.module_names().len(), 2);
1619
1620        let global = ctx.global_state();
1621        assert_eq!(global["feed"]["name"], "feed");
1622        assert_eq!(global["cart"]["count"], 5);
1623    }
1624
1625    #[test]
1626    fn test_new_with_components_resolves_child() {
1627        use crate::discovery::ComponentRegistry;
1628
1629        let mut registry = ComponentRegistry::new();
1630        registry.register("Card", r#"Column { Text("Card content") }"#, None);
1631
1632        let def = ModuleBuilder::<TestState>::new("Parent")
1633            .state(TestState {
1634                count: 0,
1635                name: "parent".into(),
1636            })
1637            // Template references the "Card" component
1638            .ui(r#"Column { Card {} }"#)
1639            .build();
1640
1641        // With components — should succeed and resolve Card
1642        let instance = ModuleInstance::new_with_components(Arc::new(def), None, &registry).unwrap();
1643        instance.mount();
1644        assert_eq!(instance.get_state().name, "parent");
1645    }
1646
1647    #[test]
1648    fn test_new_with_components_empty_registry() {
1649        use crate::discovery::ComponentRegistry;
1650
1651        let registry = ComponentRegistry::new();
1652
1653        let def = ModuleBuilder::<TestState>::new("Simple")
1654            .state(TestState::default())
1655            .ui(r#"Column { Text("Hello") }"#)
1656            .build();
1657
1658        let instance = ModuleInstance::new_with_components(Arc::new(def), None, &registry).unwrap();
1659        instance.mount();
1660        assert!(instance.is_mounted());
1661    }
1662
1663    /// Regression test: `new_with_components` must register `resource_map`
1664    /// with the engine, otherwise `Icon(@resources.xxx)` references are not
1665    /// resolved and renderers see the raw reference string. This mirrors the
1666    /// same bug that was present in the Go server's sendSessionAndInitialTree.
1667    #[test]
1668    fn test_new_with_components_registers_resources() {
1669        use crate::discovery::ComponentRegistry;
1670
1671        let registry = ComponentRegistry::new();
1672        let heart_svg = r#"<svg viewBox="0 0 24 24"><path d="M12 21s-7-4.5-7-11a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 6.5-7 11-7 11z" stroke="currentColor"/></svg>"#;
1673
1674        let def = ModuleBuilder::<TestState>::new("WithIcons")
1675            .state(TestState::default())
1676            .ui(r#"Icon(@resources.heart)"#)
1677            .resource("heart", heart_svg)
1678            .build();
1679
1680        let instance = ModuleInstance::new_with_components(Arc::new(def), None, &registry).unwrap();
1681
1682        // Directly inspect the engine's resource registry — this is the
1683        // smoking-gun check for the fix. Before the fix, the registry was
1684        // empty here because new_with_components skipped the registration
1685        // loop that `new` has.
1686        let engine = instance.engine.lock().unwrap();
1687        let resolved = engine.resource_registry().resolve("heart");
1688        assert!(
1689            resolved.is_some(),
1690            "heart resource was not registered with the engine in new_with_components — \
1691             Icon(@resources.heart) would render as a raw reference string"
1692        );
1693        let data = resolved.unwrap();
1694        assert!(
1695            !data.paths.is_empty(),
1696            "resolved heart icon has no parsed paths"
1697        );
1698        assert!(
1699            data.paths[0].d.starts_with("M12 21"),
1700            "resolved heart path d did not round-trip: {:?}",
1701            data.paths[0].d
1702        );
1703    }
1704
1705    // -----------------------------------------------------------------------
1706    // __hypen_bind two-way binding tests
1707    //
1708    // The `__hypen_bind` action is dispatched by renderers when a form
1709    // control's value changes. The SDK auto-handles it by writing the
1710    // payload's `value` into module state at the dotted `path`. See
1711    // ENGINE_CONTRACT.md §13 for the cross-SDK contract.
1712    // -----------------------------------------------------------------------
1713
1714    #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1715    struct BindState {
1716        name: String,
1717        count: i32,
1718        nested: Nested,
1719    }
1720
1721    #[derive(Clone, Default, Serialize, Deserialize, Debug, PartialEq)]
1722    struct Nested {
1723        flag: bool,
1724    }
1725
1726    #[test]
1727    fn test_hypen_bind_writes_value_at_path() {
1728        let def = ModuleBuilder::<BindState>::new("BindTest")
1729            .state(BindState::default())
1730            .build();
1731        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1732
1733        instance
1734            .dispatch_action(
1735                "__hypen_bind",
1736                Some(serde_json::json!({"path": "name", "value": "Alice"})),
1737            )
1738            .unwrap();
1739
1740        assert_eq!(instance.get_state().name, "Alice");
1741    }
1742
1743    #[test]
1744    fn test_hypen_bind_writes_typed_number() {
1745        let def = ModuleBuilder::<BindState>::new("BindTest")
1746            .state(BindState::default())
1747            .build();
1748        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1749
1750        instance
1751            .dispatch_action(
1752                "__hypen_bind",
1753                Some(serde_json::json!({"path": "count", "value": 42})),
1754            )
1755            .unwrap();
1756
1757        assert_eq!(instance.get_state().count, 42);
1758    }
1759
1760    #[test]
1761    fn test_hypen_bind_writes_nested_path() {
1762        let def = ModuleBuilder::<BindState>::new("BindTest")
1763            .state(BindState::default())
1764            .build();
1765        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1766
1767        instance
1768            .dispatch_action(
1769                "__hypen_bind",
1770                Some(serde_json::json!({"path": "nested.flag", "value": true})),
1771            )
1772            .unwrap();
1773
1774        assert!(instance.get_state().nested.flag);
1775    }
1776
1777    #[test]
1778    fn test_hypen_bind_invalid_path_returns_error() {
1779        // Binding to a field that doesn't exist on the typed state should
1780        // surface as a state-serde error rather than silently corrupting state.
1781        let def = ModuleBuilder::<BindState>::new("BindTest")
1782            .state(BindState {
1783                name: "before".into(),
1784                count: 0,
1785                nested: Nested::default(),
1786            })
1787            .build();
1788        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1789
1790        let result = instance.dispatch_action(
1791            "__hypen_bind",
1792            Some(serde_json::json!({"path": "name", "value": 42})), // wrong type for `name`
1793        );
1794        assert!(result.is_err(), "type-mismatched bind should fail");
1795        // State must be untouched on failure.
1796        assert_eq!(instance.get_state().name, "before");
1797    }
1798
1799    #[test]
1800    fn test_hypen_bind_missing_path_returns_error() {
1801        let def = ModuleBuilder::<BindState>::new("BindTest")
1802            .state(BindState::default())
1803            .build();
1804        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1805
1806        let result = instance.dispatch_action(
1807            "__hypen_bind",
1808            Some(serde_json::json!({"value": "missing path"})),
1809        );
1810        assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1811    }
1812
1813    #[test]
1814    fn test_hypen_bind_missing_payload_returns_error() {
1815        let def = ModuleBuilder::<BindState>::new("BindTest")
1816            .state(BindState::default())
1817            .build();
1818        let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1819
1820        let result = instance.dispatch_action("__hypen_bind", None);
1821        assert!(matches!(result, Err(SdkError::ActionPayload { .. })));
1822    }
1823
1824    // -----------------------------------------------------------------------
1825    // Async handler tests (behind "async" feature)
1826    // -----------------------------------------------------------------------
1827
1828    #[cfg(feature = "async")]
1829    mod async_tests {
1830        use super::*;
1831
1832        #[derive(Clone, Default, Serialize, Deserialize, Debug)]
1833        struct AsyncState {
1834            count: i32,
1835            name: String,
1836        }
1837
1838        #[tokio::test]
1839        async fn test_async_action_handler() {
1840            let def = ModuleBuilder::<AsyncState>::new("AsyncTest")
1841                .state(AsyncState {
1842                    count: 0,
1843                    name: "test".into(),
1844                })
1845                .on_action_async::<()>("increment", |mut state, _, _ctx| {
1846                    Box::pin(async move {
1847                        state.count += 1;
1848                        state
1849                    })
1850                })
1851                .build();
1852
1853            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1854            instance.mount();
1855
1856            instance
1857                .dispatch_action_async("increment", None)
1858                .await
1859                .unwrap();
1860            assert_eq!(instance.get_state().count, 1);
1861
1862            instance
1863                .dispatch_action_async("increment", None)
1864                .await
1865                .unwrap();
1866            assert_eq!(instance.get_state().count, 2);
1867        }
1868
1869        #[tokio::test]
1870        async fn test_async_typed_payload() {
1871            #[derive(Deserialize)]
1872            struct AddPayload {
1873                amount: i32,
1874            }
1875
1876            let def = ModuleBuilder::<AsyncState>::new("AsyncTyped")
1877                .state(AsyncState {
1878                    count: 10,
1879                    name: "test".into(),
1880                })
1881                .on_action_async::<AddPayload>("add", |mut state, payload, _ctx| {
1882                    Box::pin(async move {
1883                        state.count += payload.amount;
1884                        state
1885                    })
1886                })
1887                .build();
1888
1889            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1890            instance.mount();
1891
1892            instance
1893                .dispatch_action_async("add", Some(serde_json::json!({"amount": 5})))
1894                .await
1895                .unwrap();
1896            assert_eq!(instance.get_state().count, 15);
1897        }
1898
1899        #[tokio::test]
1900        async fn test_async_falls_back_to_sync() {
1901            let def = ModuleBuilder::<AsyncState>::new("Fallback")
1902                .state(AsyncState {
1903                    count: 0,
1904                    name: "test".into(),
1905                })
1906                .on_action::<()>("sync_inc", |state, _, _ctx| {
1907                    state.count += 1;
1908                })
1909                .build();
1910
1911            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1912
1913            // dispatch_action_async should fall back to the sync handler
1914            instance
1915                .dispatch_action_async("sync_inc", None)
1916                .await
1917                .unwrap();
1918            assert_eq!(instance.get_state().count, 1);
1919        }
1920
1921        #[tokio::test]
1922        async fn test_async_on_created() {
1923            let def = ModuleBuilder::<AsyncState>::new("AsyncCreated")
1924                .state(AsyncState {
1925                    count: 0,
1926                    name: "test".into(),
1927                })
1928                .on_created_async(|mut state, _ctx| {
1929                    Box::pin(async move {
1930                        state.count = 42;
1931                        state.name = "initialized".into();
1932                        state
1933                    })
1934                })
1935                .build();
1936
1937            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1938            instance.mount_async().await;
1939
1940            assert_eq!(instance.get_state().count, 42);
1941            assert_eq!(instance.get_state().name, "initialized");
1942        }
1943
1944        #[tokio::test]
1945        async fn test_async_on_destroyed() {
1946            let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1947            let destroyed_clone = destroyed.clone();
1948
1949            let def = ModuleBuilder::<AsyncState>::new("AsyncDestroyed")
1950                .state(AsyncState {
1951                    count: 0,
1952                    name: "test".into(),
1953                })
1954                .on_destroyed_async(move |state, _ctx| {
1955                    let flag = destroyed_clone.clone();
1956                    Box::pin(async move {
1957                        flag.store(true, std::sync::atomic::Ordering::SeqCst);
1958                        state
1959                    })
1960                })
1961                .build();
1962
1963            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1964            instance.mount();
1965            assert!(!destroyed.load(std::sync::atomic::Ordering::SeqCst));
1966
1967            instance.unmount_async().await;
1968            assert!(destroyed.load(std::sync::atomic::Ordering::SeqCst));
1969            assert!(!instance.is_mounted());
1970        }
1971
1972        #[tokio::test]
1973        async fn test_async_mount_idempotent() {
1974            let call_count = Arc::new(std::sync::atomic::AtomicI32::new(0));
1975            let cc = call_count.clone();
1976
1977            let def = ModuleBuilder::<AsyncState>::new("Idempotent")
1978                .state(AsyncState::default())
1979                .on_created_async(move |state, _ctx| {
1980                    let count = cc.clone();
1981                    Box::pin(async move {
1982                        count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1983                        state
1984                    })
1985                })
1986                .build();
1987
1988            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
1989            instance.mount_async().await;
1990            instance.mount_async().await; // second call is noop
1991
1992            assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
1993        }
1994
1995        #[tokio::test]
1996        async fn test_async_dispatch_unknown_action() {
1997            let def = ModuleBuilder::<AsyncState>::new("Unknown")
1998                .state(AsyncState::default())
1999                .build();
2000
2001            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
2002            let result = instance.dispatch_action_async("nonexistent", None).await;
2003            assert!(result.is_err());
2004        }
2005
2006        #[tokio::test]
2007        async fn test_async_mixed_sync_and_async_actions() {
2008            #[derive(Deserialize)]
2009            struct SetName {
2010                name: String,
2011            }
2012
2013            let def = ModuleBuilder::<AsyncState>::new("Mixed")
2014                .state(AsyncState {
2015                    count: 0,
2016                    name: "init".into(),
2017                })
2018                .on_action::<()>("sync_inc", |state, _, _ctx| {
2019                    state.count += 1;
2020                })
2021                .on_action_async::<SetName>("async_set_name", |mut state, payload, _ctx| {
2022                    Box::pin(async move {
2023                        state.name = payload.name;
2024                        state
2025                    })
2026                })
2027                .build();
2028
2029            let instance = ModuleInstance::new(Arc::new(def), None).unwrap();
2030            instance.mount();
2031
2032            // Use async dispatch for both sync and async handlers
2033            instance
2034                .dispatch_action_async("sync_inc", None)
2035                .await
2036                .unwrap();
2037            assert_eq!(instance.get_state().count, 1);
2038
2039            instance
2040                .dispatch_action_async("async_set_name", Some(serde_json::json!({"name": "Alice"})))
2041                .await
2042                .unwrap();
2043            assert_eq!(instance.get_state().name, "Alice");
2044        }
2045    }
2046}