Skip to main content

pax_runtime/
router.rs

1//! Runtime routing primitives used by declarative `Router` / `Route` control flow.
2
3use std::collections::HashMap;
4use std::iter;
5use std::ops::Range;
6use std::rc::Rc;
7use_RefCell!();
8
9use pax_manifest::ControlFlowRouteBranchDefinition;
10use pax_runtime_api::pax_value::ImplToFromPaxAny;
11use pax_runtime_api::{
12    borrow, borrow_mut, use_RefCell, Interpolatable, PaxValue, Property, ToPaxValue, Variable,
13};
14
15use crate::api::Layer;
16use crate::{
17    BaseInstance, ExpandedNode, InstanceFlags, InstanceNode, InstantiationArgs, RuntimeContext,
18    RuntimePropertiesStackFrame,
19};
20
21/// Internal stack symbol carrying the router input location for nested scopes.
22pub const INTERNAL_ROUTE_LOCATION_SYMBOL: &str = "$route_location";
23/// Internal stack symbol carrying the current route match object.
24pub const INTERNAL_ROUTE_MATCH_SYMBOL: &str = "$route_match";
25const ROUTE_SYMBOL: &str = "route";
26
27/// Structured application location shared across platforms.
28///
29/// On web targets this is serialized to and from `window.location`, while on
30/// non-web targets it remains a platform-agnostic route state model.
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct RouteLocation {
33    /// Decoded path segments, with `/` represented by an empty vector.
34    pub path_segments: Vec<String>,
35    /// Query-string values keyed by parameter name, preserving repeated keys.
36    pub query: HashMap<String, Vec<String>>,
37    /// Optional fragment without the leading `#`.
38    pub fragment: Option<String>,
39}
40
41impl RouteLocation {
42    /// Returns the canonical root location.
43    pub fn root() -> Self {
44        Self::default()
45    }
46
47    /// Clones this location while replacing only the path segments.
48    pub fn with_path_segments(&self, path_segments: Vec<String>) -> Self {
49        Self {
50            path_segments,
51            query: self.query.clone(),
52            fragment: self.fragment.clone(),
53        }
54    }
55}
56
57impl From<pax_message::RouteChangeInterruptArgs> for RouteLocation {
58    fn from(value: pax_message::RouteChangeInterruptArgs) -> Self {
59        Self {
60            path_segments: value.path_segments,
61            query: value.query,
62            fragment: value.fragment,
63        }
64    }
65}
66
67impl From<&pax_message::RouteChangeInterruptArgs> for RouteLocation {
68    fn from(value: &pax_message::RouteChangeInterruptArgs) -> Self {
69        Self {
70            path_segments: value.path_segments.clone(),
71            query: value.query.clone(),
72            fragment: value.fragment.clone(),
73        }
74    }
75}
76
77impl Interpolatable for RouteLocation {}
78
79impl ToPaxValue for RouteLocation {
80    fn to_pax_value(self) -> PaxValue {
81        PaxValue::Object(vec![
82            (
83                "path_segments".to_string(),
84                self.path_segments.to_pax_value(),
85            ),
86            ("query".to_string(), multimap_to_pax_value(&self.query)),
87            ("fragment".to_string(), self.fragment.to_pax_value()),
88        ])
89    }
90}
91
92/// Structured match data exposed to the active route subtree as `route`.
93#[derive(Clone, Debug, Default, PartialEq, Eq)]
94pub struct RouteMatch {
95    /// Location as seen by the current router scope.
96    pub location: RouteLocation,
97    /// Full global application location.
98    pub global_location: RouteLocation,
99    /// Named values captured from `:param` segments.
100    pub params: HashMap<String, String>,
101    /// Number of path segments consumed by the selected branch.
102    pub consumed_segments: usize,
103    /// Remaining path tail left after the selected branch.
104    pub remainder: Vec<String>,
105    /// Whether the selected branch consumed the entire scoped path.
106    pub is_exact: bool,
107}
108
109impl RouteMatch {
110    /// Converts the remainder into a `RouteLocation` for nested router input.
111    pub fn remainder_location(&self) -> RouteLocation {
112        self.location.with_path_segments(self.remainder.clone())
113    }
114
115    fn default_match(location: &RouteLocation, global_location: &RouteLocation) -> Self {
116        Self {
117            location: location.clone(),
118            global_location: global_location.clone(),
119            params: HashMap::new(),
120            consumed_segments: 0,
121            remainder: location.path_segments.clone(),
122            is_exact: location.path_segments.is_empty(),
123        }
124    }
125}
126
127impl Interpolatable for RouteMatch {}
128
129impl ToPaxValue for RouteMatch {
130    fn to_pax_value(self) -> PaxValue {
131        PaxValue::Object(vec![
132            ("location".to_string(), self.location.to_pax_value()),
133            (
134                "global_location".to_string(),
135                self.global_location.to_pax_value(),
136            ),
137            ("params".to_string(), string_map_to_pax_value(&self.params)),
138            (
139                "consumed_segments".to_string(),
140                self.consumed_segments.to_pax_value(),
141            ),
142            ("remainder".to_string(), self.remainder.to_pax_value()),
143            ("is_exact".to_string(), self.is_exact.to_pax_value()),
144        ])
145    }
146}
147
148/// Internal router inputs carried into a `RouterInstance`.
149#[derive(Default)]
150pub struct RouterProperties {
151    /// Location scoped to the current router.
152    pub input_location: Property<RouteLocation>,
153    /// Full application location retained for diagnostics and coordination.
154    pub global_location: Property<RouteLocation>,
155}
156
157impl ImplToFromPaxAny for RouterProperties {}
158
159impl ToPaxValue for RouterProperties {
160    fn to_pax_value(self) -> PaxValue {
161        PaxValue::Object(vec![
162            (
163                "input_location".to_string(),
164                self.input_location.to_pax_value(),
165            ),
166            (
167                "global_location".to_string(),
168                self.global_location.to_pax_value(),
169            ),
170        ])
171    }
172}
173
174#[derive(Clone, Debug, PartialEq, Eq)]
175enum RouteSegmentPattern {
176    Literal(String),
177    Param(String),
178    CatchAll,
179}
180
181/// Compiled route branch used by the runtime matcher.
182#[derive(Clone, Debug)]
183pub struct CompiledRouteBranch {
184    /// Whether this branch is the fallback `default=true` branch.
185    pub default: bool,
186    /// Whether this branch should stack over the previously active branch.
187    pub modal: bool,
188    pattern: Vec<RouteSegmentPattern>,
189}
190
191impl CompiledRouteBranch {
192    fn from_definition(definition: &ControlFlowRouteBranchDefinition) -> Self {
193        Self {
194            default: definition.default,
195            modal: definition.modal,
196            pattern: definition
197                .path
198                .as_deref()
199                .map(compile_route_pattern)
200                .unwrap_or_default(),
201        }
202    }
203}
204
205#[derive(Clone, Debug, Default, PartialEq, Eq)]
206struct RouteSelection {
207    branch_index: Option<usize>,
208    route_match: Option<RouteMatch>,
209}
210
211pub struct RouterInstance {
212    base: BaseInstance,
213    branches: Vec<CompiledRouteBranch>,
214    branch_child_ranges: Vec<Range<usize>>,
215}
216
217impl InstanceNode for RouterInstance {
218    fn instantiate(args: InstantiationArgs) -> Rc<Self>
219    where
220        Self: Sized,
221    {
222        Rc::new(Self {
223            base: BaseInstance::new(
224                args,
225                InstanceFlags {
226                    invisible_to_slot: true,
227                    invisible_to_raycasting: true,
228                    layer: Layer::DontCare,
229                    is_component: false,
230                    is_slot: false,
231                },
232            ),
233            branches: vec![],
234            branch_child_ranges: vec![],
235        })
236    }
237
238    fn handle_mount(
239        self: Rc<Self>,
240        expanded_node: &Rc<ExpandedNode>,
241        context: &Rc<RuntimeContext>,
242    ) {
243        self.handle_setup(expanded_node, context, true);
244    }
245
246    fn handle_control_flow_node_expansion(
247        self: Rc<Self>,
248        expanded_node: &Rc<ExpandedNode>,
249        context: &Rc<RuntimeContext>,
250    ) {
251        self.handle_setup(expanded_node, context, false);
252    }
253
254    fn resolve_debug(
255        &self,
256        f: &mut std::fmt::Formatter,
257        _expanded_node: Option<&ExpandedNode>,
258    ) -> std::fmt::Result {
259        f.debug_struct("Router").finish()
260    }
261
262    fn base(&self) -> &BaseInstance {
263        &self.base
264    }
265}
266
267impl RouterInstance {
268    /// Creates a router instance with precompiled route branches.
269    pub fn instantiate_with_branches(
270        args: InstantiationArgs,
271        branches: Vec<CompiledRouteBranch>,
272        branch_child_ranges: Vec<Range<usize>>,
273    ) -> Rc<Self> {
274        Rc::new(Self {
275            base: BaseInstance::new(
276                args,
277                InstanceFlags {
278                    invisible_to_slot: true,
279                    invisible_to_raycasting: true,
280                    layer: Layer::DontCare,
281                    is_component: false,
282                    is_slot: false,
283                },
284            ),
285            branches,
286            branch_child_ranges,
287        })
288    }
289
290    fn handle_setup(
291        self: Rc<Self>,
292        expanded_node: &Rc<ExpandedNode>,
293        context: &Rc<RuntimeContext>,
294        is_mount: bool,
295    ) {
296        let weak_ref_self = Rc::downgrade(expanded_node);
297        let cloned_self = Rc::clone(&self);
298        let cloned_context = Rc::clone(context);
299
300        let input_location =
301            expanded_node.with_properties_unwrapped(|properties: &mut RouterProperties| {
302                properties.input_location.clone()
303            });
304        let global_location =
305            expanded_node.with_properties_unwrapped(|properties: &mut RouterProperties| {
306                properties.global_location.clone()
307            });
308
309        let deps = vec![input_location.untyped(), global_location.untyped()];
310        let branches = self.branches.clone();
311        let branch_child_ranges = self.branch_child_ranges.clone();
312        let old_selection = RefCell::new(RouteSelection::default());
313
314        expanded_node
315            .children
316            .replace_with(Property::computed_with_name(
317                move || {
318                    let Some(cloned_expanded_node) = weak_ref_self.upgrade() else {
319                        panic!("ran evaluator after expanded node dropped (router)")
320                    };
321
322                    let input = input_location.get();
323                    let global = global_location.get();
324                    let selection = select_route_branch(&branches, &input, &global);
325
326                    if *borrow!(old_selection) == selection {
327                        return cloned_expanded_node.current_attached_children();
328                    }
329                    *borrow_mut!(old_selection) = selection.clone();
330
331                    let selected_branch = selection.branch_index.and_then(|branch_index| {
332                        branches
333                            .get(branch_index)
334                            .map(|branch| (branch_index, branch))
335                    });
336                    let selected_range = selected_branch
337                        .and_then(|(branch_index, _)| branch_child_ranges.get(branch_index))
338                        .cloned()
339                        .unwrap_or(0..0);
340                    let children = borrow!(cloned_self.base().get_instance_children());
341                    let start = selected_range.start.min(children.len());
342                    let end = selected_range.end.min(children.len());
343                    let route_match = selection.route_match.clone();
344                    let env = route_match
345                        .clone()
346                        .map(route_scope)
347                        .map(|scope| cloned_expanded_node.stack.push(scope))
348                        .unwrap_or_else(|| Rc::clone(&cloned_expanded_node.stack));
349                    let selected_range = start..end;
350                    let active_children = borrow!(cloned_expanded_node.active_children).clone();
351                    let selected_children = Self::matching_children_for_range(
352                        &cloned_expanded_node,
353                        &active_children,
354                        &children,
355                        &selected_range,
356                        route_match.as_ref(),
357                        &env,
358                        &cloned_context,
359                        is_mount,
360                    );
361
362                    let mut new_active_children = selected_children;
363                    if selected_branch
364                        .map(|(_, branch)| branch.modal)
365                        .unwrap_or(false)
366                    {
367                        for child in active_children {
368                            if !new_active_children
369                                .iter()
370                                .any(|selected| Rc::ptr_eq(selected, &child))
371                            {
372                                new_active_children.push(child);
373                            }
374                        }
375                    }
376
377                    if is_mount {
378                        cloned_expanded_node.attach_children(
379                            new_active_children,
380                            &cloned_context,
381                            &cloned_expanded_node.parent_frame,
382                        )
383                    } else {
384                        new_active_children
385                    }
386                },
387                &deps,
388                &format!("router_children (node id: {})", expanded_node.id.0),
389            ));
390    }
391
392    fn matching_children_for_range(
393        expanded_node: &Rc<ExpandedNode>,
394        active_children: &[Rc<ExpandedNode>],
395        templates: &[Rc<dyn InstanceNode>],
396        range: &Range<usize>,
397        route_match: Option<&RouteMatch>,
398        env: &Rc<RuntimePropertiesStackFrame>,
399        context: &Rc<RuntimeContext>,
400        is_mount: bool,
401    ) -> Vec<Rc<ExpandedNode>> {
402        let mut selected = Vec::new();
403        for template in templates[range.clone()].iter() {
404            let active = active_children
405                .iter()
406                .find(|child| {
407                    !selected.iter().any(|selected| Rc::ptr_eq(selected, child))
408                        && Self::child_uses_template(child, template)
409                        && Self::same_route_instance(
410                            Self::child_route_match(child).as_ref(),
411                            route_match,
412                        )
413                })
414                .cloned();
415            let rescued = active.or_else(|| {
416                (is_mount && expanded_node.attached.get() > 0)
417                    .then(|| {
418                        expanded_node.rescue_exiting_child_matching(|child| {
419                            Self::child_uses_template(child, template)
420                                && Self::same_route_instance(
421                                    Self::child_route_match(child).as_ref(),
422                                    route_match,
423                                )
424                        })
425                    })
426                    .flatten()
427            });
428            let child = rescued.unwrap_or_else(|| {
429                expanded_node
430                    .create_children_detached(
431                        iter::once((Rc::clone(template), Rc::clone(env))),
432                        context,
433                        &Rc::downgrade(expanded_node),
434                    )
435                    .pop()
436                    .expect("route child creation returned no child")
437            });
438            if let Some(route_match) = route_match {
439                Self::set_child_route_match(&child, route_match.clone());
440            }
441            if !is_mount && child.attached.get() == 0 {
442                child.recurse_control_flow_expansion(context);
443            }
444            selected.push(child);
445        }
446        selected
447    }
448
449    fn child_uses_template(child: &Rc<ExpandedNode>, template: &Rc<dyn InstanceNode>) -> bool {
450        let child_template = borrow!(child.instance_node).clone();
451        Rc::ptr_eq(&child_template, template)
452    }
453
454    fn child_route_match(child: &Rc<ExpandedNode>) -> Option<RouteMatch> {
455        Self::child_route_match_property(child).map(|route_match| route_match.get())
456    }
457
458    fn child_route_match_property(child: &Rc<ExpandedNode>) -> Option<Property<RouteMatch>> {
459        child
460            .stack
461            .resolve_symbol_as_erased_property(INTERNAL_ROUTE_MATCH_SYMBOL)
462            .map(Property::<RouteMatch>::new_from_untyped)
463    }
464
465    fn same_route_instance(lhs: Option<&RouteMatch>, rhs: Option<&RouteMatch>) -> bool {
466        // The template identifies the route declaration. Captured params identify its instance;
467        // remainder and location fields are reactive context for that same mounted instance.
468        match (lhs, rhs) {
469            (Some(lhs), Some(rhs)) => lhs.params == rhs.params,
470            (None, None) => true,
471            _ => false,
472        }
473    }
474
475    fn set_child_route_match(child: &Rc<ExpandedNode>, route_match: RouteMatch) {
476        if let Some(property) = Self::child_route_match_property(child) {
477            property.set_if_neq(route_match);
478        }
479    }
480}
481
482pub fn compile_route_branches(
483    definitions: &[ControlFlowRouteBranchDefinition],
484) -> Vec<CompiledRouteBranch> {
485    definitions
486        .iter()
487        .map(CompiledRouteBranch::from_definition)
488        .collect()
489}
490
491fn compile_route_pattern(path: &str) -> Vec<RouteSegmentPattern> {
492    if path.is_empty() {
493        panic!("Route path must not be empty");
494    }
495
496    let normalized = if path == "/" {
497        "/"
498    } else {
499        path.trim_start_matches('/').trim_end_matches('/')
500    };
501
502    if normalized.is_empty() {
503        return vec![];
504    }
505
506    let segments = normalized
507        .split('/')
508        .filter(|segment| !segment.is_empty())
509        .collect::<Vec<_>>();
510    let mut pattern = Vec::new();
511    for (index, segment) in segments.iter().enumerate() {
512        if *segment == "*" {
513            if index != segments.len() - 1 {
514                panic!("Route catch-all '*' must be terminal");
515            }
516            pattern.push(RouteSegmentPattern::CatchAll);
517        } else if let Some(param_name) = segment.strip_prefix(':') {
518            if param_name.is_empty() {
519                panic!("Route params must be named");
520            }
521            pattern.push(RouteSegmentPattern::Param(param_name.to_string()));
522        } else if segment.contains('*') {
523            panic!("Route only supports terminal '*'");
524        } else {
525            pattern.push(RouteSegmentPattern::Literal(segment.to_string()));
526        }
527    }
528
529    pattern
530}
531
532fn select_route_branch(
533    branches: &[CompiledRouteBranch],
534    input: &RouteLocation,
535    global: &RouteLocation,
536) -> RouteSelection {
537    let mut default_index = None;
538
539    for (index, branch) in branches.iter().enumerate() {
540        if branch.default {
541            default_index.get_or_insert(index);
542            continue;
543        }
544
545        if let Some(route_match) = match_route_branch(branch, input, global) {
546            return RouteSelection {
547                branch_index: Some(index),
548                route_match: Some(route_match),
549            };
550        }
551    }
552
553    default_index
554        .map(|index| RouteSelection {
555            branch_index: Some(index),
556            route_match: Some(RouteMatch::default_match(input, global)),
557        })
558        .unwrap_or_default()
559}
560
561fn match_route_branch(
562    branch: &CompiledRouteBranch,
563    input: &RouteLocation,
564    global: &RouteLocation,
565) -> Option<RouteMatch> {
566    let mut params = HashMap::new();
567    let mut input_index = 0usize;
568
569    for (pattern_index, segment_pattern) in branch.pattern.iter().enumerate() {
570        match segment_pattern {
571            RouteSegmentPattern::Literal(expected) => {
572                let actual = input.path_segments.get(input_index)?;
573                if actual != expected {
574                    return None;
575                }
576                input_index += 1;
577            }
578            RouteSegmentPattern::Param(name) => {
579                let actual = input.path_segments.get(input_index)?;
580                params.insert(name.clone(), actual.clone());
581                input_index += 1;
582            }
583            RouteSegmentPattern::CatchAll => {
584                if pattern_index != branch.pattern.len() - 1 {
585                    return None;
586                }
587
588                let remainder = input.path_segments[input_index..].to_vec();
589                return Some(RouteMatch {
590                    location: input.clone(),
591                    global_location: global.clone(),
592                    params,
593                    consumed_segments: input_index,
594                    is_exact: remainder.is_empty(),
595                    remainder,
596                });
597            }
598        }
599    }
600
601    if input_index != input.path_segments.len() {
602        return None;
603    }
604
605    Some(RouteMatch {
606        location: input.clone(),
607        global_location: global.clone(),
608        params,
609        consumed_segments: input_index,
610        remainder: vec![],
611        is_exact: true,
612    })
613}
614
615fn route_scope(route_match: RouteMatch) -> HashMap<String, Variable> {
616    let route_property = Property::new(route_match);
617    vec![
618        (
619            ROUTE_SYMBOL.to_string(),
620            Variable::new_from_typed_property(route_property.clone()),
621        ),
622        (
623            INTERNAL_ROUTE_MATCH_SYMBOL.to_string(),
624            Variable::new_from_typed_property(route_property),
625        ),
626    ]
627    .into_iter()
628    .collect()
629}
630
631fn multimap_to_pax_value(map: &HashMap<String, Vec<String>>) -> PaxValue {
632    let mut entries = map
633        .iter()
634        .map(|(key, values)| (key.clone(), values.clone().to_pax_value()))
635        .collect::<Vec<_>>();
636    entries.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0));
637    PaxValue::Object(entries)
638}
639
640fn string_map_to_pax_value(map: &HashMap<String, String>) -> PaxValue {
641    let mut entries = map
642        .iter()
643        .map(|(key, value)| (key.clone(), value.clone().to_pax_value()))
644        .collect::<Vec<_>>();
645    entries.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0));
646    PaxValue::Object(entries)
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652    use crate::api::math::{Point2, Transform2};
653    use crate::api::{CommonProperties, Layer, Size, Window};
654    use crate::{
655        BaseInstance, ComponentInstance, Globals, InstanceFlags, RuntimePropertiesStackFrame,
656        TransformAndBounds,
657    };
658    use pax_manifest::cartridge_generation::{
659        ComponentTransitionConfig, TRANSITION_PHASE_ENTER, TRANSITION_PHASE_EXIT,
660        TRANSITION_PHASE_IDLE,
661    };
662    use pax_manifest::ValueDefinition;
663    use pax_runtime_api::pax_value::{PaxAny, ToFromPaxAny};
664    use pax_runtime_api::{Duration, Platform, TargetInfo, OS};
665    use std::cell::RefCell;
666
667    fn route(path_segments: &[&str]) -> RouteLocation {
668        RouteLocation {
669            path_segments: path_segments
670                .iter()
671                .map(|segment| (*segment).to_string())
672                .collect(),
673            ..Default::default()
674        }
675    }
676
677    fn test_globals() -> Globals {
678        Globals {
679            elapsed_frames: Property::new(0),
680            elapsed_millis: Property::new(0),
681            viewport: Property::new(TransformAndBounds {
682                transform: Transform2::identity(),
683                bounds: (100.0, 100.0),
684            }),
685            gyro: Property::new(Default::default()),
686            accel: Property::new(Default::default()),
687            route_location: Property::new(RouteLocation::root()),
688            browser_allows_scroller_vector_layers: Property::new(true),
689            browser_allows_nested_scroller_vector_layers: Property::new(true),
690            platform: Platform::Unknown,
691            os: OS::Unknown,
692            target: TargetInfo::new(Platform::Unknown, OS::Unknown),
693            get_elapsed_millis: Rc::new(|| 0),
694        }
695    }
696
697    fn default_properties_factory() -> Box<
698        dyn Fn(
699            Rc<RuntimePropertiesStackFrame>,
700            Option<Rc<ExpandedNode>>,
701        ) -> Option<Rc<RefCell<PaxAny>>>,
702    > {
703        Box::new(|_, expanded_node| {
704            expanded_node
705                .is_none()
706                .then(|| Rc::new(RefCell::new(PaxAny::Builtin(PaxValue::default()))))
707        })
708    }
709
710    fn default_common_properties_factory() -> Box<
711        dyn Fn(
712            Rc<RuntimePropertiesStackFrame>,
713            Option<Rc<ExpandedNode>>,
714        ) -> Option<Rc<RefCell<CommonProperties>>>,
715    > {
716        Box::new(|_, expanded_node| {
717            expanded_node
718                .is_none()
719                .then(|| Rc::new(RefCell::new(CommonProperties::default())))
720        })
721    }
722
723    fn component_args(template: Option<Vec<Rc<dyn InstanceNode>>>) -> InstantiationArgs {
724        InstantiationArgs {
725            prototypical_common_properties: crate::CommonPropertiesInit::Factory(
726                default_common_properties_factory(),
727            ),
728            prototypical_properties: crate::PropertiesInit::Factory(default_properties_factory()),
729            handler_registry: None,
730            children: None,
731            component_template: template.map(RefCell::new),
732            component_settings: None,
733            template_node_identifier: None,
734            template_node_type_id: None,
735            template_node_selector_info: None,
736            transition_config: Default::default(),
737            properties_scope: crate::PropertiesScopeInit::None,
738        }
739    }
740
741    fn leaf_args() -> InstantiationArgs {
742        InstantiationArgs {
743            prototypical_common_properties: crate::CommonPropertiesInit::Factory(
744                default_common_properties_factory(),
745            ),
746            prototypical_properties: crate::PropertiesInit::Factory(default_properties_factory()),
747            handler_registry: None,
748            children: None,
749            component_template: Some(RefCell::new(Vec::new())),
750            component_settings: None,
751            template_node_identifier: None,
752            template_node_type_id: None,
753            template_node_selector_info: None,
754            transition_config: ComponentTransitionConfig {
755                has_enter: true,
756                enter_frame_count: 10,
757                has_exit: true,
758                exit_frame_count: 10,
759                timeout_ms: 5_000,
760                ..Default::default()
761            },
762            properties_scope: crate::PropertiesScopeInit::None,
763        }
764    }
765
766    fn leaf() -> Rc<dyn InstanceNode> {
767        ComponentInstance::instantiate(leaf_args())
768    }
769
770    struct HitBoxInstance {
771        base: BaseInstance,
772    }
773
774    impl InstanceNode for HitBoxInstance {
775        fn base(&self) -> &BaseInstance {
776            &self.base
777        }
778
779        fn instantiate(args: InstantiationArgs) -> Rc<Self> {
780            Rc::new(Self {
781                base: BaseInstance::new(
782                    args,
783                    InstanceFlags {
784                        invisible_to_slot: false,
785                        invisible_to_raycasting: false,
786                        layer: Layer::Canvas,
787                        is_component: false,
788                        is_slot: false,
789                    },
790                ),
791            })
792        }
793
794        fn resolve_debug(
795            &self,
796            f: &mut std::fmt::Formatter,
797            _expanded_node: Option<&ExpandedNode>,
798        ) -> std::fmt::Result {
799            write!(f, "HitBox")
800        }
801    }
802
803    fn hit_box_args(transition_config: ComponentTransitionConfig) -> InstantiationArgs {
804        InstantiationArgs {
805            prototypical_common_properties: crate::CommonPropertiesInit::Factory(Box::new(
806                |_, expanded_node| {
807                    expanded_node.is_none().then(|| {
808                        let mut cp = CommonProperties::default();
809                        cp.width = Property::new(Some(Size::Pixels(40.into())));
810                        cp.height = Property::new(Some(Size::Pixels(40.into())));
811                        Rc::new(RefCell::new(cp))
812                    })
813                },
814            )),
815            prototypical_properties: crate::PropertiesInit::Factory(default_properties_factory()),
816            handler_registry: None,
817            children: None,
818            component_template: Some(RefCell::new(Vec::new())),
819            component_settings: None,
820            template_node_identifier: None,
821            template_node_type_id: None,
822            template_node_selector_info: None,
823            transition_config,
824            properties_scope: crate::PropertiesScopeInit::None,
825        }
826    }
827
828    fn hit_box(transition_config: ComponentTransitionConfig) -> Rc<dyn InstanceNode> {
829        HitBoxInstance::instantiate(hit_box_args(transition_config))
830    }
831
832    fn dynamic_exit_duration_leaf(frames: f64) -> Rc<dyn InstanceNode> {
833        let mut args = leaf_args();
834        args.transition_config = ComponentTransitionConfig {
835            has_exit: true,
836            exit_dynamic_durations: vec![ValueDefinition::LiteralValue(PaxValue::Duration(
837                Duration::Frames(frames.into()),
838            ))],
839            timeout_ms: 5_000,
840            ..Default::default()
841        };
842        ComponentInstance::instantiate(args)
843    }
844
845    fn router_args(
846        input_location: Property<RouteLocation>,
847        global_location: Property<RouteLocation>,
848        children: Vec<Rc<dyn InstanceNode>>,
849    ) -> InstantiationArgs {
850        let input_location_for_factory = input_location.clone();
851        let global_location_for_factory = global_location.clone();
852        InstantiationArgs {
853            prototypical_common_properties: crate::CommonPropertiesInit::Factory(
854                default_common_properties_factory(),
855            ),
856            prototypical_properties: crate::PropertiesInit::Factory(Box::new(
857                move |_, expanded_node| {
858                    if let Some(expanded_node) = expanded_node {
859                        expanded_node.with_properties_unwrapped(
860                            |properties: &mut RouterProperties| {
861                                properties
862                                    .input_location
863                                    .replace_with(input_location_for_factory.clone());
864                                properties
865                                    .global_location
866                                    .replace_with(global_location_for_factory.clone());
867                            },
868                        );
869                        return None;
870                    }
871
872                    Some(Rc::new(RefCell::new({
873                        let mut properties = RouterProperties::default();
874                        properties.input_location = input_location_for_factory.clone();
875                        properties.global_location = global_location_for_factory.clone();
876                        properties.to_pax_any()
877                    })))
878                },
879            )),
880            handler_registry: None,
881            children: Some(RefCell::new(children)),
882            component_template: None,
883            component_settings: None,
884            template_node_identifier: None,
885            template_node_type_id: None,
886            template_node_selector_info: None,
887            transition_config: Default::default(),
888            properties_scope: crate::PropertiesScopeInit::None,
889        }
890    }
891
892    fn mounted_router(
893        input_location: Property<RouteLocation>,
894        branch_definitions: Vec<ControlFlowRouteBranchDefinition>,
895        branch_child_ranges: Vec<Range<usize>>,
896        children: Vec<Rc<dyn InstanceNode>>,
897    ) -> (Rc<ExpandedNode>, Rc<ExpandedNode>, Rc<RuntimeContext>) {
898        let router: Rc<dyn InstanceNode> = RouterInstance::instantiate_with_branches(
899            router_args(input_location.clone(), input_location, children),
900            compile_route_branches(&branch_definitions),
901            branch_child_ranges,
902        );
903        let root_component =
904            ComponentInstance::instantiate(component_args(Some(vec![Rc::clone(&router)])));
905        let context = Rc::new(RuntimeContext::new(test_globals()));
906        let root = ExpandedNode::initialize_root(root_component, &context);
907        root.recurse_update(&context);
908        let router_node = root.children.get().remove(0);
909        (root, router_node, context)
910    }
911
912    fn route_param(node: &Rc<ExpandedNode>, name: &str) -> Option<String> {
913        let route = node.stack.resolve_symbol(ROUTE_SYMBOL)?.get_as_pax_value();
914        let PaxValue::Object(route_fields) = route else {
915            return None;
916        };
917        let params = route_fields
918            .into_iter()
919            .find_map(|(field, value)| (field == "params").then_some(value))?;
920        let PaxValue::Object(param_fields) = params else {
921            return None;
922        };
923        param_fields
924            .into_iter()
925            .find_map(|(field, value)| (field == name).then_some(value))
926            .and_then(|value| match value {
927                PaxValue::String(value) => Some(value),
928                _ => None,
929            })
930    }
931
932    #[test]
933    fn terminal_catch_all_preserves_remainder() {
934        let branches = compile_route_branches(&[ControlFlowRouteBranchDefinition {
935            path: Some("/settings/*".to_string()),
936            default: false,
937            modal: false,
938            child_ids: vec![],
939            metadata: None,
940        }]);
941        let input = route(&["settings", "team", "42"]);
942        let selection = select_route_branch(&branches, &input, &input);
943
944        assert_eq!(selection.branch_index, Some(0));
945        let route_match = selection.route_match.unwrap();
946        assert_eq!(route_match.consumed_segments, 1);
947        assert_eq!(
948            route_match.remainder,
949            vec!["team".to_string(), "42".to_string()]
950        );
951        assert!(!route_match.is_exact);
952    }
953
954    #[test]
955    fn params_are_captured_for_exact_routes() {
956        let branches = compile_route_branches(&[ControlFlowRouteBranchDefinition {
957            path: Some("/docs/:slug".to_string()),
958            default: false,
959            modal: false,
960            child_ids: vec![],
961            metadata: None,
962        }]);
963        let input = route(&["docs", "router"]);
964        let selection = select_route_branch(&branches, &input, &input);
965
966        assert_eq!(selection.branch_index, Some(0));
967        let route_match = selection.route_match.unwrap();
968        assert_eq!(route_match.params.get("slug"), Some(&"router".to_string()));
969        assert_eq!(route_match.remainder, Vec::<String>::new());
970        assert!(route_match.is_exact);
971    }
972
973    #[test]
974    fn relative_paths_compile_like_absolute_paths() {
975        let branches = compile_route_branches(&[ControlFlowRouteBranchDefinition {
976            path: Some("settings/*".to_string()),
977            default: false,
978            modal: false,
979            child_ids: vec![],
980            metadata: None,
981        }]);
982        let input = route(&["settings", "integrations", "logs"]);
983        let selection = select_route_branch(&branches, &input, &input);
984
985        assert_eq!(selection.branch_index, Some(0));
986        let route_match = selection.route_match.unwrap();
987        assert_eq!(
988            route_match.remainder,
989            vec!["integrations".to_string(), "logs".to_string()]
990        );
991    }
992
993    #[test]
994    fn default_branch_receives_unmatched_input() {
995        let branches = compile_route_branches(&[
996            ControlFlowRouteBranchDefinition {
997                path: Some("/docs/:slug".to_string()),
998                default: false,
999                modal: false,
1000                child_ids: vec![],
1001                metadata: None,
1002            },
1003            ControlFlowRouteBranchDefinition {
1004                path: None,
1005                default: true,
1006                modal: false,
1007                child_ids: vec![],
1008                metadata: None,
1009            },
1010        ]);
1011        let input = route(&["missing"]);
1012        let selection = select_route_branch(&branches, &input, &input);
1013
1014        assert_eq!(selection.branch_index, Some(1));
1015        let route_match = selection.route_match.unwrap();
1016        assert_eq!(route_match.remainder, vec!["missing".to_string()]);
1017        assert_eq!(route_match.location, input);
1018    }
1019
1020    #[test]
1021    fn same_branch_route_match_change_enters_new_tree_and_exits_old_tree() {
1022        let input_location = Property::new(route(&["users", "1"]));
1023        let (_root, router_node, context) = mounted_router(
1024            input_location.clone(),
1025            vec![ControlFlowRouteBranchDefinition {
1026                path: Some("/users/:id".to_string()),
1027                default: false,
1028                modal: false,
1029                child_ids: vec![],
1030                metadata: None,
1031            }],
1032            vec![0..1],
1033            vec![leaf()],
1034        );
1035
1036        let initial_active = borrow!(router_node.active_children).clone();
1037        assert_eq!(initial_active.len(), 1);
1038        assert_eq!(route_param(&initial_active[0], "id"), Some("1".to_string()));
1039
1040        input_location.set(route(&["users", "2"]));
1041        router_node.recurse_update(&context);
1042
1043        let active = borrow!(router_node.active_children).clone();
1044        let exiting = borrow!(router_node.exiting_children).clone();
1045        assert_eq!(active.len(), 1);
1046        assert_eq!(exiting.len(), 1);
1047        assert_ne!(active[0].id.0, initial_active[0].id.0);
1048        assert_eq!(route_param(&active[0], "id"), Some("2".to_string()));
1049        assert_eq!(route_param(&exiting[0], "id"), Some("1".to_string()));
1050        assert_eq!(active[0].transition_phase.get(), TRANSITION_PHASE_ENTER);
1051        assert_eq!(exiting[0].transition_phase.get(), TRANSITION_PHASE_EXIT);
1052    }
1053
1054    #[test]
1055    fn catch_all_reuses_route_tree_when_only_remainder_changes() {
1056        let input_location = Property::new(route(&["teams", "atlas"]));
1057        let (_root, router_node, context) = mounted_router(
1058            input_location.clone(),
1059            vec![ControlFlowRouteBranchDefinition {
1060                path: Some("/teams/:id/*".to_string()),
1061                default: false,
1062                modal: false,
1063                child_ids: vec![],
1064                metadata: None,
1065            }],
1066            vec![0..1],
1067            vec![leaf()],
1068        );
1069
1070        let initial_active = borrow!(router_node.active_children).clone();
1071        assert_eq!(initial_active.len(), 1);
1072
1073        input_location.set(route(&[
1074            "teams",
1075            "atlas",
1076            "settings",
1077            "integrations",
1078            "logs",
1079        ]));
1080        router_node.recurse_update(&context);
1081
1082        let active = borrow!(router_node.active_children).clone();
1083        assert_eq!(active.len(), 1);
1084        assert_eq!(active[0].id.0, initial_active[0].id.0);
1085        assert!(borrow!(router_node.exiting_children).is_empty());
1086        assert_eq!(
1087            RouterInstance::child_route_match(&active[0])
1088                .expect("active route should retain its match")
1089                .remainder,
1090            vec![
1091                "settings".to_string(),
1092                "integrations".to_string(),
1093                "logs".to_string(),
1094            ]
1095        );
1096    }
1097
1098    #[test]
1099    fn branch_change_transitions_each_root_of_multi_root_routes() {
1100        let input_location = Property::new(route(&["alpha"]));
1101        let (_root, router_node, context) = mounted_router(
1102            input_location.clone(),
1103            vec![
1104                ControlFlowRouteBranchDefinition {
1105                    path: Some("/alpha".to_string()),
1106                    default: false,
1107                    modal: false,
1108                    child_ids: vec![],
1109                    metadata: None,
1110                },
1111                ControlFlowRouteBranchDefinition {
1112                    path: Some("/beta".to_string()),
1113                    default: false,
1114                    modal: false,
1115                    child_ids: vec![],
1116                    metadata: None,
1117                },
1118            ],
1119            vec![0..2, 2..4],
1120            vec![leaf(), leaf(), leaf(), leaf()],
1121        );
1122
1123        let initial_active = borrow!(router_node.active_children).clone();
1124        assert_eq!(initial_active.len(), 2);
1125
1126        input_location.set(route(&["beta"]));
1127        router_node.recurse_update(&context);
1128
1129        let active = borrow!(router_node.active_children).clone();
1130        let exiting = borrow!(router_node.exiting_children).clone();
1131        assert_eq!(active.len(), 2);
1132        assert_eq!(exiting.len(), 2);
1133        assert!(active
1134            .iter()
1135            .all(|child| child.transition_phase.get() == TRANSITION_PHASE_ENTER));
1136        assert!(exiting
1137            .iter()
1138            .all(|child| child.transition_phase.get() == TRANSITION_PHASE_EXIT));
1139        assert!(initial_active
1140            .iter()
1141            .all(|old_child| exiting.iter().any(|child| child.id.0 == old_child.id.0)));
1142    }
1143
1144    #[test]
1145    fn branch_can_be_reselected_while_previous_instance_is_exiting() {
1146        let input_location = Property::new(route(&["alpha"]));
1147        let (_root, router_node, context) = mounted_router(
1148            input_location.clone(),
1149            vec![
1150                ControlFlowRouteBranchDefinition {
1151                    path: Some("/alpha".to_string()),
1152                    default: false,
1153                    modal: false,
1154                    child_ids: vec![],
1155                    metadata: None,
1156                },
1157                ControlFlowRouteBranchDefinition {
1158                    path: Some("/beta".to_string()),
1159                    default: false,
1160                    modal: false,
1161                    child_ids: vec![],
1162                    metadata: None,
1163                },
1164            ],
1165            vec![0..1, 1..2],
1166            vec![leaf(), leaf()],
1167        );
1168
1169        let initial_alpha = borrow!(router_node.active_children)[0].id;
1170
1171        input_location.set(route(&["beta"]));
1172        router_node.recurse_update(&context);
1173        assert_eq!(borrow!(router_node.active_children).len(), 1);
1174        assert_eq!(borrow!(router_node.exiting_children).len(), 1);
1175        assert_eq!(borrow!(router_node.exiting_children)[0].id, initial_alpha);
1176
1177        input_location.set(route(&["alpha"]));
1178        router_node.recurse_update(&context);
1179
1180        let active = borrow!(router_node.active_children).clone();
1181        let exiting = borrow!(router_node.exiting_children).clone();
1182        assert_eq!(active.len(), 1);
1183        assert_eq!(active[0].id, initial_alpha);
1184        assert_eq!(active[0].transition_phase.get(), TRANSITION_PHASE_ENTER);
1185        assert_eq!(exiting.len(), 1);
1186        assert_ne!(exiting[0].id, initial_alpha);
1187    }
1188
1189    #[test]
1190    fn retained_exiting_route_tree_does_not_intercept_hit_testing() {
1191        let input_location = Property::new(route(&["alpha"]));
1192        let exiting_route_config = ComponentTransitionConfig {
1193            has_exit: true,
1194            exit_frame_count: 30,
1195            timeout_ms: 5_000,
1196            ..Default::default()
1197        };
1198        let (root, router_node, context) = mounted_router(
1199            input_location.clone(),
1200            vec![
1201                ControlFlowRouteBranchDefinition {
1202                    path: Some("/alpha".to_string()),
1203                    default: false,
1204                    modal: false,
1205                    child_ids: vec![],
1206                    metadata: None,
1207                },
1208                ControlFlowRouteBranchDefinition {
1209                    path: Some("/beta".to_string()),
1210                    default: false,
1211                    modal: false,
1212                    child_ids: vec![],
1213                    metadata: None,
1214                },
1215            ],
1216            vec![0..1, 1..2],
1217            vec![hit_box(exiting_route_config), hit_box(Default::default())],
1218        );
1219
1220        input_location.set(route(&["beta"]));
1221        router_node.recurse_update(&context);
1222
1223        let active = borrow!(router_node.active_children).clone();
1224        assert_eq!(active.len(), 1);
1225        assert_eq!(borrow!(router_node.exiting_children).len(), 1);
1226
1227        let hits = context.get_elements_beneath_ray(
1228            Some(root),
1229            Point2::<Window>::new(5.0, 5.0),
1230            true,
1231            vec![],
1232            false,
1233        );
1234        assert_eq!(hits.first().map(|node| node.id), Some(active[0].id));
1235    }
1236
1237    #[test]
1238    fn dynamic_exit_duration_controls_retained_route_cleanup() {
1239        let input_location = Property::new(route(&["alpha"]));
1240        let (_root, router_node, context) = mounted_router(
1241            input_location.clone(),
1242            vec![
1243                ControlFlowRouteBranchDefinition {
1244                    path: Some("/alpha".to_string()),
1245                    default: false,
1246                    modal: false,
1247                    child_ids: vec![],
1248                    metadata: None,
1249                },
1250                ControlFlowRouteBranchDefinition {
1251                    path: Some("/beta".to_string()),
1252                    default: false,
1253                    modal: false,
1254                    child_ids: vec![],
1255                    metadata: None,
1256                },
1257            ],
1258            vec![0..1, 1..2],
1259            vec![dynamic_exit_duration_leaf(3.0), leaf()],
1260        );
1261
1262        input_location.set(route(&["beta"]));
1263        router_node.recurse_update(&context);
1264        assert_eq!(borrow!(router_node.exiting_children).len(), 1);
1265        let exiting_child = borrow!(router_node.exiting_children)[0].clone();
1266
1267        context.globals().elapsed_frames.set(2);
1268        context.drain_node_effects();
1269        assert_eq!(borrow!(router_node.exiting_children).len(), 1);
1270
1271        context.globals().elapsed_frames.set(3);
1272        context.drain_node_effects();
1273        assert!(borrow!(router_node.exiting_children).is_empty());
1274        assert_eq!(exiting_child.transition_phase.get(), TRANSITION_PHASE_IDLE);
1275        assert!(!pax_runtime_api::properties::property_has_direct_outbound(
1276            &context.globals().elapsed_frames.untyped(),
1277            &exiting_child.transition_playhead.untyped(),
1278        ));
1279        assert!(!pax_runtime_api::properties::property_has_direct_outbound(
1280            &context.globals().elapsed_millis.untyped(),
1281            &exiting_child.transition_playhead_millis.untyped(),
1282        ));
1283    }
1284
1285    #[test]
1286    fn active_enter_transition_returns_to_idle_after_duration() {
1287        let input_location = Property::new(route(&["alpha"]));
1288        let (_root, router_node, context) = mounted_router(
1289            input_location.clone(),
1290            vec![
1291                ControlFlowRouteBranchDefinition {
1292                    path: Some("/alpha".to_string()),
1293                    default: false,
1294                    modal: false,
1295                    child_ids: vec![],
1296                    metadata: None,
1297                },
1298                ControlFlowRouteBranchDefinition {
1299                    path: Some("/beta".to_string()),
1300                    default: false,
1301                    modal: false,
1302                    child_ids: vec![],
1303                    metadata: None,
1304                },
1305            ],
1306            vec![0..1, 1..2],
1307            vec![leaf(), leaf()],
1308        );
1309
1310        input_location.set(route(&["beta"]));
1311        router_node.recurse_update(&context);
1312
1313        let active = borrow!(router_node.active_children).clone();
1314        assert_eq!(active.len(), 1);
1315        assert_eq!(active[0].transition_phase.get(), TRANSITION_PHASE_ENTER);
1316
1317        context.globals().elapsed_frames.set(9);
1318        context.drain_node_effects();
1319        assert_eq!(active[0].transition_phase.get(), TRANSITION_PHASE_ENTER);
1320
1321        context.globals().elapsed_frames.set(10);
1322        context.drain_node_effects();
1323        assert_eq!(active[0].transition_phase.get(), TRANSITION_PHASE_IDLE);
1324    }
1325
1326    #[test]
1327    fn modal_branch_keeps_previous_route_active_underneath() {
1328        let input_location = Property::new(route(&["alpha"]));
1329        let (_root, router_node, context) = mounted_router(
1330            input_location.clone(),
1331            vec![
1332                ControlFlowRouteBranchDefinition {
1333                    path: Some("/alpha".to_string()),
1334                    default: false,
1335                    modal: false,
1336                    child_ids: vec![],
1337                    metadata: None,
1338                },
1339                ControlFlowRouteBranchDefinition {
1340                    path: Some("/tools".to_string()),
1341                    default: false,
1342                    modal: true,
1343                    child_ids: vec![],
1344                    metadata: None,
1345                },
1346            ],
1347            vec![0..1, 1..2],
1348            vec![leaf(), leaf()],
1349        );
1350
1351        let base_id = borrow!(router_node.active_children)[0].id;
1352
1353        input_location.set(route(&["tools"]));
1354        router_node.recurse_update(&context);
1355
1356        let active = borrow!(router_node.active_children).clone();
1357        let exiting = borrow!(router_node.exiting_children).clone();
1358        assert_eq!(active.len(), 2);
1359        assert!(exiting.is_empty());
1360        assert_ne!(active[0].id, base_id);
1361        assert_eq!(active[0].transition_phase.get(), TRANSITION_PHASE_ENTER);
1362        assert_eq!(active[1].id, base_id);
1363    }
1364
1365    #[test]
1366    fn dismissing_modal_reveals_previous_route_without_remounting() {
1367        let input_location = Property::new(route(&["alpha"]));
1368        let (_root, router_node, context) = mounted_router(
1369            input_location.clone(),
1370            vec![
1371                ControlFlowRouteBranchDefinition {
1372                    path: Some("/alpha".to_string()),
1373                    default: false,
1374                    modal: false,
1375                    child_ids: vec![],
1376                    metadata: None,
1377                },
1378                ControlFlowRouteBranchDefinition {
1379                    path: Some("/tools".to_string()),
1380                    default: false,
1381                    modal: true,
1382                    child_ids: vec![],
1383                    metadata: None,
1384                },
1385            ],
1386            vec![0..1, 1..2],
1387            vec![leaf(), leaf()],
1388        );
1389
1390        let base_id = borrow!(router_node.active_children)[0].id;
1391
1392        input_location.set(route(&["tools"]));
1393        router_node.recurse_update(&context);
1394        let modal_id = borrow!(router_node.active_children)[0].id;
1395
1396        input_location.set(route(&["alpha"]));
1397        router_node.recurse_update(&context);
1398
1399        let active = borrow!(router_node.active_children).clone();
1400        let exiting = borrow!(router_node.exiting_children).clone();
1401        assert_eq!(active.len(), 1);
1402        assert_eq!(active[0].id, base_id);
1403        assert_eq!(exiting.len(), 1);
1404        assert_eq!(exiting[0].id, modal_id);
1405        assert_eq!(exiting[0].transition_phase.get(), TRANSITION_PHASE_EXIT);
1406    }
1407
1408    #[test]
1409    fn no_match_transitions_out_previous_route_tree() {
1410        let input_location = Property::new(route(&["alpha"]));
1411        let (_root, router_node, context) = mounted_router(
1412            input_location.clone(),
1413            vec![ControlFlowRouteBranchDefinition {
1414                path: Some("/alpha".to_string()),
1415                default: false,
1416                modal: false,
1417                child_ids: vec![],
1418                metadata: None,
1419            }],
1420            vec![0..1],
1421            vec![leaf()],
1422        );
1423
1424        assert_eq!(borrow!(router_node.active_children).len(), 1);
1425
1426        input_location.set(route(&["missing"]));
1427        router_node.recurse_update(&context);
1428
1429        let active = borrow!(router_node.active_children).clone();
1430        let exiting = borrow!(router_node.exiting_children).clone();
1431        assert!(active.is_empty());
1432        assert_eq!(exiting.len(), 1);
1433        assert_eq!(exiting[0].transition_phase.get(), TRANSITION_PHASE_EXIT);
1434    }
1435}