Skip to main content

hypen_engine/
engine.rs

1use crate::{
2    dispatch::{Action, ActionDispatcher},
3    error::EngineError,
4    ir::{ComponentRegistry, Element, IRNode},
5    lifecycle::{ModuleInstance, ResourceCache},
6    reactive::{DependencyGraph, Scheduler},
7    reconcile::{reconcile_ir_with_ds, InstanceTree, Patch},
8    state::StateChange,
9};
10
11/// Static null value to avoid cloning state when no module exists
12static NULL_STATE: serde_json::Value = serde_json::Value::Null;
13
14/// Callback type for rendering patches to the platform
15pub type RenderCallback = Box<dyn Fn(&[Patch]) + Send + Sync>;
16
17/// The main Hypen engine that orchestrates reactive UI rendering.
18///
19/// # Architecture: Module-Scoped State Binding
20///
21/// Each `Engine` instance resolves `${state.xxx}` bindings against one active module.
22/// This is a deliberate scoping decision — not a limitation. Each rendering scope
23/// owns its own state, and multi-module applications compose upward by nesting
24/// module instances under a parent (similar to Compose's ViewModel scoping).
25///
26/// ## Multi-module Applications
27///
28/// Multi-module apps (e.g., AppState + StatsScreen) work as follows:
29///
30/// 1. **TypeScript SDK layer** manages multiple `HypenModuleInstance` objects,
31///    each with its own Proxy-based state tracking.
32///
33/// 2. **Cross-module communication** uses `HypenGlobalContext`:
34///    ```typescript
35///    // In StatsScreen's action handler:
36///    const app = context.getModule<AppState>("app");
37///    app.setState({ showStats: true });
38///    ```
39///
40/// 3. **State binding scope**: Each module's template binds to its own state.
41///    Cross-module data flows through explicit props, callbacks, or action
42///    dispatching — keeping each module's reactive graph self-contained:
43///    ```hypen
44///    // StatsScreen receives app data via props, not state binding
45///    StatsView(data: @props.appStats)
46///    ```
47///
48/// ## Design Rationale
49///
50/// Scoping `${state.xxx}` to a single module per rendering context keeps the
51/// reactive dependency graph simple and predictable. Rather than allowing ambient
52/// state access across modules (which creates implicit coupling), data flows
53/// explicitly through props, actions, and the global context. This makes each
54/// module independently testable and avoids the "state lives everywhere" problem.
55pub struct Engine {
56    /// Component registry for expanding custom components
57    component_registry: ComponentRegistry,
58
59    /// Current module instance (provides state for `${state.xxx}` bindings)
60    module: Option<ModuleInstance>,
61
62    /// Instance tree (virtual tree, no platform objects)
63    tree: InstanceTree,
64
65    /// Reactive dependency graph
66    dependencies: DependencyGraph,
67
68    /// Scheduler for dirty nodes
69    scheduler: Scheduler,
70
71    /// Action dispatcher
72    actions: ActionDispatcher,
73
74    /// Resource cache
75    resources: ResourceCache,
76
77    /// Callback to send patches to the platform renderer
78    render_callback: Option<RenderCallback>,
79
80    /// Revision counter for remote UI
81    revision: u64,
82
83    /// Data source states: provider name → current state (push-updated by plugins)
84    /// Used to resolve `$provider.path` bindings (e.g., `$spacetime.messages`)
85    data_sources: indexmap::IndexMap<String, serde_json::Value>,
86}
87
88impl Engine {
89    pub fn new() -> Self {
90        Self {
91            component_registry: ComponentRegistry::new(),
92            module: None,
93            tree: InstanceTree::new(),
94            dependencies: DependencyGraph::new(),
95            scheduler: Scheduler::new(),
96            actions: ActionDispatcher::new(),
97            resources: ResourceCache::new(),
98            render_callback: None,
99            revision: 0,
100            data_sources: indexmap::IndexMap::new(),
101        }
102    }
103
104    /// Register a custom component
105    pub fn register_component(&mut self, component: crate::ir::Component) {
106        self.component_registry.register(component);
107    }
108
109    /// Set the component resolver for dynamic component loading
110    /// The resolver receives (component_name, context_path) and should return
111    /// ResolvedComponent { source, path } or None
112    pub fn set_component_resolver<F>(&mut self, resolver: F)
113    where
114        F: Fn(&str, Option<&str>) -> Option<crate::ir::ResolvedComponent> + Send + Sync + 'static,
115    {
116        self.component_registry
117            .set_resolver(std::sync::Arc::new(resolver));
118    }
119
120    /// Set the module instance
121    pub fn set_module(&mut self, module: ModuleInstance) {
122        self.module = Some(module);
123    }
124
125    /// Set the render callback
126    pub fn set_render_callback<F>(&mut self, callback: F)
127    where
128        F: Fn(&[Patch]) + Send + Sync + 'static,
129    {
130        self.render_callback = Some(Box::new(callback));
131    }
132
133    /// Register an action handler
134    pub fn on_action<F>(&mut self, action_name: impl Into<String>, handler: F)
135    where
136        F: Fn(&Action) + Send + Sync + 'static,
137    {
138        self.actions.on(action_name, handler);
139    }
140
141    /// Render an element tree (initial render or full re-render).
142    ///
143    /// This accepts a flat `Element`. For sources that use ForEach, When,
144    /// or If, prefer [`render_ir_node`] which preserves first-class control flow.
145    pub fn render(&mut self, element: &Element) {
146        let ir_node = IRNode::Element(element.clone());
147        self.render_ir_node(&ir_node);
148    }
149
150    /// Render an IRNode tree (initial render or full re-render).
151    ///
152    /// Unlike [`render`], this accepts an `IRNode` (from `ast_to_ir_node()`),
153    /// which preserves ForEach, When, and If as first-class control-flow nodes.
154    /// This is the same path used by the WASM engine.
155    pub fn render_ir_node(&mut self, ir_node: &IRNode) {
156        // Expand components (registry needs mutable access for lazy loading)
157        let expanded = self.component_registry.expand_ir_node(ir_node);
158
159        // Get current state reference (no clone needed - reconcile only borrows)
160        let state: &serde_json::Value = self
161            .module
162            .as_ref()
163            .map(|m| m.get_state())
164            .unwrap_or(&NULL_STATE);
165
166        // Clear dependency graph before rebuilding
167        self.dependencies.clear();
168
169        // Reconcile and generate patches (dependencies are collected during tree construction)
170        let ds = if self.data_sources.is_empty() {
171            None
172        } else {
173            Some(&self.data_sources)
174        };
175        let patches = reconcile_ir_with_ds(
176            &mut self.tree,
177            &expanded,
178            None,
179            state,
180            &mut self.dependencies,
181            ds,
182        );
183
184        // Send patches to renderer
185        self.emit_patches(patches);
186
187        self.revision += 1;
188    }
189
190    /// Handle a state change notification from the module host
191    /// The host keeps the actual state - we just need to know what changed
192    pub fn notify_state_change(&mut self, change: &StateChange) {
193        // Find affected nodes based on changed paths
194        let mut affected_nodes = indexmap::IndexSet::new();
195        for path in change.paths() {
196            affected_nodes.extend(self.dependencies.get_affected_nodes(path));
197        }
198
199        // Mark affected nodes as dirty
200        self.scheduler
201            .mark_many_dirty(affected_nodes.iter().copied());
202
203        // Re-render dirty subtrees
204        // Note: The host will be asked to provide values during rendering via a callback
205        self.render_dirty();
206    }
207
208    /// Convenience method for backward compatibility / JSON patches
209    pub fn update_state(&mut self, state_patch: serde_json::Value) {
210        // Update module state FIRST before rendering
211        if let Some(module) = &mut self.module {
212            module.update_state(state_patch.clone());
213        }
214
215        // Then notify and render with the new state
216        let change = StateChange::from_json(&state_patch);
217        self.notify_state_change(&change);
218    }
219
220    /// Dispatch an action
221    pub fn dispatch_action(&mut self, action: Action) -> Result<(), EngineError> {
222        self.actions.dispatch(&action)
223    }
224
225    /// Render only dirty nodes
226    fn render_dirty(&mut self) {
227        let ds = if self.data_sources.is_empty() {
228            None
229        } else {
230            Some(&self.data_sources)
231        };
232        let patches = crate::render::render_dirty_nodes_full(
233            &mut self.scheduler,
234            &mut self.tree,
235            self.module.as_ref(),
236            &mut self.dependencies,
237            ds,
238        );
239
240        if !patches.is_empty() {
241            self.emit_patches(patches);
242            self.revision += 1;
243        }
244    }
245
246    /// Send patches to the renderer
247    fn emit_patches(&self, patches: Vec<Patch>) {
248        if let Some(ref callback) = self.render_callback {
249            callback(&patches);
250        }
251    }
252
253    /// Get the current revision number
254    pub fn revision(&self) -> u64 {
255        self.revision
256    }
257
258    /// Get access to the component registry
259    pub fn component_registry(&self) -> &ComponentRegistry {
260        &self.component_registry
261    }
262
263    /// Get mutable access to the component registry
264    pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
265        &mut self.component_registry
266    }
267
268    /// Get access to the resource cache
269    pub fn resources(&self) -> &ResourceCache {
270        &self.resources
271    }
272
273    /// Get mutable access to the resource cache
274    pub fn resources_mut(&mut self) -> &mut ResourceCache {
275        &mut self.resources
276    }
277
278    // ── Data Source Context ─────────────────────────────────────────────
279
280    /// Set (or replace) a named data source context.
281    ///
282    /// Registers the provider in the dependency graph (if not already known),
283    /// stores the data, and re-renders every node bound to `$name.*`.
284    /// This is the single entry point for all data source writes —
285    /// sparse merging (if needed) should happen at the SDK layer before
286    /// calling this method with the merged object.
287    ///
288    /// # Example
289    /// ```ignore
290    /// engine.set_context("spacetime", json!({
291    ///     "message": [{ "id": 1, "text": "Hello" }],
292    ///     "user": [{ "id": 1, "name": "Alice" }]
293    /// }));
294    /// ```
295    pub fn set_context(&mut self, name: &str, data: serde_json::Value) {
296        // Ensure provider is registered in the dependency graph
297        self.dependencies.register_data_source_provider(name);
298
299        // Find affected nodes using top-level keys as changed paths.
300        // A binding like `$spacetime.messages` registers as `ds:spacetime:messages`.
301        let mut affected = indexmap::IndexSet::new();
302        if let Some(obj) = data.as_object() {
303            for key in obj.keys() {
304                let namespaced = format!("ds:{}:{}", name, key);
305                affected.extend(self.dependencies.get_affected_nodes(&namespaced));
306            }
307        }
308        // Also check for nodes bound to the provider root
309        affected.extend(
310            self.dependencies
311                .get_affected_nodes(&format!("ds:{}", name)),
312        );
313
314        // Store data (consumed directly — no clone)
315        self.data_sources.insert(name.to_string(), data);
316
317        self.scheduler
318            .mark_many_dirty(affected.iter().copied());
319        self.render_dirty();
320    }
321
322    /// Remove a data source context entirely.
323    ///
324    /// Drops the provider's state and re-renders bound nodes (they resolve to null).
325    pub fn remove_context(&mut self, name: &str) {
326        self.data_sources.shift_remove(name);
327
328        // Find all nodes bound to ds:name or ds:name:* and mark dirty.
329        // Uses dedicated scan because the `:` separator in data source paths
330        // isn't handled by the prefix index's `.`-based parent/child walking.
331        let affected = self.dependencies.get_data_source_affected_nodes(name);
332
333        if !affected.is_empty() {
334            self.scheduler
335                .mark_many_dirty(affected.iter().copied());
336            self.render_dirty();
337        }
338    }
339
340    /// Get read access to the data sources map.
341    pub fn data_sources(&self) -> &indexmap::IndexMap<String, serde_json::Value> {
342        &self.data_sources
343    }
344}
345
346impl Default for Engine {
347    fn default() -> Self {
348        Self::new()
349    }
350}