hypen-engine 0.4.46

A Rust implementation of the Hypen engine
Documentation
use crate::{
    dispatch::{Action, ActionDispatcher},
    error::EngineError,
    ir::{ComponentRegistry, Element, IRNode},
    lifecycle::{ModuleInstance, ResourceCache},
    reactive::{DependencyGraph, Scheduler},
    reconcile::{reconcile_ir_with_ds, InstanceTree, Patch},
    state::StateChange,
};

/// Static null value to avoid cloning state when no module exists
static NULL_STATE: serde_json::Value = serde_json::Value::Null;

/// Callback type for rendering patches to the platform
pub type RenderCallback = Box<dyn Fn(&[Patch]) + Send + Sync>;

/// The main Hypen engine that orchestrates reactive UI rendering.
///
/// # Architecture: Module-Scoped State Binding
///
/// Each `Engine` instance resolves `${state.xxx}` bindings against one active module.
/// This is a deliberate scoping decision — not a limitation. Each rendering scope
/// owns its own state, and multi-module applications compose upward by nesting
/// module instances under a parent (similar to Compose's ViewModel scoping).
///
/// ## Multi-module Applications
///
/// Multi-module apps (e.g., AppState + StatsScreen) work as follows:
///
/// 1. **TypeScript SDK layer** manages multiple `HypenModuleInstance` objects,
///    each with its own Proxy-based state tracking.
///
/// 2. **Cross-module communication** uses `HypenGlobalContext`:
///    ```typescript
///    // In StatsScreen's action handler:
///    const app = context.getModule<AppState>("app");
///    app.setState({ showStats: true });
///    ```
///
/// 3. **State binding scope**: Each module's template binds to its own state.
///    Cross-module data flows through explicit props, callbacks, or action
///    dispatching — keeping each module's reactive graph self-contained:
///    ```hypen
///    // StatsScreen receives app data via props, not state binding
///    StatsView(data: @props.appStats)
///    ```
///
/// ## Design Rationale
///
/// Scoping `${state.xxx}` to a single module per rendering context keeps the
/// reactive dependency graph simple and predictable. Rather than allowing ambient
/// state access across modules (which creates implicit coupling), data flows
/// explicitly through props, actions, and the global context. This makes each
/// module independently testable and avoids the "state lives everywhere" problem.
pub struct Engine {
    /// Component registry for expanding custom components
    component_registry: ComponentRegistry,

    /// Current module instance (provides state for `${state.xxx}` bindings)
    module: Option<ModuleInstance>,

    /// Instance tree (virtual tree, no platform objects)
    tree: InstanceTree,

    /// Reactive dependency graph
    dependencies: DependencyGraph,

    /// Scheduler for dirty nodes
    scheduler: Scheduler,

    /// Action dispatcher
    actions: ActionDispatcher,

    /// Resource cache
    resources: ResourceCache,

    /// Callback to send patches to the platform renderer
    render_callback: Option<RenderCallback>,

    /// Revision counter for remote UI
    revision: u64,

    /// Data source states: provider name → current state (push-updated by plugins)
    /// Used to resolve `$provider.path` bindings (e.g., `$spacetime.messages`)
    data_sources: indexmap::IndexMap<String, serde_json::Value>,
}

impl Engine {
    pub fn new() -> Self {
        Self {
            component_registry: ComponentRegistry::new(),
            module: None,
            tree: InstanceTree::new(),
            dependencies: DependencyGraph::new(),
            scheduler: Scheduler::new(),
            actions: ActionDispatcher::new(),
            resources: ResourceCache::new(),
            render_callback: None,
            revision: 0,
            data_sources: indexmap::IndexMap::new(),
        }
    }

    /// Register a custom component
    pub fn register_component(&mut self, component: crate::ir::Component) {
        self.component_registry.register(component);
    }

    /// Set the component resolver for dynamic component loading
    /// The resolver receives (component_name, context_path) and should return
    /// ResolvedComponent { source, path } or None
    pub fn set_component_resolver<F>(&mut self, resolver: F)
    where
        F: Fn(&str, Option<&str>) -> Option<crate::ir::ResolvedComponent> + Send + Sync + 'static,
    {
        self.component_registry
            .set_resolver(std::sync::Arc::new(resolver));
    }

    /// Set the module instance
    pub fn set_module(&mut self, module: ModuleInstance) {
        self.module = Some(module);
    }

    /// Set the render callback
    pub fn set_render_callback<F>(&mut self, callback: F)
    where
        F: Fn(&[Patch]) + Send + Sync + 'static,
    {
        self.render_callback = Some(Box::new(callback));
    }

    /// Register an action handler
    pub fn on_action<F>(&mut self, action_name: impl Into<String>, handler: F)
    where
        F: Fn(&Action) + Send + Sync + 'static,
    {
        self.actions.on(action_name, handler);
    }

    /// Render an element tree (initial render or full re-render).
    ///
    /// This accepts a flat `Element`. For sources that use ForEach, When,
    /// or If, prefer [`render_ir_node`] which preserves first-class control flow.
    pub fn render(&mut self, element: &Element) {
        let ir_node = IRNode::Element(element.clone());
        self.render_ir_node(&ir_node);
    }

