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