Skip to main content

hypen_engine/ir/
component.rs

1use super::Element;
2use indexmap::IndexMap;
3use std::collections::HashSet;
4use std::sync::Arc;
5
6/// Standard primitive element types recognized by all Hypen renderers.
7///
8/// Registering these prevents the component resolver from trying to resolve
9/// them as user-defined components. All renderers (DOM, Canvas, iOS, Android)
10/// are expected to handle these element types natively.
11pub const DEFAULT_PRIMITIVES: &[&str] = &[
12    "Text", "Column", "Row", "Button", "Input", "Textarea", "Image", "Container", "Box",
13    "Center", "List", "Spacer", "Stack", "Divider", "Grid", "Card", "Heading", "Checkbox",
14    "Select", "Switch", "Slider", "Spinner", "Badge", "Avatar", "ProgressBar", "Video", "Audio",
15    "Paragraph",
16];
17
18/// Result from component resolution
19/// Contains the source code and the resolved path for the component
20pub struct ResolvedComponent {
21    pub source: String,
22    pub path: String,
23    pub passthrough: bool,
24    pub lazy: bool,
25}
26
27/// Callback type for resolving component source code
28/// Takes component name and optional path context, returns resolved component
29/// Path context is the file path where this component is being referenced from
30/// Resolver should return (source_code, resolved_path) where resolved_path is the
31/// absolute path to the component file (used for resolving nested components)
32pub type ComponentResolver =
33    Arc<dyn Fn(&str, Option<&str>) -> Option<ResolvedComponent> + Send + Sync>;
34
35/// A component definition - a template that can be instantiated
36#[derive(Clone)]
37pub struct Component {
38    /// Component name
39    pub name: String,
40
41    /// Template function that produces element tree
42    /// Takes props and returns expanded element tree
43    pub template: Arc<dyn Fn(IndexMap<String, serde_json::Value>) -> Element + Send + Sync>,
44
45    /// Default props
46    pub default_props: IndexMap<String, serde_json::Value>,
47
48    /// Source path where this component was loaded from (optional)
49    pub source_path: Option<String>,
50
51    /// If true, this component acts as a passthrough container
52    /// It preserves its children and props without template expansion
53    pub passthrough: bool,
54
55    /// If true, this component's children are NOT expanded during initial pass
56    /// Children remain as component references for lazy rendering
57    pub lazy: bool,
58}
59
60impl Component {
61    pub fn new(
62        name: impl Into<String>,
63        template: impl Fn(IndexMap<String, serde_json::Value>) -> Element + Send + Sync + 'static,
64    ) -> Self {
65        Self {
66            name: name.into(),
67            template: Arc::new(template),
68            default_props: IndexMap::new(),
69            source_path: None,
70            passthrough: false,
71            lazy: false,
72        }
73    }
74
75    pub fn with_defaults(mut self, defaults: IndexMap<String, serde_json::Value>) -> Self {
76        self.default_props = defaults;
77        self
78    }
79
80    pub fn with_source_path(mut self, path: impl Into<String>) -> Self {
81        self.source_path = Some(path.into());
82        self
83    }
84
85    pub fn with_passthrough(mut self, passthrough: bool) -> Self {
86        self.passthrough = passthrough;
87        self
88    }
89
90    pub fn with_lazy(mut self, lazy: bool) -> Self {
91        self.lazy = lazy;
92        self
93    }
94
95    /// Instantiate this component with given props
96    pub fn instantiate(&self, props: IndexMap<String, serde_json::Value>) -> Element {
97        let mut merged_props = self.default_props.clone();
98        merged_props.extend(props);
99        (self.template)(merged_props)
100    }
101}
102
103/// Registry of all available components
104pub struct ComponentRegistry {
105    /// Components indexed by fully qualified key (path:name or just name)
106    components: IndexMap<String, Component>,
107    /// Optional resolver for dynamically loading components
108    resolver: Option<ComponentResolver>,
109    /// Cache of resolved component paths to prevent re-resolving
110    /// Key format: "path:component_name" or "component_name" if no path
111    /// Value: true = resolved successfully, false = failed to resolve
112    resolved_cache: IndexMap<String, bool>,
113    /// Primitive element names that should never be resolved as components.
114    /// Separate from resolved_cache to avoid conflating "is a primitive"
115    /// with "failed to resolve without context."
116    primitives: HashSet<String>,
117}
118
119impl ComponentRegistry {
120    pub fn new() -> Self {
121        Self {
122            components: IndexMap::new(),
123            resolver: None,
124            resolved_cache: IndexMap::new(),
125            primitives: HashSet::new(),
126        }
127    }
128
129    /// Register a primitive element name to skip component resolution.
130    /// Called by the renderer to mark built-in DOM elements.
131    pub fn register_primitive(&mut self, name: &str) {
132        self.primitives.insert(name.to_string());
133    }
134
135    /// Register the standard set of Hypen primitives.
136    ///
137    /// These are the built-in element types that all renderers support.
138    /// Registering them prevents the component resolver from trying to
139    /// look them up as user-defined components.
140    pub fn register_default_primitives(&mut self) {
141        for name in DEFAULT_PRIMITIVES {
142            self.register_primitive(name);
143        }
144    }
145
146    /// Check if a name is a registered primitive element.
147    pub fn is_primitive(&self, name: &str) -> bool {
148        self.primitives.contains(name)
149    }
150
151    /// Clear all resolved components and caches, but preserve primitives
152    /// and the resolver callback. Used for hot-reload so components are
153    /// re-resolved from fresh source files.
154    pub fn clear_resolved(&mut self) {
155        self.components.clear();
156        self.resolved_cache.clear();
157    }
158
159    /// Set the component resolver callback
160    pub fn set_resolver(&mut self, resolver: ComponentResolver) {
161        self.resolver = Some(resolver);
162    }
163
164    pub fn register(&mut self, component: Component) {
165        // Register with qualified key if source path is available
166        if let Some(ref path) = component.source_path {
167            let qualified_key = format!("{}:{}", path, component.name);
168            self.components.insert(qualified_key, component.clone());
169        }
170
171        // Always register with unqualified name as fallback
172        self.components.insert(component.name.clone(), component);
173    }
174
175    /// Get a component by name and optional context path
176    pub fn get(&self, name: &str, context_path: Option<&str>) -> Option<&Component> {
177        // Try with context path first
178        if let Some(path) = context_path {
179            let qualified_key = format!("{}:{}", path, name);
180            if let Some(component) = self.components.get(&qualified_key) {
181                return Some(component);
182            }
183        }
184
185        // Fall back to just name (for globally registered components)
186        self.components.get(name)
187    }
188
189    /// Try to resolve and register a component by name and context path
190    fn try_resolve(&mut self, name: &str, context_path: Option<&str>) -> bool {
191        // Primitives are never resolved as components, regardless of context.
192        if self.primitives.contains(name) {
193            return false;
194        }
195
196        // Build cache key
197        let cache_key = if let Some(path) = context_path {
198            format!("{}:{}", path, name)
199        } else {
200            name.to_string()
201        };
202
203        // Check cache for context-scoped resolution
204        if let Some(&cached) = self.resolved_cache.get(&cache_key) {
205            return cached;
206        }
207
208        // Try to resolve
209        if let Some(ref resolver) = self.resolver {
210            if let Some(resolved) = resolver(name, context_path) {
211                // For lazy components, don't parse the template
212                // Children won't be expanded until explicitly requested
213                if resolved.lazy {
214                    #[cfg(all(target_arch = "wasm32", feature = "js"))]
215                    web_sys::console::log_1(
216                        &format!("Registering lazy component: {}", name).into(),
217                    );
218
219                    // Create a dummy component - children won't be expanded
220                    let dummy_element = Element::new(name);
221                    let component = Component::new(name, move |_props| dummy_element.clone())
222                        .with_source_path(resolved.path.clone())
223                        .with_lazy(true);
224
225                    self.register(component);
226                    self.resolved_cache.insert(cache_key, true);
227                    return true;
228                }
229
230                // For passthrough components, don't parse the template
231                // They act as transparent containers
232                if resolved.passthrough {
233                    #[cfg(all(target_arch = "wasm32", feature = "js"))]
234                    web_sys::console::log_1(
235                        &format!("Registering passthrough component: {}", name).into(),
236                    );
237
238                    // Create a dummy component - the template won't be used for passthrough
239                    let dummy_element = Element::new(name);
240                    let component = Component::new(name, move |_props| dummy_element.clone())
241                        .with_source_path(resolved.path.clone())
242                        .with_passthrough(true);
243
244                    self.register(component);
245                    self.resolved_cache.insert(cache_key, true);
246                    return true;
247                }
248
249                // Parse the component source for non-passthrough components
250                match hypen_parser::parse_component(&resolved.source) {
251                    Ok(component_spec) => {
252                        // Convert to IR, preserving control-flow children
253                        let ir_node = super::expand::ast_to_ir_node(&component_spec);
254                        let element = match ir_node {
255                            super::IRNode::Element(e) => e,
256                            _ => {
257                                #[cfg(all(target_arch = "wasm32", feature = "js"))]
258                                web_sys::console::error_1(
259                                    &format!("Component {} root must be an element", name).into(),
260                                );
261                                return false;
262                            }
263                        };
264
265                        // Create a component that returns the parsed element
266                        let component = Component::new(name, move |_props| element.clone())
267                            .with_source_path(resolved.path.clone())
268                            .with_passthrough(false);
269
270                        self.register(component);
271                        self.resolved_cache.insert(cache_key, true);
272                        return true;
273                    }
274                    Err(e) => {
275                        #[cfg(all(target_arch = "wasm32", feature = "js"))]
276                        web_sys::console::error_1(
277                            &format!("Failed to parse component {}: {:?}", name, e).into(),
278                        );
279
280                        #[cfg(not(all(target_arch = "wasm32", feature = "js")))]
281                        eprintln!("Failed to parse component {}: {:?}", name, e);
282
283                        self.resolved_cache.insert(cache_key, false);
284                        return false;
285                    }
286                }
287            }
288        }
289
290        self.resolved_cache.insert(cache_key, false);
291        false
292    }
293
294    pub fn expand(&mut self, element: &Element) -> Element {
295        self.expand_with_context(element, None)
296    }
297
298    /// Force expand an element's children (used for lazy components)
299    /// This will expand children that were previously kept unexpanded
300    pub fn expand_children(
301        &mut self,
302        element: &Element,
303        context_path: Option<&str>,
304    ) -> Vec<Element> {
305        element
306            .children
307            .iter()
308            .map(|child| self.expand_with_context(child, context_path))
309            .collect()
310    }
311
312    /// Expand an element with a context path for component resolution
313    fn expand_with_context(&mut self, element: &Element, context_path: Option<&str>) -> Element {
314        // First check if component exists, if not try to resolve it
315        let component_exists = self.get(&element.element_type, context_path).is_some();
316
317        if !component_exists {
318            // Try to resolve the component dynamically
319            self.try_resolve(&element.element_type, context_path);
320        }
321
322        // If this element references a registered component, expand it
323        if let Some(component) = self.get(&element.element_type, context_path) {
324            // Check if this is a lazy component (children NOT expanded until explicitly requested)
325            if component.lazy {
326                // Lazy component: keep element and children, but DON'T expand children yet
327                let mut element = element.clone();
328
329                // Mark as lazy so reconciler knows to skip children
330                element.props.insert(
331                    "__lazy".to_string(),
332                    super::Value::Static(serde_json::json!(true)),
333                );
334
335                #[cfg(all(target_arch = "wasm32", feature = "js"))]
336                web_sys::console::log_1(
337                    &format!(
338                        "Lazy {} (props: {:?}): {} children kept unexpanded",
339                        element.element_type,
340                        element.props.keys().collect::<Vec<_>>(),
341                        element.children.len()
342                    )
343                    .into(),
344                );
345
346                return element;
347            }
348
349            // Check if this is a passthrough component
350            if component.passthrough {
351                // Passthrough component: keep the original element but expand its children
352                let mut element = element.clone();
353
354                #[cfg(all(target_arch = "wasm32", feature = "js"))]
355                {
356                    let props_str = element
357                        .props
358                        .iter()
359                        .map(|(k, v)| format!("{}={:?}", k, v))
360                        .collect::<Vec<_>>()
361                        .join(", ");
362                    web_sys::console::log_1(
363                        &format!(
364                            "Passthrough {} (props: [{}]): {} children before expansion",
365                            element.element_type,
366                            props_str,
367                            element.children.len()
368                        )
369                        .into(),
370                    );
371                }
372
373                // Get the source path for child context
374                let child_context = component.source_path.clone();
375                let child_context_ref = child_context.as_deref();
376
377                // Recursively expand children
378                element.children = element
379                    .children
380                    .into_iter()
381                    .map(|child| Arc::new(self.expand_with_context(&child, child_context_ref)))
382                    .collect();
383
384                #[cfg(all(target_arch = "wasm32", feature = "js"))]
385                web_sys::console::log_1(
386                    &format!(
387                        "Passthrough {}: {} children after expansion",
388                        element.element_type,
389                        element.children.len()
390                    )
391                    .into(),
392                );
393
394                element
395            } else {
396                // Regular component: instantiate template and replace
397                // Convert Value props to serde_json::Value (resolve only static values here)
398                let mut props = IndexMap::new();
399                for (k, v) in &element.props {
400                    if let super::Value::Static(val) = v {
401                        props.insert(k.clone(), val.clone());
402                    }
403                }
404
405                let mut expanded = component.instantiate(props);
406
407                // Preserve bindings and actions from the original element
408                for (k, v) in &element.props {
409                    match v {
410                        super::Value::Binding(_) | super::Value::Action(_) => {
411                            expanded.props.insert(k.clone(), v.clone());
412                        }
413                        _ => {}
414                    }
415                }
416
417                // Get the source path of this component for resolving its children
418                // Clone it to avoid holding a borrow
419                let child_context = component.source_path.clone();
420
421                // Replace Children() placeholders with actual children from the caller
422                expanded.children = self.replace_children_slots(
423                    &expanded.children,
424                    &element.children,
425                    context_path,
426                );
427
428                // Recursively expand children with the new context
429                let child_context_ref = child_context.as_deref();
430                expanded.children = expanded
431                    .children
432                    .into_iter()
433                    .map(|child| Arc::new(self.expand_with_context(&child, child_context_ref)))
434                    .collect();
435                // Also expand ir_children (control-flow children from ast_to_ir_node)
436                if !expanded.ir_children.is_empty() {
437                    expanded.ir_children = expanded
438                        .ir_children
439                        .iter()
440                        .map(|child| self.expand_ir_node_with_context(child, child_context_ref))
441                        .collect();
442                }
443
444                expanded
445            }
446        } else {
447            // Not a component, just expand children
448            let mut element = element.clone();
449            element.children = element
450                .children
451                .into_iter()
452                .map(|child| Arc::new(self.expand_with_context(&child, context_path)))
453                .collect();
454            // Also expand ir_children (control-flow children from ast_to_ir_node)
455            if !element.ir_children.is_empty() {
456                element.ir_children = element
457                    .ir_children
458                    .iter()
459                    .map(|child| self.expand_ir_node_with_context(child, context_path))
460                    .collect();
461            }
462            element
463        }
464    }
465
466    /// Expand an IRNode recursively, expanding any Element nodes via the component registry
467    pub fn expand_ir_node(&mut self, node: &super::IRNode) -> super::IRNode {
468        self.expand_ir_node_with_context(node, None)
469    }
470
471    /// Expand an IRNode with context path for component resolution
472    fn expand_ir_node_with_context(
473        &mut self,
474        node: &super::IRNode,
475        context_path: Option<&str>,
476    ) -> super::IRNode {
477        match node {
478            super::IRNode::Element(element) => {
479                // Expand the element through the registry
480                let mut expanded = self.expand_with_context(element, context_path);
481
482                // Also expand ir_children if present (control-flow children)
483                if !expanded.ir_children.is_empty() {
484                    expanded.ir_children = expanded
485                        .ir_children
486                        .iter()
487                        .map(|child| self.expand_ir_node_with_context(child, context_path))
488                        .collect();
489                }
490
491                super::IRNode::Element(expanded)
492            }
493            super::IRNode::ForEach {
494                source,
495                item_name,
496                key_path,
497                template,
498                props,
499            } => {
500                // Recursively expand template children
501                let expanded_template: Vec<super::IRNode> = template
502                    .iter()
503                    .map(|child| self.expand_ir_node_with_context(child, context_path))
504                    .collect();
505
506                super::IRNode::ForEach {
507                    source: source.clone(),
508                    item_name: item_name.clone(),
509                    key_path: key_path.clone(),
510                    template: expanded_template,
511                    props: props.clone(),
512                }
513            }
514            super::IRNode::Conditional {
515                value,
516                branches,
517                fallback,
518            } => {
519                // Expand branch children
520                let expanded_branches: Vec<super::ConditionalBranch> = branches
521                    .iter()
522                    .map(|branch| super::ConditionalBranch {
523                        pattern: branch.pattern.clone(),
524                        children: branch
525                            .children
526                            .iter()
527                            .map(|child| self.expand_ir_node_with_context(child, context_path))
528                            .collect(),
529                    })
530                    .collect();
531
532                // Expand fallback children if present
533                let expanded_fallback = fallback.as_ref().map(|fb| {
534                    fb.iter()
535                        .map(|child| self.expand_ir_node_with_context(child, context_path))
536                        .collect()
537                });
538
539                super::IRNode::Conditional {
540                    value: value.clone(),
541                    branches: expanded_branches,
542                    fallback: expanded_fallback,
543                }
544            }
545        }
546    }
547
548    /// Replace Children() placeholders with actual children
549    /// Supports named slots via Children().slot("header")
550    fn replace_children_slots(
551        &self,
552        template_children: &im::Vector<Arc<Element>>,
553        actual_children: &im::Vector<Arc<Element>>,
554        _context_path: Option<&str>,
555    ) -> im::Vector<Arc<Element>> {
556        let mut result = im::Vector::new();
557
558        for child in template_children {
559            if child.element_type == "Children" {
560                // Check if this is a named slot via .slot() applicator
561                // Applicators are stored as props with format "applicatorName.argIndex"
562                let slot_name = self.get_slot_name(&child.props);
563
564                // If named slot, filter children by slot applicator
565                // Otherwise, include all children that don't have a slot applicator
566                if let Some(slot) = slot_name {
567                    for c in actual_children.iter() {
568                        if self.get_slot_name(&c.props) == Some(slot) {
569                            result.push_back(Arc::clone(c));
570                        }
571                    }
572                } else {
573                    // Default slot - children without slot applicator
574                    for c in actual_children.iter() {
575                        if self.get_slot_name(&c.props).is_none() {
576                            result.push_back(Arc::clone(c));
577                        }
578                    }
579                }
580            } else {
581                // Not a Children() placeholder - keep as is but recurse into its children
582                let mut new_child = (**child).clone();
583                new_child.children =
584                    self.replace_children_slots(&child.children, actual_children, _context_path);
585                result.push_back(Arc::new(new_child));
586            }
587        }
588
589        result
590    }
591
592    /// Extract slot name from applicators
593    /// Looks for .slot("name") which becomes prop "slot.0" = "name"
594    fn get_slot_name<'a>(&self, props: &'a super::Props) -> Option<&'a str> {
595        props.get("slot.0").and_then(|v| {
596            if let super::Value::Static(serde_json::Value::String(s)) = v {
597                Some(s.as_str())
598            } else {
599                None
600            }
601        })
602    }
603}
604
605impl Default for ComponentRegistry {
606    fn default() -> Self {
607        Self::new()
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::ir::Value;
615
616    #[test]
617    fn test_dynamic_component_resolution() {
618        let mut registry = ComponentRegistry::new();
619
620        // Set up a resolver that returns component source and path
621        registry.set_resolver(Arc::new(|name: &str, _context: Option<&str>| {
622            if name == "Header" {
623                Some(ResolvedComponent {
624                    source: r#"Row { Text("Header") }"#.to_string(),
625                    path: "/components/Header.hypen".to_string(),
626                    passthrough: false,
627                    lazy: false,
628                })
629            } else {
630                None
631            }
632        }));
633
634        // Create an element that references the unregistered Header component
635        let element = Element::new("Column").with_child(Element::new("Header"));
636
637        // Expand should trigger resolution
638        let expanded = registry.expand(&element);
639
640        // Should have expanded Header into Row { Text }
641        assert_eq!(expanded.element_type, "Column");
642        assert_eq!(expanded.children.len(), 1);
643        let row = &expanded.children[0];
644        assert_eq!(row.element_type, "Row");
645        // Text child is in ir_children (from ast_to_ir_node path)
646        assert_eq!(row.ir_children.len(), 1);
647        match &row.ir_children[0] {
648            crate::ir::IRNode::Element(text) => assert_eq!(text.element_type, "Text"),
649            other => panic!("Expected Element, got {:?}", other),
650        }
651    }
652
653    #[test]
654    fn test_component_resolution_with_path_context() {
655        let mut registry = ComponentRegistry::new();
656
657        // Set up a resolver that resolves based on context path
658        registry.set_resolver(Arc::new(|name: &str, context: Option<&str>| {
659            match (name, context) {
660                ("Button", Some("/pages/Home.hypen")) => Some(ResolvedComponent {
661                    source: r#"Text("Home Button")"#.to_string(),
662                    path: "/components/buttons/HomeButton.hypen".to_string(),
663                    passthrough: false,
664                    lazy: false,
665                }),
666                ("Button", Some("/pages/About.hypen")) => Some(ResolvedComponent {
667                    source: r#"Text("About Button")"#.to_string(),
668                    path: "/components/buttons/AboutButton.hypen".to_string(),
669                    passthrough: false,
670                    lazy: false,
671                }),
672                _ => None,
673            }
674        }));
675
676        // Register a component with a source path
677        let home_element =
678            Element::new("Text").with_prop("0", Value::Static(serde_json::json!("Home")));
679        let home_component = Component::new("Home", move |_| home_element.clone())
680            .with_source_path("/pages/Home.hypen");
681        registry.register(home_component);
682
683        // Create an element that uses Button from Home context
684        let element = Element::new("Column")
685            .with_child(Element::new("Home").with_child(Element::new("Button")));
686
687        let expanded = registry.expand(&element);
688
689        // Button should resolve differently based on its context
690        assert_eq!(expanded.element_type, "Column");
691    }
692
693    #[test]
694    fn test_component_resolution_caching() {
695        let mut registry = ComponentRegistry::new();
696        let call_count = Arc::new(std::sync::Mutex::new(0));
697        let call_count_clone = call_count.clone();
698
699        // Resolver that tracks calls
700        registry.set_resolver(Arc::new(move |name: &str, _context: Option<&str>| {
701            if name == "Button" {
702                *call_count_clone.lock().unwrap() += 1;
703                Some(ResolvedComponent {
704                    source: r#"Text("Click")"#.to_string(),
705                    path: "/components/Button.hypen".to_string(),
706                    passthrough: false,
707                    lazy: false,
708                })
709            } else {
710                None
711            }
712        }));
713
714        // First expansion should call resolver
715        let element1 = Element::new("Button");
716        let _ = registry.expand(&element1);
717        assert_eq!(*call_count.lock().unwrap(), 1);
718
719        // Second expansion should use cache
720        let element2 = Element::new("Button");
721        let _ = registry.expand(&element2);
722        assert_eq!(*call_count.lock().unwrap(), 1); // Still 1, not 2
723    }
724
725    #[test]
726    fn test_failed_resolution_cached() {
727        let mut registry = ComponentRegistry::new();
728        let call_count = Arc::new(std::sync::Mutex::new(0));
729        let call_count_clone = call_count.clone();
730
731        // Resolver that returns None
732        registry.set_resolver(Arc::new(move |_name: &str, _context: Option<&str>| {
733            *call_count_clone.lock().unwrap() += 1;
734            None
735        }));
736
737        // First expansion should call resolver
738        let element1 = Element::new("Unknown");
739        let _ = registry.expand(&element1);
740        assert_eq!(*call_count.lock().unwrap(), 1);
741
742        // Second expansion should use cached failure
743        let element2 = Element::new("Unknown");
744        let _ = registry.expand(&element2);
745        assert_eq!(*call_count.lock().unwrap(), 1); // Cached
746    }
747
748    #[test]
749    fn test_passthrough_component_preserves_props() {
750        let mut registry = ComponentRegistry::new();
751
752        // Register Router and Route as passthrough components
753        registry.set_resolver(Arc::new(|name: &str, _context: Option<&str>| {
754            if name == "Router" || name == "Route" {
755                Some(ResolvedComponent {
756                    source: String::new(), // Empty template for passthrough
757                    path: name.to_string(),
758                    passthrough: true,
759                    lazy: false,
760                })
761            } else if name == "HomePage" {
762                // Regular component with a simple template
763                Some(ResolvedComponent {
764                    source: "Text(\"Home\")".to_string(),
765                    path: name.to_string(),
766                    passthrough: false,
767                    lazy: false,
768                })
769            } else {
770                None
771            }
772        }));
773
774        // Build a tree structure like:
775        // Router {
776        //   Route("/") { HomePage }
777        //   Route("/about") { HomePage }
778        // }
779        let mut router = Element::new("Router");
780
781        let mut route1 = Element::new("Route");
782        route1
783            .props
784            .insert("0".to_string(), Value::Static(serde_json::json!("/")));
785        route1
786            .children
787            .push_back(std::sync::Arc::new(Element::new("HomePage")));
788
789        let mut route2 = Element::new("Route");
790        route2
791            .props
792            .insert("0".to_string(), Value::Static(serde_json::json!("/about")));
793        route2
794            .children
795            .push_back(std::sync::Arc::new(Element::new("HomePage")));
796
797        router.children.push_back(std::sync::Arc::new(route1));
798        router.children.push_back(std::sync::Arc::new(route2));
799
800        // Expand the tree
801        let expanded = registry.expand(&router);
802
803        // Verify Router is preserved
804        assert_eq!(expanded.element_type, "Router");
805        assert_eq!(expanded.children.len(), 2);
806
807        // Verify first Route preserves its path prop
808        let expanded_route1 = &expanded.children[0];
809        assert_eq!(expanded_route1.element_type, "Route");
810        if let Some(Value::Static(path)) = expanded_route1.props.get("0") {
811            assert_eq!(path.as_str().unwrap(), "/");
812        } else {
813            panic!("Route 1 missing path prop");
814        }
815
816        // Verify second Route preserves its path prop
817        let expanded_route2 = &expanded.children[1];
818        assert_eq!(expanded_route2.element_type, "Route");
819        if let Some(Value::Static(path)) = expanded_route2.props.get("0") {
820            assert_eq!(path.as_str().unwrap(), "/about");
821        } else {
822            panic!("Route 2 missing path prop");
823        }
824
825        // Verify children are expanded (HomePage should be replaced with Text)
826        assert_eq!(expanded_route1.children.len(), 1);
827        assert_eq!(expanded_route1.children[0].element_type, "Text");
828    }
829
830    #[test]
831    fn test_bare_miss_does_not_block_context_resolve() {
832        // Regression: a bare-name resolution failure must not prevent
833        // a later context-scoped resolution from succeeding.
834        let mut registry = ComponentRegistry::new();
835
836        registry.set_resolver(Arc::new(|name: &str, context: Option<&str>| {
837            // Only resolve "Header" when inside /pages/Home.hypen
838            match (name, context) {
839                ("Header", Some("/pages/Home.hypen")) => Some(ResolvedComponent {
840                    source: r#"Text("Home Header")"#.to_string(),
841                    path: "/components/Header.hypen".to_string(),
842                    passthrough: false,
843                    lazy: false,
844                }),
845                _ => None,
846            }
847        }));
848
849        // First: bare-name miss (no context)
850        let element = Element::new("Header");
851        let expanded = registry.expand(&element);
852        // Should NOT resolve — bare name has no match
853        assert_eq!(expanded.element_type, "Header");
854
855        // Second: context-scoped resolve should still succeed
856        let element2 = Element::new("Header");
857        let expanded2 = registry.expand_with_context(&element2, Some("/pages/Home.hypen"));
858        // Should resolve into the template (a Text element via ir_children)
859        assert_ne!(expanded2.element_type, "Header",
860            "Context-scoped resolve must not be blocked by prior bare-name miss");
861    }
862
863    #[test]
864    fn test_primitives_never_shadowed_by_resolver() {
865        // Primitives must never be resolved as user components,
866        // even when a resolver would return a match.
867        let mut registry = ComponentRegistry::new();
868        registry.register_primitive("Text");
869
870        registry.set_resolver(Arc::new(|name: &str, _context: Option<&str>| {
871            if name == "Text" {
872                Some(ResolvedComponent {
873                    source: r#"Column { Text("Shadowed!") }"#.to_string(),
874                    path: "/evil/Text.hypen".to_string(),
875                    passthrough: false,
876                    lazy: false,
877                })
878            } else {
879                None
880            }
881        }));
882
883        // Bare resolve — should not shadow
884        let element = Element::new("Text");
885        let expanded = registry.expand(&element);
886        assert_eq!(expanded.element_type, "Text");
887        assert!(expanded.children.is_empty());
888
889        // Context resolve — should still not shadow
890        let expanded2 = registry.expand_with_context(&element, Some("/some/path"));
891        assert_eq!(expanded2.element_type, "Text");
892        assert!(expanded2.children.is_empty());
893    }
894}