    /// Render an IRNode tree (initial render or full re-render).
    ///
    /// Unlike [`render`], this accepts an `IRNode` (from `ast_to_ir_node()`),
    /// which preserves ForEach, When, and If as first-class control-flow nodes.
    /// This is the same path used by the WASM engine.
    pub fn render_ir_node(&mut self, ir_node: &IRNode) {
        // Expand components (registry needs mutable access for lazy loading)
        let expanded = self.component_registry.expand_ir_node(ir_node);

        // Get current state reference (no clone needed - reconcile only borrows)
        let state: &serde_json::Value = self
            .module
            .as_ref()
            .map(|m| m.get_state())
            .unwrap_or(&NULL_STATE);

        // Clear dependency graph before rebuilding
        self.dependencies.clear();

        // Reconcile and generate patches (dependencies are collected during tree construction)
        let ds = if self.data_sources.is_empty() {
            None
        } else {
            Some(&self.data_sources)
        };
        let patches = reconcile_ir_with_ds(
            &mut self.tree,
            &expanded,
            None,
            state,
            &mut self.dependencies,
            ds,
        );

        // Send patches to renderer
        self.emit_patches(patches);

        self.revision += 1;
    }

    /// Handle a state change notification from the module host
    /// The host keeps the actual state - we just need to know what changed
    pub fn notify_state_change(&mut self, change: &StateChange) {
        // Find affected nodes based on changed paths
        let mut affected_nodes = indexmap::IndexSet::new();
        for path in change.paths() {
            affected_nodes.extend(self.dependencies.get_affected_nodes(path));
        }

        // Mark affected nodes as dirty
        self.scheduler
            .mark_many_dirty(affected_nodes.iter().copied());

        // Re-render dirty subtrees
        // Note: The host will be asked to provide values during rendering via a callback
        self.render_dirty();
    }

    /// Convenience method for backward compatibility / JSON patches
    pub fn update_state(&mut self, state_patch: serde_json::Value) {
        // Update module state FIRST before rendering
        if let Some(module) = &mut self.module {
            module.update_state(state_patch.clone());
        }

        // Then notify and render with the new state
        let change = StateChange::from_json(&state_patch);
        self.notify_state_change(&change);
    }

    /// Dispatch an action
    pub fn dispatch_action(&mut self, action: Action) -> Result<(), EngineError> {
        self.actions.dispatch(&action)
    }

    /// Render only dirty nodes
    fn render_dirty(&mut self) {
        let ds = if self.data_sources.is_empty() {
            None
        } else {
            Some(&self.data_sources)
        };
        let patches = crate::render::render_dirty_nodes_full(
            &mut self.scheduler,
            &mut self.tree,
            self.module.as_ref(),
            &mut self.dependencies,
            ds,
        );

        if !patches.is_empty() {
            self.emit_patches(patches);
            self.revision += 1;
        }
    }

    /// Send patches to the renderer
    fn emit_patches(&self, patches: Vec<Patch>) {
        if let Some(ref callback) = self.render_callback {
            callback(&patches);
        }
    }

    /// Get the current revision number
    pub fn revision(&self) -> u64 {
        self.revision
    }

    /// Get access to the component registry
    pub fn component_registry(&self) -> &ComponentRegistry {
        &self.component_registry
    }

    /// Get mutable access to the component registry
    pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
        &mut self.component_registry
    }

    /// Get access to the resource cache
    pub fn resources(&self) -> &ResourceCache {
        &self.resources
    }

    /// Get mutable access to the resource cache
    pub fn resources_mut(&mut self) -> &mut ResourceCache {
        &mut self.resources
    }

    // ── Data Source Context ─────────────────────────────────────────────

    /// Set (or replace) a named data source context.
    ///
    /// Registers the provider in the dependency graph (if not already known),
    /// stores the data, and re-renders every node bound to `$name.*`.
    /// This is the single entry point for all data source writes —
    /// sparse merging (if needed) should happen at the SDK layer before
    /// calling this method with the merged object.
    ///
    /// # Example
    /// ```ignore
    /// engine.set_context("spacetime", json!({
    ///     "message": [{ "id": 1, "text": "Hello" }],
    ///     "user": [{ "id": 1, "name": "Alice" }]
    /// }));
    /// ```
    pub fn set_context(&mut self, name: &str, data: serde_json::Value) {
        // Ensure provider is registered in the dependency graph
        self.dependencies.register_data_source_provider(name);

        // Find affected nodes using top-level keys as changed paths.
        // A binding like `$spacetime.messages` registers as `ds:spacetime:messages`.
        let mut affected = indexmap::IndexSet::new();
        if let Some(obj) = data.as_object() {
            for key in obj.keys() {
                let namespaced = format!("ds:{}:{}", name, key);
                affected.extend(self.dependencies.get_affected_nodes(&namespaced));
            }
        }
        // Also check for nodes bound to the provider root
        affected.extend(
            self.dependencies
                .get_affected_nodes(&format!("ds:{}", name)),
        );

        // Store data (consumed directly — no clone)
        self.data_sources.insert(name.to_string(), data);

        self.scheduler
            .mark_many_dirty(affected.iter().copied());
        self.render_dirty();
    }

    /// Remove a data source context entirely.
    ///
    /// Drops the provider's state and re-renders bound nodes (they resolve to null).
    pub fn remove_context(&mut self, name: &str) {
        self.data_sources.shift_remove(name);

        // Find all nodes bound to ds:name or ds:name:* and mark dirty.
        // Uses dedicated scan because the `:` separator in data source paths
        // isn't handled by the prefix index's `.`-based parent/child walking.
        let affected = self.dependencies.get_data_source_affected_nodes(name);

        if !affected.is_empty() {
            self.scheduler
                .mark_many_dirty(affected.iter().copied());
            self.render_dirty();
        }
    }

    /// Get read access to the data sources map.
    pub fn data_sources(&self) -> &indexmap::IndexMap<String, serde_json::Value> {
        &self.data_sources
    }
}

impl Default for Engine {
    fn default() -> Self {
        Self::new()
    }
}