hypen_engine/ir/
component.rs

1use super::Element;
2use indexmap::IndexMap;
3use std::sync::Arc;
4
5/// Result from component resolution
6/// Contains the source code and the resolved path for the component
7pub struct ResolvedComponent {
8    pub source: String,
9    pub path: String,
10    pub passthrough: bool,
11    pub lazy: bool,
12}
13
14/// Callback type for resolving component source code
15/// Takes component name and optional path context, returns resolved component
16/// Path context is the file path where this component is being referenced from
17/// Resolver should return (source_code, resolved_path) where resolved_path is the
18/// absolute path to the component file (used for resolving nested components)
19pub type ComponentResolver = Arc<dyn Fn(&str, Option<&str>) -> Option<ResolvedComponent> + Send + Sync>;
20
21/// A component definition - a template that can be instantiated
22#[derive(Clone)]
23pub struct Component {
24    /// Component name
25    pub name: String,
26
27    /// Template function that produces element tree
28    /// Takes props and returns expanded element tree
29    pub template: Arc<dyn Fn(IndexMap<String, serde_json::Value>) -> Element + Send + Sync>,
30
31    /// Default props
32    pub default_props: IndexMap<String, serde_json::Value>,
33
34    /// Source path where this component was loaded from (optional)
35    pub source_path: Option<String>,
36
37    /// If true, this component acts as a passthrough container
38    /// It preserves its children and props without template expansion
39    pub passthrough: bool,
40    
41    /// If true, this component's children are NOT expanded during initial pass
42    /// Children remain as component references for lazy rendering
43    pub lazy: bool,
44}
45
46impl Component {
47    pub fn new(
48        name: impl Into<String>,
49        template: impl Fn(IndexMap<String, serde_json::Value>) -> Element + Send + Sync + 'static,
50    ) -> Self {
51        Self {
52            name: name.into(),
53            template: Arc::new(template),
54            default_props: IndexMap::new(),
55            source_path: None,
56            passthrough: false,
57            lazy: false,
58        }
59    }
60
61    pub fn with_defaults(mut self, defaults: IndexMap<String, serde_json::Value>) -> Self {
62        self.default_props = defaults;
63        self
64    }
65
66    pub fn with_source_path(mut self, path: impl Into<String>) -> Self {
67        self.source_path = Some(path.into());
68        self
69    }
70
71    pub fn with_passthrough(mut self, passthrough: bool) -> Self {
72        self.passthrough = passthrough;
73        self
74    }
75    
76    pub fn with_lazy(mut self, lazy: bool) -> Self {
77        self.lazy = lazy;
78        self
79    }
80
81    /// Instantiate this component with given props
82    pub fn instantiate(&self, props: IndexMap<String, serde_json::Value>) -> Element {
83        let mut merged_props = self.default_props.clone();
84        merged_props.extend(props);
85        (self.template)(merged_props)
86    }
87}
88
89/// Registry of all available components
90pub struct ComponentRegistry {
91    /// Components indexed by fully qualified key (path:name or just name)
92    components: IndexMap<String, Component>,
93    /// Optional resolver for dynamically loading components
94    resolver: Option<ComponentResolver>,
95    /// Cache of resolved component paths to prevent re-resolving
96    /// Key format: "path:component_name" or "component_name" if no path
97    resolved_cache: IndexMap<String, bool>,
98}
99
100impl ComponentRegistry {
101    pub fn new() -> Self {
102        Self {
103            components: IndexMap::new(),
104            resolver: None,
105            resolved_cache: IndexMap::new(),
106        }
107    }
108    
109    /// Register a primitive element name to skip component resolution
110    /// Called by the renderer to mark built-in DOM elements
111    pub fn register_primitive(&mut self, name: &str) {
112        self.resolved_cache.insert(name.to_string(), false);
113    }
114
115    /// Set the component resolver callback
116    pub fn set_resolver(&mut self, resolver: ComponentResolver) {
117        self.resolver = Some(resolver);
118    }
119
120    pub fn register(&mut self, component: Component) {
121        // Register with qualified key if source path is available
122        if let Some(ref path) = component.source_path {
123            let qualified_key = format!("{}:{}", path, component.name);
124            self.components.insert(qualified_key, component.clone());
125        }
126
127        // Always register with unqualified name as fallback
128        self.components.insert(component.name.clone(), component);
129    }
130
131    /// Get a component by name and optional context path
132    pub fn get(&self, name: &str, context_path: Option<&str>) -> Option<&Component> {
133        // Try with context path first
134        if let Some(path) = context_path {
135            let qualified_key = format!("{}:{}", path, name);
136            if let Some(component) = self.components.get(&qualified_key) {
137                return Some(component);
138            }
139        }
140
141        // Fall back to just name (for globally registered components)
142        self.components.get(name)
143    }
144
145    /// Try to resolve and register a component by name and context path
146    fn try_resolve(&mut self, name: &str, context_path: Option<&str>) -> bool {
147        // Build cache key
148        let cache_key = if let Some(path) = context_path {
149            format!("{}:{}", path, name)
150        } else {
151            name.to_string()
152        };
153
154        // Check cache first
155        if let Some(&cached) = self.resolved_cache.get(&cache_key) {
156            return cached;
157        }
158
159        // Try to resolve
160        if let Some(ref resolver) = self.resolver {
161            if let Some(resolved) = resolver(name, context_path) {
162                // For lazy components, don't parse the template
163                // Children won't be expanded until explicitly requested
164                if resolved.lazy {
165                    #[cfg(target_arch = "wasm32")]
166                    web_sys::console::log_1(&format!("📦 Registering lazy component: {}", name).into());
167                    
168                    // Create a dummy component - children won't be expanded
169                    let dummy_element = Element::new(name);
170                    let component = Component::new(name, move |_props| dummy_element.clone())
171                        .with_source_path(resolved.path.clone())
172                        .with_lazy(true);
173                    
174                    self.register(component);
175                    self.resolved_cache.insert(cache_key, true);
176                    return true;
177                }
178                
179                // For passthrough components, don't parse the template
180                // They act as transparent containers
181                if resolved.passthrough {
182                    #[cfg(target_arch = "wasm32")]
183                    web_sys::console::log_1(&format!("📦 Registering passthrough component: {}", name).into());
184                    
185                    // Create a dummy component - the template won't be used for passthrough
186                    let dummy_element = Element::new(name);
187                    let component = Component::new(name, move |_props| dummy_element.clone())
188                        .with_source_path(resolved.path.clone())
189                        .with_passthrough(true);
190                    
191                    self.register(component);
192                    self.resolved_cache.insert(cache_key, true);
193                    return true;
194                }
195                
196                // Parse the component source for non-passthrough components
197                match hypen_parser::parse_component(&resolved.source) {
198                    Ok(component_spec) => {
199                        // Convert to IR
200                        let element = super::expand::ast_to_ir(&component_spec);
201
202                        // Create a component that returns the parsed element
203                        let component = Component::new(name, move |_props| element.clone())
204                            .with_source_path(resolved.path.clone())
205                            .with_passthrough(false);
206
207                        self.register(component);
208                        self.resolved_cache.insert(cache_key, true);
209                        return true;
210                    }
211                    Err(e) => {
212                        #[cfg(target_arch = "wasm32")]
213                        web_sys::console::error_1(&format!("Failed to parse component {}: {:?}", name, e).into());
214
215                        #[cfg(not(target_arch = "wasm32"))]
216                        eprintln!("Failed to parse component {}: {:?}", name, e);
217
218                        self.resolved_cache.insert(cache_key, false);
219                        return false;
220                    }
221                }
222            }
223        }
224
225        self.resolved_cache.insert(cache_key, false);
226        false
227    }
228
229    pub fn expand(&mut self, element: &Element) -> Element {
230        self.expand_with_context(element, None)
231    }
232    
233    /// Force expand an element's children (used for lazy components)
234    /// This will expand children that were previously kept unexpanded
235    pub fn expand_children(&mut self, element: &Element, context_path: Option<&str>) -> Vec<Element> {
236        element.children.iter()
237            .map(|child| self.expand_with_context(child, context_path))
238            .collect()
239    }
240
241    /// Expand an element with a context path for component resolution
242    fn expand_with_context(&mut self, element: &Element, context_path: Option<&str>) -> Element {
243        // First check if component exists, if not try to resolve it
244        let component_exists = self.get(&element.element_type, context_path).is_some();
245
246        if !component_exists {
247            // Try to resolve the component dynamically
248            self.try_resolve(&element.element_type, context_path);
249        }
250
251        // If this element references a registered component, expand it
252        if let Some(component) = self.get(&element.element_type, context_path) {
253            // Check if this is a lazy component (children NOT expanded until explicitly requested)
254            if component.lazy {
255                // Lazy component: keep element and children, but DON'T expand children yet
256                let mut element = element.clone();
257                
258                // Mark as lazy so reconciler knows to skip children
259                element.props.insert("__lazy".to_string(), super::Value::Static(serde_json::json!(true)));
260                
261                #[cfg(target_arch = "wasm32")]
262                web_sys::console::log_1(&format!(
263                    "💤 Lazy {} (props: {:?}): {} children kept unexpanded", 
264                    element.element_type,
265                    element.props.keys().collect::<Vec<_>>(),
266                    element.children.len()
267                ).into());
268                
269                return element;
270            }
271            
272            // Check if this is a passthrough component
273            if component.passthrough {
274                // Passthrough component: keep the original element but expand its children
275                let mut element = element.clone();
276                
277                #[cfg(target_arch = "wasm32")]
278                {
279                    let props_str = element.props.iter()
280                        .map(|(k, v)| format!("{}={:?}", k, v))
281                        .collect::<Vec<_>>()
282                        .join(", ");
283                    web_sys::console::log_1(&format!(
284                        "🔄 Passthrough {} (props: [{}]): {} children before expansion", 
285                        element.element_type, 
286                        props_str,
287                        element.children.len()
288                    ).into());
289                }
290                
291                // Get the source path for child context
292                let child_context = component.source_path.clone();
293                let child_context_ref = child_context.as_deref();
294                
295                // Recursively expand children
296                element.children = element
297                    .children
298                    .into_iter()
299                    .map(|child| self.expand_with_context(&child, child_context_ref))
300                    .collect();
301                
302                #[cfg(target_arch = "wasm32")]
303                web_sys::console::log_1(&format!("🔄 Passthrough {}: {} children after expansion", element.element_type, element.children.len()).into());
304                
305                element
306            } else {
307                // Regular component: instantiate template and replace
308                // Convert Value props to serde_json::Value (resolve only static values here)
309                let mut props = IndexMap::new();
310                for (k, v) in &element.props {
311                    if let super::Value::Static(val) = v {
312                        props.insert(k.clone(), val.clone());
313                    }
314                }
315
316                let mut expanded = component.instantiate(props);
317
318                // Preserve bindings and actions from the original element
319                for (k, v) in &element.props {
320                    match v {
321                        super::Value::Binding(_) | super::Value::Action(_) => {
322                            expanded.props.insert(k.clone(), v.clone());
323                        }
324                        _ => {}
325                    }
326                }
327
328                // Get the source path of this component for resolving its children
329                // Clone it to avoid holding a borrow
330                let child_context = component.source_path.clone();
331
332                // Replace Children() placeholders with actual children from the caller
333                expanded.children = self.replace_children_slots(&expanded.children, &element.children, context_path);
334
335                // Recursively expand children with the new context
336                let child_context_ref = child_context.as_deref();
337                expanded.children = expanded
338                    .children
339                    .into_iter()
340                    .map(|child| self.expand_with_context(&child, child_context_ref))
341                    .collect();
342
343                expanded
344            }
345        } else {
346            // Not a component, just expand children
347            let mut element = element.clone();
348            element.children = element
349                .children
350                .into_iter()
351                .map(|child| self.expand_with_context(&child, context_path))
352                .collect();
353            element
354        }
355    }
356
357    /// Replace Children() placeholders with actual children
358    /// Supports named slots via Children().slot("header")
359    fn replace_children_slots(&self, template_children: &[Element], actual_children: &[Element], _context_path: Option<&str>) -> Vec<Element> {
360        let mut result = Vec::new();
361
362        for child in template_children {
363            if child.element_type == "Children" {
364                // Check if this is a named slot via .slot() applicator
365                // Applicators are stored as props with format "applicatorName.argIndex"
366                let slot_name = self.get_slot_name(&child.props);
367
368                // If named slot, filter children by slot applicator
369                // Otherwise, include all children that don't have a slot applicator
370                let children_to_insert: Vec<Element> = if let Some(slot) = slot_name {
371                    actual_children.iter()
372                        .filter(|c| {
373                            self.get_slot_name(&c.props)
374                                .map_or(false, |s| s == slot)
375                        })
376                        .cloned()
377                        .collect()
378                } else {
379                    // Default slot - children without slot applicator
380                    actual_children.iter()
381                        .filter(|c| self.get_slot_name(&c.props).is_none())
382                        .cloned()
383                        .collect()
384                };
385
386                result.extend(children_to_insert);
387            } else {
388                // Not a Children() placeholder - keep as is but recurse into its children
389                let mut new_child = child.clone();
390                new_child.children = self.replace_children_slots(&child.children, actual_children, _context_path);
391                result.push(new_child);
392            }
393        }
394
395        result
396    }
397
398    /// Extract slot name from applicators
399    /// Looks for .slot("name") which becomes prop "slot.0" = "name"
400    fn get_slot_name<'a>(&self, props: &'a super::Props) -> Option<&'a str> {
401        props.get("slot.0")
402            .and_then(|v| {
403                if let super::Value::Static(serde_json::Value::String(s)) = v {
404                    Some(s.as_str())
405                } else {
406                    None
407                }
408            })
409    }
410}
411
412impl Default for ComponentRegistry {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::ir::Value;
422
423    #[test]
424    fn test_dynamic_component_resolution() {
425        let mut registry = ComponentRegistry::new();
426
427        // Set up a resolver that returns component source and path
428        registry.set_resolver(Arc::new(|name: &str, _context: Option<&str>| {
429            if name == "Header" {
430                Some(ResolvedComponent {
431                    source: r#"Row { Text("Header") }"#.to_string(),
432                    path: "/components/Header.hypen".to_string(),
433                    passthrough: false,
434                    lazy: false,
435                })
436            } else {
437                None
438            }
439        }));
440
441        // Create an element that references the unregistered Header component
442        let element = Element::new("Column")
443            .with_child(Element::new("Header"));
444
445        // Expand should trigger resolution
446        let expanded = registry.expand(&element);
447
448        // Should have expanded Header into Row { Text }
449        assert_eq!(expanded.element_type, "Column");
450        assert_eq!(expanded.children.len(), 1);
451        assert_eq!(expanded.children[0].element_type, "Row");
452        assert_eq!(expanded.children[0].children[0].element_type, "Text");
453    }
454
455    #[test]
456    fn test_component_resolution_with_path_context() {
457        let mut registry = ComponentRegistry::new();
458
459        // Set up a resolver that resolves based on context path
460        registry.set_resolver(Arc::new(|name: &str, context: Option<&str>| {
461            match (name, context) {
462                ("Button", Some("/pages/Home.hypen")) => Some(ResolvedComponent {
463                    source: r#"Text("Home Button")"#.to_string(),
464                    path: "/components/buttons/HomeButton.hypen".to_string(),
465                    passthrough: false,
466                    lazy: false,
467                }),
468                ("Button", Some("/pages/About.hypen")) => Some(ResolvedComponent {
469                    source: r#"Text("About Button")"#.to_string(),
470                    path: "/components/buttons/AboutButton.hypen".to_string(),
471                    passthrough: false,
472                    lazy: false,
473                }),
474                _ => None,
475            }
476        }));
477
478        // Register a component with a source path
479        let home_element = Element::new("Text").with_prop("0", Value::Static(serde_json::json!("Home")));
480        let home_component = Component::new("Home", move |_| home_element.clone())
481            .with_source_path("/pages/Home.hypen");
482        registry.register(home_component);
483
484        // Create an element that uses Button from Home context
485        let element = Element::new("Column")
486            .with_child(Element::new("Home").with_child(Element::new("Button")));
487
488        let expanded = registry.expand(&element);
489
490        // Button should resolve differently based on its context
491        assert_eq!(expanded.element_type, "Column");
492    }
493
494    #[test]
495    fn test_component_resolution_caching() {
496        let mut registry = ComponentRegistry::new();
497        let call_count = Arc::new(std::sync::Mutex::new(0));
498        let call_count_clone = call_count.clone();
499
500        // Resolver that tracks calls
501        registry.set_resolver(Arc::new(move |name: &str, _context: Option<&str>| {
502            if name == "Button" {
503                *call_count_clone.lock().unwrap() += 1;
504                Some(ResolvedComponent {
505                    source: r#"Text("Click")"#.to_string(),
506                    path: "/components/Button.hypen".to_string(),
507                    passthrough: false,
508                    lazy: false,
509                })
510            } else {
511                None
512            }
513        }));
514
515        // First expansion should call resolver
516        let element1 = Element::new("Button");
517        let _ = registry.expand(&element1);
518        assert_eq!(*call_count.lock().unwrap(), 1);
519
520        // Second expansion should use cache
521        let element2 = Element::new("Button");
522        let _ = registry.expand(&element2);
523        assert_eq!(*call_count.lock().unwrap(), 1); // Still 1, not 2
524    }
525
526    #[test]
527    fn test_failed_resolution_cached() {
528        let mut registry = ComponentRegistry::new();
529        let call_count = Arc::new(std::sync::Mutex::new(0));
530        let call_count_clone = call_count.clone();
531
532        // Resolver that returns None
533        registry.set_resolver(Arc::new(move |_name: &str, _context: Option<&str>| {
534            *call_count_clone.lock().unwrap() += 1;
535            None
536        }));
537
538        // First expansion should call resolver
539        let element1 = Element::new("Unknown");
540        let _ = registry.expand(&element1);
541        assert_eq!(*call_count.lock().unwrap(), 1);
542
543        // Second expansion should use cached failure
544        let element2 = Element::new("Unknown");
545        let _ = registry.expand(&element2);
546        assert_eq!(*call_count.lock().unwrap(), 1); // Cached
547    }
548
549    #[test]
550    fn test_passthrough_component_preserves_props() {
551        let mut registry = ComponentRegistry::new();
552
553        // Register Router and Route as passthrough components
554        registry.set_resolver(Arc::new(|name: &str, _context: Option<&str>| {
555            if name == "Router" || name == "Route" {
556                Some(ResolvedComponent {
557                    source: String::new(), // Empty template for passthrough
558                    path: name.to_string(),
559                    passthrough: true,
560                    lazy: false,
561                })
562            } else if name == "HomePage" {
563                // Regular component with a simple template
564                Some(ResolvedComponent {
565                    source: "Text(\"Home\")".to_string(),
566                    path: name.to_string(),
567                    passthrough: false,
568                    lazy: false,
569                })
570            } else {
571                None
572            }
573        }));
574
575        // Build a tree structure like:
576        // Router {
577        //   Route("/") { HomePage }
578        //   Route("/about") { HomePage }
579        // }
580        let mut router = Element::new("Router");
581        
582        let mut route1 = Element::new("Route");
583        route1.props.insert("0".to_string(), Value::Static(serde_json::json!("/")));
584        route1.children.push(Element::new("HomePage"));
585        
586        let mut route2 = Element::new("Route");
587        route2.props.insert("0".to_string(), Value::Static(serde_json::json!("/about")));
588        route2.children.push(Element::new("HomePage"));
589        
590        router.children.push(route1);
591        router.children.push(route2);
592
593        // Expand the tree
594        let expanded = registry.expand(&router);
595
596        // Verify Router is preserved
597        assert_eq!(expanded.element_type, "Router");
598        assert_eq!(expanded.children.len(), 2);
599
600        // Verify first Route preserves its path prop
601        let expanded_route1 = &expanded.children[0];
602        assert_eq!(expanded_route1.element_type, "Route");
603        if let Some(Value::Static(path)) = expanded_route1.props.get("0") {
604            assert_eq!(path.as_str().unwrap(), "/");
605        } else {
606            panic!("Route 1 missing path prop");
607        }
608
609        // Verify second Route preserves its path prop
610        let expanded_route2 = &expanded.children[1];
611        assert_eq!(expanded_route2.element_type, "Route");
612        if let Some(Value::Static(path)) = expanded_route2.props.get("0") {
613            assert_eq!(path.as_str().unwrap(), "/about");
614        } else {
615            panic!("Route 2 missing path prop");
616        }
617
618        // Verify children are expanded (HomePage should be replaced with Text)
619        assert_eq!(expanded_route1.children.len(), 1);
620        assert_eq!(expanded_route1.children[0].element_type, "Text");
621    }
622}