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}