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