hypen_engine/engine.rs
1use crate::{
2 dispatch::{Action, ActionDispatcher},
3 engine_core::EngineCore,
4 error::EngineError,
5 ir::{ComponentRegistry, IRNode, ResourceRegistry},
6 lifecycle::{ModuleInstance, ResourceCache},
7 reconcile::Patch,
8 state::StateChange,
9};
10use indexmap::IndexMap;
11use std::sync::{Arc, Weak};
12
13/// Callback type for rendering patches to the platform
14pub type RenderCallback = Box<dyn Fn(&[Patch]) + Send + Sync>;
15
16/// The main Hypen engine that orchestrates reactive UI rendering.
17///
18/// # Architecture: Shared Engine, Namespaced State
19///
20/// Multi-module applications share a **single `Engine` instance**. Each module
21/// registers its state under a lowercase-name prefix (e.g., `search`) so the
22/// engine holds one merged state tree. The SDK layer (`HypenModuleInstance` in
23/// TypeScript, `ModuleInstance` in Kotlin/Swift/Go) manages module lifecycles,
24/// action routing, and cross-module communication -- the engine itself only sees
25/// a flat JSON state and resolves `@{state.xxx}` bindings against it.
26///
27/// ## Multi-module Applications
28///
29/// 1. **One shared engine** -- all modules register their namespaced state into
30/// it. Nested modules are expected and use the same engine while keeping
31/// their own namespaced state.
32///
33/// 2. **SDK-managed module instances** -- each SDK maintains multiple module
34/// instances (e.g., `HypenModuleInstance` in TypeScript), each with its own
35/// state tracking (Proxy in TS, ObservableState in Kotlin/Swift/Go).
36///
37/// 3. **Cross-module communication** uses `HypenGlobalContext`:
38/// ```typescript
39/// // In Search's action handler:
40/// const app = context.getModule<AppState>("App");
41/// const currentView = app.getState().currentView;
42/// app.setState({ showSearch: true });
43/// ```
44///
45/// 4. **State binding scope**: Each module's template binds to its own state
46/// via the namespace prefix. Cross-module data flows through the global
47/// context, keeping each module's reactive graph self-contained.
48///
49/// ## Design Rationale
50///
51/// Keeping one shared engine avoids duplicating the parser, reconciler, and
52/// dependency graph per module. Namespaced state keys ensure modules don't
53/// shadow each other's fields while the engine's path-based dependency tracker
54/// naturally scopes re-renders to only the affected module's bindings.
55pub struct Engine {
56 /// Shared core: component registry, resource registry, module state,
57 /// instance tree, dependency graph, scheduler, data sources, etc.
58 core: EngineCore,
59
60 /// Action dispatcher
61 actions: ActionDispatcher,
62
63 /// Resource cache
64 resources: ResourceCache,
65
66 /// Callback to send patches to the platform renderer
67 render_callback: Option<RenderCallback>,
68
69 /// Weak handle to the primary module's state Arc as of the last
70 /// `notify_state_change` render. Used to detect duplicate notifications
71 /// (e.g., an SDK Proxy microtask firing after `update_state` already
72 /// processed the same change).
73 ///
74 /// A `Weak` is used (rather than a raw `usize` pointer) to avoid the
75 /// ABA problem: when the original Arc is dropped and a new Arc happens
76 /// to be allocated at the same address, pointer-identity comparison
77 /// would silently treat them as equal. `Weak` keeps the ArcInner
78 /// header alive so `Arc::ptr_eq` on an upgraded `Weak` is unambiguous:
79 /// a true result means literally the same allocation.
80 last_state_weak: Weak<serde_json::Value>,
81}
82
83impl Engine {
84 pub fn new() -> Self {
85 Self {
86 core: EngineCore::new(),
87 actions: ActionDispatcher::new(),
88 resources: ResourceCache::new(),
89 render_callback: None,
90 last_state_weak: Weak::new(),
91 }
92 }
93
94 /// Register a custom component
95 pub fn register_component(&mut self, component: crate::ir::Component) {
96 self.core.register_component(component);
97 }
98
99 /// Set the component resolver for dynamic component loading
100 /// The resolver receives (component_name, context_path) and should return
101 /// ResolvedComponent { source, path } or None
102 pub fn set_component_resolver<F>(&mut self, resolver: F)
103 where
104 F: Fn(&str, Option<&str>) -> Option<crate::ir::ResolvedComponent> + Send + Sync + 'static,
105 {
106 self.core.set_component_resolver(resolver);
107 }
108
109 /// Register a single resource from raw SVG content.
110 pub fn register_resource(&mut self, name: &str, svg: &str) {
111 self.core.register_resource(name, svg);
112 }
113
114 /// Register multiple resources from a name -> SVG map.
115 pub fn register_resources(&mut self, map: IndexMap<String, String>) {
116 self.core.register_resources(map);
117 }
118
119 /// Get access to the resource registry
120 pub fn resource_registry(&self) -> &ResourceRegistry {
121 &self.core.resource_registry
122 }
123
124 /// Get mutable access to the resource registry
125 pub fn resource_registry_mut(&mut self) -> &mut ResourceRegistry {
126 &mut self.core.resource_registry
127 }
128
129 /// Backwards-compatible alias
130 pub fn icon_registry(&self) -> &ResourceRegistry {
131 &self.core.resource_registry
132 }
133
134 /// Backwards-compatible alias
135 pub fn icon_registry_mut(&mut self) -> &mut ResourceRegistry {
136 &mut self.core.resource_registry
137 }
138
139 /// Set the primary module instance (backward-compatible single-module API).
140 pub fn set_module(&mut self, module: ModuleInstance) {
141 self.core.set_module(module);
142 }
143
144 /// Register a named module for multi-module apps.
145 ///
146 /// The engine scopes `@{state.xxx}` bindings to this module's state when
147 /// rendering a component whose source starts with `module <name> { ... }`.
148 /// The `name` should be lowercase (e.g., `"search"`).
149 ///
150 /// Also registers the module's declared actions in the action->module map
151 /// so that `update_state` after `dispatch_action` automatically routes
152 /// to the correct module.
153 pub fn register_module(&mut self, name: impl Into<String>, module: ModuleInstance) {
154 self.core.register_module(name, module);
155 }
156
157
158 /// Get a named module's state (for reconciler lookups).
159 pub fn get_module_state(&self, name: &str) -> Option<&serde_json::Value> {
160 self.core.get_module_state(name)
161 }
162
163 /// Get access to all registered modules (for reconciler).
164 pub fn modules(&self) -> &IndexMap<String, ModuleInstance> {
165 &self.core.modules
166 }
167
168 /// Set the render callback
169 pub fn set_render_callback<F>(&mut self, callback: F)
170 where
171 F: Fn(&[Patch]) + Send + Sync + 'static,
172 {
173 self.render_callback = Some(Box::new(callback));
174 }
175
176 /// Register an action handler
177 pub fn on_action<F>(&mut self, action_name: impl Into<String>, handler: F)
178 where
179 F: Fn(&Action) + Send + Sync + 'static,
180 {
181 self.actions.on(action_name, handler);
182 }
183
184 /// Render an element tree (initial render or full re-render).
185 ///
186 /// This accepts a flat `Element`. For sources that use ForEach, When,
187 /// or If, prefer [`render_ir_node`] which preserves first-class control flow.
188 pub fn render(&mut self, element: &crate::ir::Element) {
189 let ir_node = IRNode::Element(element.clone());
190 self.render_ir_node(&ir_node);
191 }
192
193 /// Render an IRNode tree (initial render or full re-render).
194 ///
195 /// Unlike [`render`], this accepts an `IRNode` (from `ast_to_ir_node()`),
196 /// which preserves ForEach, When, and If as first-class control-flow nodes.
197 /// This is the same path used by the WASM engine.
198 pub fn render_ir_node(&mut self, ir_node: &IRNode) {
199 let patches = self.core.render_ir_node(ir_node);
200 self.emit_patches(patches);
201 }
202
203 /// Handle a state change notification from a host that keeps its own state.
204 ///
205 /// Use this when the host already mutated its own state copy and just
206 /// wants the engine to invalidate and re-render the affected nodes.
207 /// Engine-owned state should go through [`update_state`] / [`update_state_sparse`]
208 /// instead.
209 ///
210 /// Uses an Arc-identity guard: if the primary module's state Arc is
211 /// literally the same allocation as at the last notify_state_change
212 /// render, the call is a no-op. This catches SDK Proxy microtasks
213 /// re-firing after an explicit `update_state` already processed the
214 /// same change.
215 ///
216 /// The guard uses `Weak::upgrade` + `Arc::ptr_eq` rather than raw
217 /// pointer equality so a dropped-and-reallocated Arc at the same
218 /// address can never be mistaken for the one we saw before.
219 pub fn notify_state_change(&mut self, change: &StateChange) {
220 if let Some(ref module) = self.core.module {
221 let current = module.get_state_shared();
222 if self.core.revision > 0 {
223 if let Some(prev) = self.last_state_weak.upgrade() {
224 if Arc::ptr_eq(&prev, ¤t) {
225 return;
226 }
227 }
228 }
229 self.last_state_weak = Arc::downgrade(¤t);
230 }
231
232 self.core.schedule_from_state_change(change);
233 self.render_dirty();
234 }
235
236 /// Apply a state patch and re-render affected nodes.
237 ///
238 /// `scope` selects the target module:
239 /// - `None` → primary module set via [`set_module`](Self::set_module)
240 /// - `Some(name)` → named module registered via [`register_module`]
241 ///
242 /// Skips rendering if the patch produces no actual state change.
243 pub fn update_state(&mut self, scope: Option<&str>, state_patch: serde_json::Value) {
244 if self.core.update_state(scope, state_patch) {
245 self.render_dirty();
246 }
247 }
248
249 /// Apply a sparse state patch (path-value pairs) and re-render affected nodes.
250 /// See [`update_state`] for `scope` semantics.
251 pub fn update_state_sparse(
252 &mut self,
253 scope: Option<&str>,
254 paths: &[String],
255 values: &serde_json::Value,
256 ) {
257 if self.core.update_state_sparse(scope, paths, values) {
258 self.render_dirty();
259 }
260 }
261
262 /// Dispatch an action to its registered handler.
263 pub fn dispatch_action(&mut self, action: Action) -> Result<(), EngineError> {
264 self.actions.dispatch(&action)
265 }
266
267 /// Look up which named module (by lowercased name) owns a given action.
268 ///
269 /// Returns `Some(name)` when the action was registered via
270 /// [`register_module`]. Returns `None` for both "primary-module actions"
271 /// (declared on the module set via [`set_module`]) and "unknown actions"
272 /// — in both cases callers should route follow-up `update_state` calls
273 /// to the primary slot (scope `None`), which is the correct default.
274 ///
275 /// Callers that host action handlers outside the engine (e.g., the Rust
276 /// SDK's `RemoteSession`) can use this to route an incoming dispatch to
277 /// the correct module's state without having to call
278 /// [`dispatch_action`](Self::dispatch_action).
279 pub fn action_scope_for(&self, action_name: &str) -> Option<String> {
280 self.core.action_scope_for(action_name)
281 }
282
283 /// Render only dirty nodes
284 fn render_dirty(&mut self) {
285 let patches = self.core.render_dirty();
286 if !patches.is_empty() {
287 self.emit_patches(patches);
288 }
289 }
290
291 /// Send patches to the renderer.
292 /// Filters out Remove patches for elements that were Created in the same
293 /// batch -- this happens when a conditional re-reconciles module-scoped
294 /// children whose stored template doesn't carry module_scope.
295 fn emit_patches(&self, mut patches: Vec<Patch>) {
296 EngineCore::filter_spurious_removes(&mut patches);
297 if let Some(ref callback) = self.render_callback {
298 callback(&patches);
299 }
300 }
301
302 /// Get the current revision number
303 pub fn revision(&self) -> u64 {
304 self.core.revision
305 }
306
307 /// Get access to the component registry
308 pub fn component_registry(&self) -> &ComponentRegistry {
309 &self.core.component_registry
310 }
311
312 /// Get mutable access to the component registry
313 pub fn component_registry_mut(&mut self) -> &mut ComponentRegistry {
314 &mut self.core.component_registry
315 }
316
317 /// Get access to the resource cache
318 pub fn resources(&self) -> &ResourceCache {
319 &self.resources
320 }
321
322 /// Get mutable access to the resource cache
323 pub fn resources_mut(&mut self) -> &mut ResourceCache {
324 &mut self.resources
325 }
326
327 // ── Data Source Context ─────────────────────────────────────────────
328
329 /// Set (or replace) a named data source context.
330 ///
331 /// Registers the provider in the dependency graph (if not already known),
332 /// stores the data, and re-renders every node bound to `@name.*`.
333 /// This is the single entry point for all data source writes --
334 /// sparse merging (if needed) should happen at the SDK layer before
335 /// calling this method with the merged object.
336 ///
337 /// # Example
338 /// ```ignore
339 /// engine.set_context("spacetime", json!({
340 /// "message": [{ "id": 1, "text": "Hello" }],
341 /// "user": [{ "id": 1, "name": "Alice" }]
342 /// }));
343 /// ```
344 pub fn set_context(&mut self, name: &str, data: serde_json::Value) {
345 self.core.set_context(name, data);
346 self.render_dirty();
347 }
348
349 /// Remove a data source context entirely.
350 ///
351 /// Drops the provider's state and re-renders bound nodes (they resolve to null).
352 pub fn remove_context(&mut self, name: &str) {
353 self.core.remove_context(name);
354 self.render_dirty();
355 }
356
357 /// Get read access to the data sources map.
358 pub fn data_sources(&self) -> &indexmap::IndexMap<String, serde_json::Value> {
359 &self.core.data_sources
360 }
361}
362
363impl Default for Engine {
364 fn default() -> Self {
365 Self::new()
366 }
367}