Skip to main content

frust_widgets/nav/
router.rs

1//! The declarative router: a go_router-subset layer over the imperative
2//! [`navigator`](super::navigator).
3//!
4//! # Shape
5//!
6//! A [`Route`] pairs a path pattern (`/users/:id`) with a page builder and
7//! optional `name`, per-route `redirect`, and nested `children` (whose patterns
8//! compose parent + child, go_router style). A [`Router`] owns a route table, a
9//! [`NavigatorController`] it drives, an `error_builder` fallback, an optional
10//! top-level `redirect`, and a `redirect_limit` (go_router's default of 5).
11//!
12//! # Resolution
13//!
14//! [`Router::resolve`] turns a location string into a [`Resolution`]:
15//! percent-parsed via [`Location`], matched against the route tree (capturing
16//! `:param`s and composing nested chains), with **per-route and top-level
17//! redirects applied under a loop guard** — exceeding `redirect_limit` falls back
18//! to the error route, as does an unmatched location. `resolve` is pure (no
19//! controller side effects), so the matcher/redirect logic is unit-testable
20//! without a navigator.
21//!
22//! # Navigation API
23//!
24//! [`go`](Router::go) resets the stack to the matched chain (replace semantics —
25//! the current top's state is dropped); [`push`](Router::push) stacks the matched
26//! leaf (the page below is retained); [`replace`](Router::replace) swaps the top
27//! for the matched leaf; [`pop`](Router::pop) pops one page.
28//! [`go_named`](Router::go_named)/[`push_named`](Router::push_named) resolve a
29//! named route's params into a path first. Every one drives the owned
30//! [`NavigatorController`] — no reactive types here; the signal glue that feeds
31//! [`handle_location`](Router::handle_location) deep links lives in the facade,
32//! keeping `frust-widgets` reactive-free.
33//!
34//! The router is plain data + logic an app keeps in its `Component::State`
35//! alongside the controller — no global registry.
36//!
37//! # Reaching the router from a screen
38//!
39//! Neither a `Router` (`Rc` page builders) nor a [`NavigatorController`]
40//! (`Rc<RefCell<…>>`) can ride `provide_context`, which needs `Send + Sync`. The
41//! seam that can is [`RouteNavigator`] — a plain `Send + Sync` queue of
42//! [`NavRequest`] data any screen (or background task) appends to, which
43//! [`pump`](Router::pump) drains and applies here on the UI thread. Get one from
44//! [`route_navigator`](Router::route_navigator); the facade's
45//! `RouterDeepLinks::track` pumps it every rebuild, so an app that already calls
46//! `track()` gets path navigation with no extra wiring. See the
47//! [`route`](super::route) module for the full rationale.
48//!
49//! # Params handed to a page builder
50//!
51//! A page's [`RouteBuilder`] receives the location's **query merged under the
52//! path captures** — `/terminal?session=abc` reaches its builder with
53//! `session=abc`, and a `:id` capture beats a `?id=` of the same name. This is
54//! also what makes named navigation round-trip: [`path_for_name`](Router::path_for_name)
55//! emits params it could not substitute into a segment as query parameters, and
56//! resolution now reads them back.
57//!
58//! # Shell routes (nested navigators)
59//!
60//! [`shell_route`] binds a route's **children** to a second
61//! [`NavigatorController`] the app owns: the shell route's own page is built on
62//! the enclosing controller and *retained* while its children resolve onto the
63//! inner one (go_router's `ShellRoute`). A resolved chain that crosses a shell
64//! boundary is therefore **split** — the segment up to and including the shell
65//! page applies to the enclosing controller, the segment below it to the
66//! shell's inner controller — instead of flattening onto the single controller
67//! the router was built with. A chain that crosses no shell still flattens
68//! exactly as it always did.
69//!
70//! The **keep rule** is the point: when the shell page is already on the
71//! enclosing stack, a navigation *within* its subtree issues **zero** ops
72//! there. Replacing that page would drop the retained inner navigator and
73//! every page in it, which is the whole thing a shell exists to prevent.
74//! "Already placed?" is answered by the published route stack
75//! ([`NavigatorController::route_stack`]) — fact, not the op queue's intent —
76//! plus this router's own record of a placement it queued in this same frame
77//! (see [`shell_route`] for the full table and the staleness note).
78//!
79//! # Deferred (documented seams)
80//!
81//! * **Full arbitrary-depth stack reset for `go`.** The [`NavigatorController`]
82//!   exposes `push`/`pop`/`replace` only (no "pop to root"/"clear"). `go` therefore
83//!   *replaces the current top* and pushes the chain's remaining pages, dropping
84//!   the replaced page's state — correct for the common flat case and for the
85//!   depth-1 root case. Resetting a deeper stack to a shorter chain (dropping
86//!   pages *below* the top) needs a controller reset op; recorded for a later
87//!   navigator revision rather than reaching across into `navigator.rs`. A shell
88//!   chain does not widen this: each segment is placed with the same
89//!   replace-top-and-push-the-rest rule, one controller at a time.
90//! * **Shell-aware [`pop`](Router::pop).** `Router::pop` pops the router's *own*
91//!   controller, shell or no shell. Popping "one page, wherever the user
92//!   actually is" is back arbitration's job, not the router's — the facade's
93//!   back handler routes a press to the innermost navigator that claims
94//!   interest, and an inner navigator at depth 1 claims none, so the press
95//!   falls through and pops the shell page itself. See [`shell_route`]'s
96//!   *Back* section.
97
98use std::cell::RefCell;
99use std::collections::{HashMap, HashSet};
100use std::rc::Rc;
101
102use frust_core::{AnyView, View, any};
103
104use super::navigator::{NavigatorController, NavigatorId, PushOptions, ReplaceOptions};
105use super::path::{Location, PathPattern, RouteParams, encode_segment};
106use super::route::{NavRequest, RouteNavigator};
107
108/// A page builder for a route: maps the captured [`RouteParams`] to the page's
109/// view. Re-run each time the page rebuilds (via the closure the router hands the
110/// [`NavigatorController`]), so a page reconciles against live app state.
111pub type RouteBuilder<State> = Rc<dyn Fn(&RouteParams) -> AnyView<State>>;
112
113/// A redirect: given the resolved [`Location`], optionally return a different
114/// location string to redirect to (`None` = no redirect). Used both per-route and
115/// top-level; the [`Router`]'s loop guard bounds a redirect chain.
116pub type Redirect = Rc<dyn Fn(&Location) -> Option<String>>;
117
118/// A fallback page builder for an unmatched (or redirect-looping) location.
119pub type ErrorBuilder<State> = Rc<dyn Fn(&Location) -> AnyView<State>>;
120
121/// go_router's default redirect limit — the number of redirects a single
122/// resolution may follow before falling back to the error route.
123pub const DEFAULT_REDIRECT_LIMIT: usize = 5;
124
125/// One entry in a [`Router`]'s route table: a path pattern, a page builder, and
126/// optional `name`, per-route `redirect`, and nested `children`. Built
127/// fluently — [`Route::new`] then `.name`/`.redirect`/`.child`/`.children`.
128///
129/// Nested children's patterns compose with this route's (go_router semantics): a
130/// route `/users` with a child `:id` matches `/users/42`, and the matched chain
131/// is `[/users, /users/:id]`.
132pub struct Route<State: 'static> {
133    path: String,
134    name: Option<String>,
135    builder: RouteBuilder<State>,
136    redirect: Option<Redirect>,
137    children: Vec<Route<State>>,
138    /// Set only by [`shell_route`]: the controller this route's **children**
139    /// resolve onto. `None` (every route built with [`Route::new`]) means the
140    /// children resolve onto the same controller this route's own page does,
141    /// i.e. the chain flattens.
142    shell: Option<NavigatorController<State>>,
143}
144
145impl<State: 'static> Route<State> {
146    /// A route matching `path` (a pattern like `/users/:id`, or a relative child
147    /// path like `:id`), rendered by `builder`. `builder` may return any
148    /// [`View`]; it is erased here.
149    ///
150    /// ```
151    /// use frust_widgets::{Route, text};
152    /// let route: Route<()> = Route::new("/users/:id", |params| {
153    ///     text(format!("user {}", params.get("id").map_or("?", String::as_str)))
154    /// });
155    /// # let _ = route;
156    /// ```
157    pub fn new<V: View<State>>(
158        path: impl Into<String>,
159        builder: impl Fn(&RouteParams) -> V + 'static,
160    ) -> Self {
161        Route {
162            path: path.into(),
163            name: None,
164            builder: Rc::new(move |params: &RouteParams| any(builder(params))),
165            redirect: None,
166            children: Vec::new(),
167            shell: None,
168        }
169    }
170
171    /// Give this route a `name` for [`go_named`](Router::go_named)/
172    /// [`push_named`](Router::push_named) resolution.
173    pub fn name(mut self, name: impl Into<String>) -> Self {
174        self.name = Some(name.into());
175        self
176    }
177
178    /// Attach a per-route redirect, consulted (against the resolved location)
179    /// whenever this route is part of a matched chain.
180    pub fn redirect(mut self, redirect: impl Fn(&Location) -> Option<String> + 'static) -> Self {
181        self.redirect = Some(Rc::new(redirect));
182        self
183    }
184
185    /// Add one nested child route (its path composes with this route's).
186    pub fn child(mut self, child: Route<State>) -> Self {
187        self.children.push(child);
188        self
189    }
190
191    /// Replace this route's child list.
192    pub fn children(mut self, children: Vec<Route<State>>) -> Self {
193        self.children = children;
194        self
195    }
196}
197
198/// A **shell route**: a pathless route whose `children` resolve onto `inner`, a
199/// second [`NavigatorController`] the app owns, while this route's own page
200/// stays retained on the enclosing stack (go_router's `ShellRoute`).
201///
202/// `builder` builds the shell page — chrome plus the inner navigator, e.g.
203/// `scaffold(navigator(&inner, || …)).app_bar(…)` — and **must** mount a
204/// [`navigator`](super::navigator::navigator) driven by the *same* `inner`
205/// clone; that navigator is where the children land.
206///
207/// ```ignore
208/// // The app owns `inner` in its `Component::State`, exactly like the outer one.
209/// Router::with_controller(&outer, vec![
210///     Route::new("/", connect_page),               // declared BEFORE the shell
211///     shell_route(&inner, shell_page, vec![
212///         Route::new("/sessions", sessions_page),
213///         Route::new("/terminal", terminal_page),
214///     ]),
215/// ])
216/// ```
217///
218/// # Pathless, and why order matters
219///
220/// A shell route consumes no path segments (like go_router's `ShellRoute`,
221/// which has no `path` at all), so its children keep flat public paths. A
222/// zero-consuming route also matches the *empty* segment list, so a root route
223/// (`/`) must be declared **before** the shell — [`Router::resolve`] takes the
224/// first match. To scope a shell under a prefix, nest it: a
225/// `Route::new("/dash").child(shell_route(…))` puts `/dash`'s own page on the
226/// enclosing stack ahead of the shell page.
227///
228/// # How a chain that crosses this route is applied
229///
230/// The resolved chain splits at the boundary. With the shell page already on
231/// the enclosing stack — the **keep rule** — the enclosing controller gets
232/// **zero ops**, because replacing that page would drop the retained inner
233/// navigator and every page in it:
234///
235/// | Verb | Enclosing controller | Inner controller |
236/// |---|---|---|
237/// | [`go`](Router::go) | shell already placed → **keep** (zero ops); else replace the top with the shell page and push the rest | replace the top with the leaf |
238/// | [`push`](Router::push) | shell already placed → **keep**; else *push* the shell page (the page below — a `/` connect screen, say — is retained) | push the leaf |
239/// | [`replace`](Router::replace) | as `go`'s rule | replace the top with the leaf |
240/// | [`pop`](Router::pop) | pops the router's own controller — see the [module docs](self)' deferred note | — |
241///
242/// One structural op per controller per navigation, so nothing here rests on
243/// stacking two transitions in one `apply_ops` pass.
244///
245/// **A placement resets the verb below it.** When the shell page has to be
246/// placed, the inner navigator is brand new — its stack is just the root page
247/// its `navigator(…)` call names — so there is no in-shell history to stack
248/// onto and every segment below the placement applies as a `go` (replace)
249/// whatever the caller asked for. That is what makes `push("/sessions")` from a
250/// bar-less `/` land as `[/, shell]` outside and `[sessions]` (depth 1) inside,
251/// so a back press at `/sessions` leaves the shell instead of popping to a
252/// placeholder root.
253///
254/// # Matched state reaching the shell page
255///
256/// `builder` receives the resolved chain's merged [`RouteParams`] like any
257/// other route — but only as of the navigation that *placed* it. A navigation
258/// within the shell issues no op on the enclosing controller by design, so the
259/// shell page is neither rebuilt from a new builder nor restamped: its
260/// published route entry keeps naming the location that placed it. Live
261/// in-shell state is read from the **inner** navigator instead — its own
262/// [`route_stack`](NavigatorController::route_stack) (or the facade's
263/// `RouteObserver` over it), which is authoritative at every publish. Chrome
264/// inside the shell page (a title bar) reads that, never the enclosing stack.
265///
266/// # Deep links landing mid-shell
267///
268/// A `go` that runs before the shell page exists — a cold-start deep link
269/// resolved from `Component::init` — queues the inner segment's ops on `inner`
270/// while it has no widget at all. They are drained by the inner navigator's
271/// *first* `build` (the same pre-first-frame drain a top-level controller
272/// gets), so the shell's first painted frame is already the linked child: no
273/// flash, no second navigation. If a shell page defers mounting its navigator
274/// (rendering a loading state first), the ops simply wait on the queue and land
275/// on whichever build mounts it.
276///
277/// # Back
278///
279/// Nothing here wires back. The shell page's builder mounts the inner
280/// navigator, so the inner navigator wires *after* the enclosing one and the
281/// facade's innermost-first arbitration reaches it first: back pops the inner
282/// stack while it is poppable, and an inner navigator at depth 1 claims no
283/// interest, so the press falls through and pops the shell page itself.
284pub fn shell_route<State: 'static, V: View<State>>(
285    inner: &NavigatorController<State>,
286    builder: impl Fn(&RouteParams) -> V + 'static,
287    children: Vec<Route<State>>,
288) -> Route<State> {
289    Route {
290        path: String::new(),
291        name: None,
292        builder: Rc::new(move |params: &RouteParams| any(builder(params))),
293        redirect: None,
294        children,
295        shell: Some(inner.clone()),
296    }
297}
298
299/// One resolved page in a [`Resolution::Matched`] chain: the route's builder,
300/// the params handed to it (query merged under the path captures — see the
301/// [module docs](self)), and the resolved location they came from.
302/// [`build`](ResolvedPage::build) produces the page view; the router wraps it in
303/// a `Fn() -> AnyView` for the controller.
304pub struct ResolvedPage<State: 'static> {
305    builder: RouteBuilder<State>,
306    params: RouteParams,
307    location: Location,
308    /// The [`shell_route`] binding of the route this page came from: the
309    /// controller every page *below* it in the chain applies to. `None` for
310    /// every ordinary route, which is what keeps a non-shell chain flattening
311    /// onto one controller.
312    shell: Option<NavigatorController<State>>,
313}
314
315impl<State: 'static> ResolvedPage<State> {
316    /// Build the page view against its params.
317    pub fn build(&self) -> AnyView<State> {
318        (self.builder)(&self.params)
319    }
320
321    /// The params this page's builder receives.
322    pub fn params(&self) -> &RouteParams {
323        &self.params
324    }
325
326    /// The final (post-redirect) location this page resolved from — the raw
327    /// [`Location`] behind [`params`](Self::params), for a caller that needs the
328    /// path/query split rather than the merged map.
329    pub fn location(&self) -> &Location {
330        &self.location
331    }
332
333    /// Turn this page into the `Fn() -> AnyView` closure a
334    /// [`NavigatorController`] op takes (re-run each rebuild).
335    fn into_page_builder(self) -> impl Fn() -> AnyView<State> + 'static {
336        let ResolvedPage {
337            builder, params, ..
338        } = self;
339        move || (builder)(&params)
340    }
341}
342
343/// The outcome of [`Router::resolve`]: either a matched chain of pages (with the
344/// final resolved location and merged params) or an error fallback.
345pub enum Resolution<State: 'static> {
346    /// The location matched a route chain. `pages` is the chain root→leaf; for a
347    /// flat route it holds a single page. `params` is the merged param map (the
348    /// location's query merged **under** the path captures — a capture wins a
349    /// name collision), `location` the final (post-redirect) location.
350    Matched {
351        location: Location,
352        params: RouteParams,
353        pages: Vec<ResolvedPage<State>>,
354    },
355    /// No route matched (or a redirect loop exceeded the limit); the caller falls
356    /// back to the router's `error_builder`.
357    Error { location: Location },
358}
359
360impl<State: 'static> Resolution<State> {
361    /// Whether this resolution matched a route (vs. the error fallback).
362    pub fn is_matched(&self) -> bool {
363        matches!(self, Resolution::Matched { .. })
364    }
365
366    /// The merged params (query under path captures), or an empty map for an
367    /// error resolution.
368    pub fn params(&self) -> RouteParams {
369        match self {
370            Resolution::Matched { params, .. } => params.clone(),
371            Resolution::Error { .. } => RouteParams::new(),
372        }
373    }
374
375    /// The number of pages in a matched chain (0 for an error resolution).
376    pub fn page_count(&self) -> usize {
377        match self {
378            Resolution::Matched { pages, .. } => pages.len(),
379            Resolution::Error { .. } => 0,
380        }
381    }
382}
383
384/// Which navigation verb a resolved chain is being applied under — the row
385/// selector in [`shell_route`]'s application table, and what lets `go`/`push`/
386/// `replace` share one chain-splitting path instead of three copies of it.
387#[derive(Clone, Copy, PartialEq, Eq, Debug)]
388enum Verb {
389    Go,
390    Push,
391    Replace,
392}
393
394/// One piece of a chain split at its [`shell_route`] boundaries: the pages that
395/// apply to `controller`. Segment 0's controller is the router's own; each
396/// later segment's is the inner controller of the shell that opened it.
397struct Segment<State: 'static> {
398    controller: NavigatorController<State>,
399    pages: Vec<ResolvedPage<State>>,
400}
401
402/// A declarative router over a [`navigator`](super::navigator). See the [module
403/// docs](self).
404pub struct Router<State: 'static> {
405    routes: Vec<Route<State>>,
406    controller: NavigatorController<State>,
407    redirect: Option<Redirect>,
408    error_builder: ErrorBuilder<State>,
409    redirect_limit: usize,
410    /// The `Send + Sync` request queue screens navigate through (see the
411    /// [module docs](self)' "Reaching the router from a screen"). Owned here,
412    /// handed out by clone; drained by [`pump`](Self::pump).
413    route_nav: RouteNavigator,
414    /// Shell pages this router has **queued** but whose target controller has
415    /// not published them yet, keyed by the shell's inner-controller id and
416    /// valued by that target's [`route_generation`](NavigatorController::route_generation)
417    /// at queue time.
418    ///
419    /// The keep rule reads committed state (the published route stack), which
420    /// is the right authority — but it leaves one gap the router alone can
421    /// close: two shell-crossing navigations applied in the *same* frame, before
422    /// any rebuild, would each see a stack without the shell page and each queue
423    /// one, leaving two shell pages driving a single inner controller. An entry
424    /// here is honoured only while the recorded generation still matches, so it
425    /// self-invalidates on the very publish that makes the stack authoritative —
426    /// it is a note about this router's own queued intent, never a second copy
427    /// of the stack.
428    pending_shells: RefCell<HashMap<NavigatorId, u64>>,
429}
430
431impl<State: 'static> Router<State> {
432    /// A router over `routes` with a fresh [`NavigatorController`], the default
433    /// error page, and the default redirect limit ([`DEFAULT_REDIRECT_LIMIT`]).
434    pub fn new(routes: Vec<Route<State>>) -> Self {
435        Self::with_controller(&NavigatorController::new(), routes)
436    }
437
438    /// A router driving `controller` (so an app can hand the same controller to
439    /// [`navigator`](super::navigator::navigator)).
440    pub fn with_controller(
441        controller: &NavigatorController<State>,
442        routes: Vec<Route<State>>,
443    ) -> Self {
444        Router {
445            routes,
446            controller: controller.clone(),
447            redirect: None,
448            error_builder: Rc::new(default_error_page),
449            redirect_limit: DEFAULT_REDIRECT_LIMIT,
450            route_nav: RouteNavigator::new(),
451            pending_shells: RefCell::new(HashMap::new()),
452        }
453    }
454
455    /// Set a top-level redirect, consulted before matching on every resolution.
456    pub fn redirect(mut self, redirect: impl Fn(&Location) -> Option<String> + 'static) -> Self {
457        self.redirect = Some(Rc::new(redirect));
458        self
459    }
460
461    /// Replace the error-page builder (default: a simple themed "not found" page).
462    /// `error_builder` may return any [`View`]; it is erased here.
463    pub fn error_builder<V: View<State>>(
464        mut self,
465        error_builder: impl Fn(&Location) -> V + 'static,
466    ) -> Self {
467        self.error_builder = Rc::new(move |location: &Location| any(error_builder(location)));
468        self
469    }
470
471    /// Override the redirect loop limit (default [`DEFAULT_REDIRECT_LIMIT`]).
472    pub fn redirect_limit(mut self, limit: usize) -> Self {
473        self.redirect_limit = limit;
474        self
475    }
476
477    /// The controller this router drives — hand it to
478    /// [`navigator`](super::navigator::navigator) so the app's page stack and the
479    /// router share one op queue.
480    pub fn controller(&self) -> &NavigatorController<State> {
481        &self.controller
482    }
483
484    /// Resolve a location string into a [`Resolution`] (pure; no controller side
485    /// effects). Applies top-level and per-route redirects under the loop guard;
486    /// an unmatched location or an over-limit redirect chain yields
487    /// [`Resolution::Error`].
488    pub fn resolve(&self, raw: &str) -> Resolution<State> {
489        let mut loc = Location::parse(raw);
490        let mut redirects = 0usize;
491
492        loop {
493            // 1. Top-level redirect (before matching).
494            if let Some(next) = self.fire_redirect(self.redirect.as_ref(), &loc) {
495                redirects += 1;
496                if redirects > self.redirect_limit {
497                    return Resolution::Error { location: loc };
498                }
499                loc = next;
500                continue;
501            }
502
503            // 2. Match against the route tree.
504            let Some((chain, params)) = self.match_chain(&loc) else {
505                return Resolution::Error { location: loc };
506            };
507
508            // 3. Per-route redirects along the matched chain (first one wins).
509            let mut redirected = None;
510            for route in &chain {
511                if let Some(next) = self.fire_redirect(route.redirect.as_ref(), &loc) {
512                    redirected = Some(next);
513                    break;
514                }
515            }
516            if let Some(next) = redirected {
517                redirects += 1;
518                if redirects > self.redirect_limit {
519                    return Resolution::Error { location: loc };
520                }
521                loc = next;
522                continue;
523            }
524
525            // 4. Matched, no redirect: build the page chain. A page builder
526            //    sees the location's query merged UNDER the path captures — a
527            //    `:id` capture beats a `?id=` of the same name — so
528            //    `/terminal?session=abc` can read its own parameter and a
529            //    named-route path built with extra params round-trips back
530            //    through `resolve` (see the module docs).
531            let mut merged = loc.query.clone();
532            merged.extend(params);
533            let pages = chain
534                .iter()
535                .map(|route| ResolvedPage {
536                    builder: route.builder.clone(),
537                    params: merged.clone(),
538                    location: loc.clone(),
539                    shell: route.shell.clone(),
540                })
541                .collect();
542            return Resolution::Matched {
543                location: loc,
544                params: merged,
545                pages,
546            };
547        }
548    }
549
550    /// Fire a redirect fn against `loc`, returning the parsed new location only if
551    /// it actually differs (a redirect to the same location is treated as "no
552    /// redirect" — it makes no progress and must not be counted toward the limit).
553    fn fire_redirect(&self, redirect: Option<&Redirect>, loc: &Location) -> Option<Location> {
554        let redirect = redirect?;
555        let next = redirect(loc)?;
556        let next_loc = Location::parse(&next);
557        if next_loc.location_string() == loc.location_string() {
558            None
559        } else {
560            Some(next_loc)
561        }
562    }
563
564    /// Match `loc`'s segments against the route tree, returning the matched chain
565    /// (root→leaf) and merged params, or `None` if nothing matches.
566    fn match_chain<'a>(&'a self, loc: &Location) -> Option<(Vec<&'a Route<State>>, RouteParams)> {
567        match_routes(&self.routes, &loc.segments, RouteParams::new(), Vec::new())
568    }
569
570    // --- Navigation API (drives the controller) ---
571
572    /// The [`RouteNavigator`] screens navigate through: a `Send + Sync` handle
573    /// safe to hold under `provide_context`, in a callback, or on a background
574    /// thread. Requests queued on it are applied by this router's next
575    /// [`pump`](Self::pump) — see the [module docs](self)' "Reaching the router
576    /// from a screen".
577    pub fn route_navigator(&self) -> RouteNavigator {
578        self.route_nav.clone()
579    }
580
581    /// Drain the [`route_navigator`](Self::route_navigator)'s queue and apply
582    /// each request, in order. Idempotent when the queue is empty, so it is meant
583    /// to be called unconditionally once per rebuild (the facade's
584    /// `RouterDeepLinks::track` does exactly that). Must run on the UI thread —
585    /// it drives the `Rc`-backed [`NavigatorController`]; queuing is what is
586    /// thread-free.
587    pub fn pump(&self) {
588        for request in self.route_nav.drain() {
589            match request {
590                NavRequest::Go(location) => self.go(&location),
591                NavRequest::Push(location) => self.push(&location),
592                NavRequest::Replace(location) => self.replace(&location),
593                NavRequest::Pop => self.pop(),
594                NavRequest::GoNamed { name, params } => self.go_named(&name, &params),
595                NavRequest::PushNamed { name, params } => self.push_named(&name, &params),
596            }
597        }
598    }
599
600    /// Reset the stack to the matched chain (replace semantics — the current top's
601    /// state is dropped). See the [module docs](self)'s deferred note on
602    /// arbitrary-depth reset.
603    ///
604    /// Each resolved page is stamped with its own [`ResolvedPage::location`]
605    /// (`R-B1`) — [`NavigatorController::route_stack`] tracks the
606    /// resulting page-to-route mapping, so no consumer needs to re-derive it.
607    ///
608    /// A chain crossing a [`shell_route`] boundary is split across the two
609    /// controllers instead, under the keep rule — see [`shell_route`]'s table.
610    pub fn go(&self, location: &str) {
611        self.apply(self.resolved(location), Verb::Go);
612    }
613
614    /// Push the matched leaf page onto the stack (the page below is retained).
615    ///
616    /// Stamps the leaf's [`ResolvedPage::location`] — see [`go`](Self::go)'s doc.
617    /// Across a [`shell_route`] boundary the leaf lands on the shell's inner
618    /// controller — see [`shell_route`]'s table.
619    pub fn push(&self, location: &str) {
620        self.apply(self.resolved(location), Verb::Push);
621    }
622
623    /// Replace the top page with the matched leaf (the top's state is dropped;
624    /// the stack depth is unchanged). [`go`](Self::go)'s single-page case, minus
625    /// the chain push — the op a [`NavRequest::Replace`] applies.
626    ///
627    /// Stamps the leaf's [`ResolvedPage::location`] — see [`go`](Self::go)'s doc.
628    /// Across a [`shell_route`] boundary the replaced top is the shell's inner
629    /// one — see [`shell_route`]'s table.
630    pub fn replace(&self, location: &str) {
631        self.apply(self.resolved(location), Verb::Replace);
632    }
633
634    /// Pop the top page of the router's **own** controller (a no-op on the root
635    /// page — see [`NavigatorController::pop`]).
636    ///
637    /// Deliberately not shell-aware: popping "one page, wherever the user
638    /// actually is" is back arbitration's job (the facade's back handler routes
639    /// a press innermost-first), not the router's. See the [module docs](self)'
640    /// deferred note and [`shell_route`]'s *Back* section.
641    pub fn pop(&self) {
642        self.controller.pop();
643    }
644
645    // --- Chain application (flat and shell-split) ---
646
647    /// Apply a [`Resolution`] under `verb`. A chain that crosses no shell
648    /// boundary is one segment and takes [`apply_flat`](Self::apply_flat) — the
649    /// flatten-onto-one-controller behaviour, unchanged. A chain that *does*
650    /// cross one takes [`apply_shell_chain`](Self::apply_shell_chain).
651    fn apply(&self, resolution: Resolution<State>, verb: Verb) {
652        match resolution {
653            Resolution::Matched { pages, .. } => {
654                let mut segments = self.split_chain(pages);
655                if segments.len() == 1 {
656                    let segment = segments.pop().expect("a chain has at least one segment");
657                    Self::apply_flat(&segment.controller, segment.pages, verb);
658                } else {
659                    self.apply_shell_chain(segments, verb);
660                }
661            }
662            Resolution::Error { location } => match verb {
663                Verb::Push => self.controller.push(self.error_page_builder(location)),
664                Verb::Go | Verb::Replace => {
665                    self.controller.replace(self.error_page_builder(location))
666                }
667            },
668        }
669    }
670
671    /// Cut the resolved chain after every page whose route carries a
672    /// [`shell_route`] binding: segment 0 applies to this router's own
673    /// controller, segment 1 to the first shell's inner controller, and so on.
674    /// A chain crossing no shell yields exactly one segment.
675    ///
676    /// A shell route matched as the chain *leaf* (its own path, with no child
677    /// consuming the rest) opens a trailing segment with no pages — kept, so
678    /// the shell page is still placed while its inner navigator is left showing
679    /// whatever it already had.
680    fn split_chain(&self, pages: Vec<ResolvedPage<State>>) -> Vec<Segment<State>> {
681        let mut segments = vec![Segment {
682            controller: self.controller.clone(),
683            pages: Vec::new(),
684        }];
685        for page in pages {
686            let inner = page.shell.clone();
687            segments
688                .last_mut()
689                .expect("segments is seeded with the outer segment")
690                .pages
691                .push(page);
692            if let Some(inner) = inner {
693                segments.push(Segment {
694                    controller: inner,
695                    pages: Vec::new(),
696                });
697            }
698        }
699        segments
700    }
701
702    /// Apply a chain that crosses at least one shell boundary: each segment to
703    /// its own controller, under the keep rule (see [`shell_route`]'s table).
704    fn apply_shell_chain(&self, segments: Vec<Segment<State>>, verb: Verb) {
705        // The inner controller each boundary opens, indexed by boundary — the
706        // identity the keep rule compares an already-placed shell page against.
707        let shells: Vec<NavigatorId> = segments
708            .iter()
709            .skip(1)
710            .map(|segment| segment.controller.id())
711            .collect();
712        let last = segments.len() - 1;
713        // While every enclosing shell page was kept, the caller's verb applies
714        // as written. The first placement flips this: everything below a
715        // freshly placed shell page is a new subtree with no history to stack
716        // onto, so it resets (see `shell_route`'s "a placement resets the verb
717        // below it").
718        let mut enclosing_kept = true;
719
720        for (index, segment) in segments.into_iter().enumerate() {
721            let effective = if enclosing_kept { verb } else { Verb::Go };
722            if index == last {
723                Self::apply_flat(&segment.controller, segment.pages, effective);
724                continue;
725            }
726
727            let shell = shells[index];
728            if enclosing_kept && self.shell_is_placed(&segment.controller, index, shell) {
729                continue;
730            }
731            enclosing_kept = false;
732            self.pending_shells
733                .borrow_mut()
734                .insert(shell, segment.controller.route_generation());
735            Self::place_segment(&segment.controller, segment.pages, effective);
736        }
737    }
738
739    /// The keep rule's question: is the page of the shell that opens boundary
740    /// `boundary` already on `target`'s stack?
741    ///
742    /// Answered against the **published** route stack — the committed fact, per
743    /// `route_state`'s intent-vs-fact split — by re-matching each entry's
744    /// location through the route table and asking whether that chain crosses
745    /// the same shell at the same boundary. Re-matching (rather than comparing
746    /// locations) is what keeps a pathless shell honest: the shell page is
747    /// stamped with the location that placed it, and every one of its children
748    /// resolves through the same shell.
749    ///
750    /// "On the stack", not "on top": a page the app pushed *over* the shell
751    /// leaves the shell page retained beneath it, and placing a second one
752    /// there would put two live navigators on one controller. The in-shell
753    /// navigation lands under the covering page instead, and is what the user
754    /// sees when they pop back to it.
755    ///
756    /// Matching is redirect-free ([`match_chain`](Self::match_chain), not
757    /// [`resolve`](Self::resolve)): this identifies the route that *produced* an
758    /// existing page, which a redirect installed later must not reinterpret.
759    fn shell_is_placed(
760        &self,
761        target: &NavigatorController<State>,
762        boundary: usize,
763        shell: NavigatorId,
764    ) -> bool {
765        let stack = target.route_stack();
766        let committed = stack
767            .entries()
768            .iter()
769            .flatten()
770            .any(|location| self.shell_at(location, boundary) == Some(shell));
771        if committed {
772            return true;
773        }
774        // Queued but not yet published — this router's own placement from
775        // earlier in the same frame (see `pending_shells`).
776        self.pending_shells.borrow().get(&shell) == Some(&stack.generation())
777    }
778
779    /// The inner-controller id of the `boundary`-th shell the chain matching
780    /// `location` crosses, or `None` if it crosses fewer.
781    fn shell_at(&self, location: &Location, boundary: usize) -> Option<NavigatorId> {
782        let (chain, _) = self.match_chain(location)?;
783        chain
784            .iter()
785            .filter_map(|route| route.shell.as_ref())
786            .nth(boundary)
787            .map(|controller| controller.id())
788    }
789
790    /// Apply one segment's pages to `controller` with the flat, single-controller
791    /// semantics `go`/`push`/`replace` have always had — the behaviour a chain
792    /// crossing no shell keeps byte-for-byte.
793    fn apply_flat(
794        controller: &NavigatorController<State>,
795        pages: Vec<ResolvedPage<State>>,
796        verb: Verb,
797    ) {
798        match verb {
799            Verb::Go => {
800                let mut pages = pages.into_iter();
801                if let Some(first) = pages.next() {
802                    let route = first.location().clone();
803                    controller.replace_with_options(
804                        first.into_page_builder(),
805                        ReplaceOptions::opaque().route(route),
806                    );
807                }
808                for page in pages {
809                    let route = page.location().clone();
810                    controller.push_with_options(
811                        page.into_page_builder(),
812                        PushOptions::opaque().route(route),
813                    );
814                }
815            }
816            Verb::Push => {
817                if let Some(leaf) = pages.into_iter().next_back() {
818                    let route = leaf.location().clone();
819                    controller.push_with_options(
820                        leaf.into_page_builder(),
821                        PushOptions::opaque().route(route),
822                    );
823                }
824            }
825            Verb::Replace => {
826                if let Some(leaf) = pages.into_iter().next_back() {
827                    let route = leaf.location().clone();
828                    controller.replace_with_options(
829                        leaf.into_page_builder(),
830                        ReplaceOptions::opaque().route(route),
831                    );
832                }
833            }
834        }
835    }
836
837    /// Place a shell-carrying segment (its pages end with the shell page) on
838    /// `controller`. A `push` stacks the whole segment so the page it came from
839    /// is retained beneath the shell; a `go`/`replace` applies the flat `go`
840    /// rule (replace the top, push the rest).
841    fn place_segment(
842        controller: &NavigatorController<State>,
843        pages: Vec<ResolvedPage<State>>,
844        verb: Verb,
845    ) {
846        match verb {
847            Verb::Push => {
848                for page in pages {
849                    let route = page.location().clone();
850                    controller.push_with_options(
851                        page.into_page_builder(),
852                        PushOptions::opaque().route(route),
853                    );
854                }
855            }
856            Verb::Go | Verb::Replace => Self::apply_flat(controller, pages, Verb::Go),
857        }
858    }
859
860    /// The single entry point a deep link resolves through — same reset semantics
861    /// as [`go`](Self::go). The facade tracks the deep-link signal and
862    /// calls this; this crate stays reactive-free.
863    pub fn handle_location(&self, location: &str) {
864        self.go(location);
865    }
866
867    /// Resolve `name` + `params` into a path, then [`go`](Self::go) to it. An
868    /// unknown name routes to the error page.
869    pub fn go_named(&self, name: &str, params: &RouteParams) {
870        match self.path_for_name(name, params) {
871            Some(path) => self.go(&path),
872            None => {
873                let location = named_error_location(name);
874                self.route_nav.set_location(location.clone());
875                self.controller.replace(self.error_page_builder(location));
876            }
877        }
878    }
879
880    /// Resolve `name` + `params` into a path, then [`push`](Self::push) it. An
881    /// unknown name routes to the error page.
882    pub fn push_named(&self, name: &str, params: &RouteParams) {
883        match self.path_for_name(name, params) {
884            Some(path) => self.push(&path),
885            None => {
886                let location = named_error_location(name);
887                self.route_nav.set_location(location.clone());
888                self.controller.push(self.error_page_builder(location));
889            }
890        }
891    }
892
893    /// Build the full path for a named route, substituting `:param`s from `params`
894    /// (composing nested parent + child patterns). Params not consumed by a path
895    /// segment become query parameters (go_router parity). `None` if no route has
896    /// the name.
897    pub fn path_for_name(&self, name: &str, params: &RouteParams) -> Option<String> {
898        let pattern = find_named_pattern(&self.routes, name, "")?;
899        Some(substitute_pattern(&pattern, params))
900    }
901
902    /// [`resolve`](Self::resolve) plus the one side effect the navigating
903    /// methods share: publishing the resolved (post-redirect) location on the
904    /// [`RouteNavigator`], so `location()` reports where the router actually
905    /// went — including the location an unmatched link errored on.
906    fn resolved(&self, raw: &str) -> Resolution<State> {
907        let resolution = self.resolve(raw);
908        let location = match &resolution {
909            Resolution::Matched { location, .. } | Resolution::Error { location } => location,
910        };
911        self.route_nav.set_location(location.clone());
912        resolution
913    }
914
915    /// Wrap the error builder into a `Fn() -> AnyView` closure for the controller.
916    fn error_page_builder(&self, location: Location) -> impl Fn() -> AnyView<State> + 'static {
917        let error_builder = self.error_builder.clone();
918        move || (error_builder)(&location)
919    }
920}
921
922/// Recursively match `routes` against the remaining location `segments`,
923/// accumulating params and the visited route chain. A route matches as a *prefix*;
924/// if it consumes all remaining segments it is the chain leaf, otherwise the match
925/// recurses into its children with the remainder.
926fn match_routes<'a, State: 'static>(
927    routes: &'a [Route<State>],
928    segments: &[String],
929    acc_params: RouteParams,
930    chain: Vec<&'a Route<State>>,
931) -> Option<(Vec<&'a Route<State>>, RouteParams)> {
932    for route in routes {
933        let pattern = PathPattern::parse(&route.path);
934        let Some((consumed, params)) = pattern.match_prefix(segments) else {
935            continue;
936        };
937
938        let mut next_params = acc_params.clone();
939        next_params.extend(params);
940
941        let mut next_chain = chain.clone();
942        next_chain.push(route);
943
944        let remaining = &segments[consumed..];
945        if remaining.is_empty() {
946            return Some((next_chain, next_params));
947        }
948        // More segments left: only a child match can consume them.
949        if let Some(found) = match_routes(&route.children, remaining, next_params, next_chain) {
950            return Some(found);
951        }
952        // This route matched a prefix but no child covered the rest — keep trying
953        // sibling routes.
954    }
955    None
956}
957
958/// Walk the route tree for a route named `name`, composing the full path pattern
959/// (parent `prefix` + each route's own path). `None` if not found.
960fn find_named_pattern<State: 'static>(
961    routes: &[Route<State>],
962    name: &str,
963    prefix: &str,
964) -> Option<String> {
965    for route in routes {
966        let full = join_paths(prefix, &route.path);
967        if route.name.as_deref() == Some(name) {
968            return Some(full);
969        }
970        if let Some(found) = find_named_pattern(&route.children, name, &full) {
971            return Some(found);
972        }
973    }
974    None
975}
976
977/// Join a parent path prefix with a child path, normalizing slashes.
978fn join_paths(prefix: &str, path: &str) -> String {
979    let a = prefix.trim_matches('/');
980    let b = path.trim_matches('/');
981    match (a.is_empty(), b.is_empty()) {
982        (true, true) => String::new(),
983        (true, false) => b.to_string(),
984        (false, true) => a.to_string(),
985        (false, false) => format!("{a}/{b}"),
986    }
987}
988
989/// Substitute a path pattern's `:param` segments from `params`, appending any
990/// unused params as a deterministic query string (go_router parity).
991fn substitute_pattern(pattern: &str, params: &RouteParams) -> String {
992    let mut used: HashSet<&str> = HashSet::new();
993    let mut path = String::new();
994
995    for seg in pattern.split('/').filter(|s| !s.is_empty()) {
996        path.push('/');
997        if let Some(name) = seg.strip_prefix(':') {
998            match params.get(name) {
999                Some(value) => {
1000                    path.push_str(&encode_segment(value));
1001                    used.insert(name);
1002                }
1003                // A missing param leaves the literal `:name` — a wiring bug the
1004                // caller sees in the resulting (unmatchable) path rather than a
1005                // panic.
1006                None => path.push_str(seg),
1007            }
1008        } else {
1009            path.push_str(seg);
1010        }
1011    }
1012    if path.is_empty() {
1013        path.push('/');
1014    }
1015
1016    let extras: Vec<String> = params
1017        .iter()
1018        .filter(|(k, _)| !used.contains(k.as_str()))
1019        .map(|(k, v)| format!("{}={}", encode_segment(k), encode_segment(v)))
1020        .collect();
1021    if extras.is_empty() {
1022        path
1023    } else {
1024        format!("{}?{}", path, extras.join("&"))
1025    }
1026}
1027
1028/// The synthetic location an unknown named route reports to the error page.
1029fn named_error_location(name: &str) -> Location {
1030    Location::parse(&format!("/{name}"))
1031}
1032
1033/// The default error page: a simple themed text page naming the missing location.
1034fn default_error_page<State: 'static>(location: &Location) -> AnyView<State> {
1035    any(crate::text(format!("Page not found: {}", location.path)))
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041    use crate::test_support::RecordingScene;
1042    use frust_core::{
1043        BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, FrameTime, InputEvent,
1044        LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase, RenderRoot,
1045        View, Widget,
1046    };
1047    use kurbo::{Point, Size};
1048    use std::cell::{Cell, RefCell};
1049    use std::rc::Rc;
1050    use std::sync::Arc;
1051    use std::sync::atomic::{AtomicUsize, Ordering};
1052
1053    use super::super::navigator::{NavigatorView, navigator};
1054    use super::super::route_state::NavChange;
1055
1056    fn params(pairs: &[(&str, &str)]) -> RouteParams {
1057        pairs
1058            .iter()
1059            .map(|(k, v)| (k.to_string(), v.to_string()))
1060            .collect()
1061    }
1062
1063    // --- A sized leaf so paint-culling / stack tests can tell pages apart. ---
1064
1065    struct SizedLeaf {
1066        size: Size,
1067    }
1068    struct SizedLeafWidget {
1069        size: Size,
1070    }
1071    impl<S: 'static> View<S> for SizedLeaf {
1072        type Element = SizedLeafWidget;
1073        fn build(&self, _ctx: &mut BuildCtx<'_>) -> SizedLeafWidget {
1074            SizedLeafWidget { size: self.size }
1075        }
1076        fn rebuild(
1077            &self,
1078            _prev: &Self,
1079            _element: &mut SizedLeafWidget,
1080            _ctx: &mut BuildCtx<'_>,
1081        ) -> ChangeFlags {
1082            ChangeFlags::NONE
1083        }
1084    }
1085    impl Widget for SizedLeafWidget {
1086        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1087            bc.constrain(self.size)
1088        }
1089        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1090            scene.fill_rect(ctx.origin(), ctx.size(), peniko::Color::BLACK);
1091        }
1092    }
1093    fn sized<S: 'static>(w: f64, h: f64) -> impl View<S> {
1094        SizedLeaf {
1095            size: Size::new(w, h),
1096        }
1097    }
1098
1099    // --- A leaf with retained state so go(replace) vs push(retain) is provable. ---
1100
1101    struct CounterView {
1102        observed: Rc<Cell<u32>>,
1103    }
1104    struct CounterWidget {
1105        count: u32,
1106        observed: Rc<Cell<u32>>,
1107    }
1108    impl View<()> for CounterView {
1109        type Element = CounterWidget;
1110        fn build(&self, _ctx: &mut BuildCtx<'_>) -> CounterWidget {
1111            CounterWidget {
1112                count: 0,
1113                observed: self.observed.clone(),
1114            }
1115        }
1116        fn rebuild(
1117            &self,
1118            _prev: &Self,
1119            element: &mut CounterWidget,
1120            _ctx: &mut BuildCtx<'_>,
1121        ) -> ChangeFlags {
1122            element.observed = self.observed.clone();
1123            ChangeFlags::NONE
1124        }
1125    }
1126    impl Widget for CounterWidget {
1127        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1128            bc.max()
1129        }
1130        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
1131            self.observed.set(self.count);
1132        }
1133        fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1134            if let InputEvent::Pointer(p) = event
1135                && p.phase == PointerPhase::Down
1136            {
1137                self.count += 1;
1138                ctx.request_redraw();
1139                return EventResult::Handled;
1140            }
1141            EventResult::Ignored
1142        }
1143    }
1144    // erasure: keep borrow escapes the fn (callers pass it through a 'static route closure)
1145    fn counter(observed: &Rc<Cell<u32>>) -> AnyView<()> {
1146        any(CounterView {
1147            observed: observed.clone(),
1148        })
1149    }
1150
1151    fn down(x: f64, y: f64) -> InputEvent {
1152        InputEvent::Pointer(PointerEvent {
1153            phase: PointerPhase::Down,
1154            position: Point::new(x, y),
1155            button: PointerButton::Primary,
1156        })
1157    }
1158
1159    // ================= Matcher =================
1160
1161    #[test]
1162    fn matches_static_route() {
1163        let router: Router<()> = Router::new(vec![Route::new("/settings", |_| sized(10.0, 10.0))]);
1164        let res = router.resolve("/settings");
1165        assert!(res.is_matched());
1166        assert_eq!(res.page_count(), 1);
1167    }
1168
1169    #[test]
1170    fn extracts_param() {
1171        let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1172        let res = router.resolve("/users/42");
1173        assert!(res.is_matched());
1174        assert_eq!(res.params().get("id").map(String::as_str), Some("42"));
1175    }
1176
1177    /// Run a page builder and hand back the params it actually received — the
1178    /// only thing that proves the query merge reaches a page (rather than just
1179    /// the `Resolution`'s own map).
1180    fn builder_params(resolution: &Resolution<()>) -> RouteParams {
1181        let Resolution::Matched { pages, .. } = resolution else {
1182            panic!("expected match");
1183        };
1184        let leaf = pages.last().expect("a matched chain has a leaf");
1185        let _view = leaf.build();
1186        leaf.params().clone()
1187    }
1188
1189    #[test]
1190    fn query_reaches_the_page_builder() {
1191        // R47: `/terminal?session=<id>` must be able to read its own parameter.
1192        let seen: Rc<RefCell<RouteParams>> = Rc::new(RefCell::new(RouteParams::new()));
1193        let recorder = seen.clone();
1194        let router: Router<()> = Router::new(vec![Route::new("/terminal", move |p| {
1195            *recorder.borrow_mut() = p.clone();
1196            sized(10.0, 10.0)
1197        })]);
1198
1199        let resolution = router.resolve("/terminal?session=abc");
1200        assert_eq!(
1201            builder_params(&resolution)
1202                .get("session")
1203                .map(String::as_str),
1204            Some("abc")
1205        );
1206        assert_eq!(
1207            seen.borrow().get("session").map(String::as_str),
1208            Some("abc"),
1209            "the builder itself receives the query parameter"
1210        );
1211    }
1212
1213    #[test]
1214    fn path_capture_beats_a_colliding_query_key() {
1215        // The merge order is query UNDER captures: `/users/42?id=99` is 42.
1216        let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1217        let resolution = router.resolve("/users/42?id=99");
1218        assert_eq!(
1219            builder_params(&resolution).get("id").map(String::as_str),
1220            Some("42")
1221        );
1222        assert_eq!(
1223            resolution.params().get("id").map(String::as_str),
1224            Some("42")
1225        );
1226    }
1227
1228    #[test]
1229    fn named_extra_params_round_trip_through_resolve() {
1230        // `path_for_name` emits unconsumed params as query; before R47 resolve
1231        // dropped them again, so `push_named(name, {id, tab})` lost `tab`.
1232        let router: Router<()> = Router::new(vec![
1233            Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
1234        ]);
1235        let path = router
1236            .path_for_name("user", &params(&[("id", "42"), ("tab", "posts")]))
1237            .expect("named route");
1238        let received = builder_params(&router.resolve(&path));
1239        assert_eq!(received.get("id").map(String::as_str), Some("42"));
1240        assert_eq!(received.get("tab").map(String::as_str), Some("posts"));
1241    }
1242
1243    #[test]
1244    fn resolved_page_exposes_its_location() {
1245        let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1246        let Resolution::Matched { pages, .. } = router.resolve("/users/42?tab=posts") else {
1247            panic!("expected match");
1248        };
1249        let leaf = pages.last().expect("leaf");
1250        assert_eq!(leaf.location().path, "/users/42");
1251        assert_eq!(
1252            leaf.location().query.get("tab").map(String::as_str),
1253            Some("posts"),
1254            "the raw location keeps path and query separate"
1255        );
1256    }
1257
1258    #[test]
1259    fn parses_query_into_params_via_location() {
1260        // Query lives on the Location; the matched resolution carries the final
1261        // location so a page can read it.
1262        let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1263        let Resolution::Matched { location, .. } = router.resolve("/users/42?tab=posts") else {
1264            panic!("expected match");
1265        };
1266        assert_eq!(location.query.get("tab").map(String::as_str), Some("posts"));
1267    }
1268
1269    #[test]
1270    fn nested_route_composes_and_chains() {
1271        let router: Router<()> = Router::new(vec![
1272            Route::new("/users", |_| sized(10.0, 10.0))
1273                .child(Route::new(":id", |_| sized(20.0, 20.0))),
1274        ]);
1275        let res = router.resolve("/users/42");
1276        assert!(res.is_matched());
1277        // Parent + child = two pages in the chain.
1278        assert_eq!(res.page_count(), 2);
1279        assert_eq!(res.params().get("id").map(String::as_str), Some("42"));
1280    }
1281
1282    #[test]
1283    fn trailing_slash_still_matches() {
1284        let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1285        assert!(router.resolve("/users/42/").is_matched());
1286    }
1287
1288    #[test]
1289    fn no_match_is_error() {
1290        let router: Router<()> = Router::new(vec![Route::new("/home", |_| sized(10.0, 10.0))]);
1291        let res = router.resolve("/nope");
1292        assert!(!res.is_matched());
1293        assert!(matches!(res, Resolution::Error { .. }));
1294    }
1295
1296    // ================= Redirects =================
1297
1298    #[test]
1299    fn per_route_redirect_follows_chain() {
1300        let router: Router<()> = Router::new(vec![
1301            Route::new("/old", |_| sized(10.0, 10.0)).redirect(|_| Some("/new".to_string())),
1302            Route::new("/new", |_| sized(20.0, 20.0)),
1303        ]);
1304        let Resolution::Matched { location, .. } = router.resolve("/old") else {
1305            panic!("expected match after redirect");
1306        };
1307        assert_eq!(location.path, "/new");
1308    }
1309
1310    #[test]
1311    fn top_level_redirect_applies() {
1312        let router: Router<()> = Router::new(vec![
1313            Route::new("/login", |_| sized(10.0, 10.0)),
1314            Route::new("/home", |_| sized(20.0, 20.0)),
1315        ])
1316        .redirect(|loc| (loc.path == "/home").then(|| "/login".to_string()));
1317        let Resolution::Matched { location, .. } = router.resolve("/home") else {
1318            panic!("expected redirect to /login");
1319        };
1320        assert_eq!(location.path, "/login");
1321    }
1322
1323    #[test]
1324    fn redirect_loop_falls_back_to_error_at_limit() {
1325        // Two routes redirecting to each other: the loop guard must give up at the
1326        // limit (5) and error rather than spin forever.
1327        let router: Router<()> = Router::new(vec![
1328            Route::new("/a", |_| sized(10.0, 10.0)).redirect(|_| Some("/b".to_string())),
1329            Route::new("/b", |_| sized(20.0, 20.0)).redirect(|_| Some("/a".to_string())),
1330        ]);
1331        let res = router.resolve("/a");
1332        assert!(
1333            matches!(res, Resolution::Error { .. }),
1334            "loop must error out"
1335        );
1336    }
1337
1338    #[test]
1339    fn redirect_to_same_location_is_not_a_loop() {
1340        // A redirect returning the current location makes no progress and must be
1341        // ignored, not counted as a loop.
1342        let router: Router<()> = Router::new(vec![
1343            Route::new("/x", |_| sized(10.0, 10.0)).redirect(|_| Some("/x".to_string())),
1344        ]);
1345        assert!(router.resolve("/x").is_matched());
1346    }
1347
1348    // ================= Named navigation =================
1349
1350    #[test]
1351    fn named_resolves_params_into_path() {
1352        let router: Router<()> = Router::new(vec![
1353            Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
1354        ]);
1355        assert_eq!(
1356            router.path_for_name("user", &params(&[("id", "42")])),
1357            Some("/users/42".to_string())
1358        );
1359    }
1360
1361    #[test]
1362    fn named_composes_nested_path() {
1363        let router: Router<()> = Router::new(vec![
1364            Route::new("/users", |_| sized(10.0, 10.0))
1365                .child(Route::new(":id", |_| sized(20.0, 20.0)).name("user")),
1366        ]);
1367        assert_eq!(
1368            router.path_for_name("user", &params(&[("id", "7")])),
1369            Some("/users/7".to_string())
1370        );
1371    }
1372
1373    #[test]
1374    fn named_extra_params_become_query() {
1375        let router: Router<()> = Router::new(vec![
1376            Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
1377        ]);
1378        assert_eq!(
1379            router.path_for_name("user", &params(&[("id", "42"), ("tab", "posts")])),
1380            Some("/users/42?tab=posts".to_string())
1381        );
1382    }
1383
1384    #[test]
1385    fn unknown_name_is_none() {
1386        let router: Router<()> = Router::new(vec![Route::new("/home", |_| sized(10.0, 10.0))]);
1387        assert!(
1388            router
1389                .path_for_name("missing", &RouteParams::new())
1390                .is_none()
1391        );
1392    }
1393
1394    // ================= Go vs push, real navigator =================
1395
1396    fn drive_paint(root: &mut RenderRoot<(), NavigatorView<()>>) -> Vec<(Point, Size)> {
1397        root.layout(Size::new(100.0, 100.0));
1398        let mut scene = RecordingScene::default();
1399        root.paint(&mut scene, FrameTime::ZERO);
1400        scene.rects
1401    }
1402
1403    #[test]
1404    fn go_replaces_top_dropping_state() {
1405        let controller: NavigatorController<()> = NavigatorController::new();
1406        let observed = Rc::new(Cell::new(0u32));
1407        let router = {
1408            let obs = observed.clone();
1409            Router::with_controller(
1410                &controller,
1411                vec![Route::new("/home", move |_| counter(&obs))],
1412            )
1413        };
1414
1415        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1416        let mut app = {
1417            let ctrl = controller.clone();
1418            let obs = observed.clone();
1419            move |_: &mut ()| {
1420                let obs = obs.clone();
1421                navigator(&ctrl, move || counter(&obs))
1422            }
1423        };
1424        let mut state = ();
1425        root.rebuild(&mut app, &mut state);
1426        root.layout(Size::new(100.0, 100.0));
1427        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1428
1429        // Increment the home counter.
1430        root.event(&mut state, &down(5.0, 5.0));
1431        root.rebuild(&mut app, &mut state);
1432        root.layout(Size::new(100.0, 100.0));
1433        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1434        assert_eq!(observed.get(), 1);
1435
1436        // go(/home) replaces the top with a fresh page: state dropped → counter 0.
1437        router.go("/home");
1438        root.rebuild(&mut app, &mut state);
1439        root.layout(Size::new(100.0, 100.0));
1440        root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1441        assert_eq!(observed.get(), 0, "go dropped the previous page's state");
1442    }
1443
1444    #[test]
1445    fn push_stacks_and_retains_below() {
1446        let controller: NavigatorController<()> = NavigatorController::new();
1447        let router: Router<()> = Router::with_controller(
1448            &controller,
1449            vec![
1450                Route::new("/home", |_| sized(10.0, 10.0)),
1451                Route::new("/detail", |_| sized(20.0, 20.0)),
1452            ],
1453        );
1454
1455        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1456        let mut app = {
1457            let ctrl = controller.clone();
1458            move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
1459        };
1460        let mut state = ();
1461        root.rebuild(&mut app, &mut state);
1462
1463        // Only the home page paints.
1464        assert_eq!(
1465            drive_paint(&mut root),
1466            vec![(Point::ZERO, Size::new(10.0, 10.0))]
1467        );
1468
1469        // push(/detail): detail (20x20, opaque) covers home — only detail paints.
1470        router.push("/detail");
1471        root.rebuild(&mut app, &mut state);
1472        assert_eq!(
1473            drive_paint(&mut root),
1474            vec![(Point::ZERO, Size::new(20.0, 20.0))]
1475        );
1476
1477        // pop: home is revealed again (its pod was retained beneath detail).
1478        router.pop();
1479        root.rebuild(&mut app, &mut state);
1480        assert_eq!(
1481            drive_paint(&mut root),
1482            vec![(Point::ZERO, Size::new(10.0, 10.0))]
1483        );
1484    }
1485
1486    #[test]
1487    fn go_to_unmatched_shows_error_page() {
1488        // A go() to an unmatched location drives the controller with the error
1489        // page rather than silently doing nothing.
1490        let controller: NavigatorController<()> = NavigatorController::new();
1491        let router: Router<()> = Router::with_controller(
1492            &controller,
1493            vec![Route::new("/home", |_| sized(10.0, 10.0))],
1494        )
1495        .error_builder(|_| sized(99.0, 99.0));
1496
1497        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1498        let mut app = {
1499            let ctrl = controller.clone();
1500            move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
1501        };
1502        let mut state = ();
1503        root.rebuild(&mut app, &mut state);
1504
1505        router.go("/does-not-exist");
1506        root.rebuild(&mut app, &mut state);
1507        assert_eq!(
1508            drive_paint(&mut root),
1509            vec![(Point::ZERO, Size::new(99.0, 99.0))],
1510            "the error page replaced the top"
1511        );
1512    }
1513
1514    // ================= Route-state stamping =================
1515
1516    #[test]
1517    fn go_push_replace_all_stamp_the_resolved_location_onto_the_route_stack() {
1518        let controller: NavigatorController<()> = NavigatorController::new();
1519        let router: Router<()> = Router::with_controller(
1520            &controller,
1521            vec![
1522                Route::new("/home", |_| sized(10.0, 10.0)),
1523                Route::new("/detail", |_| sized(20.0, 20.0)),
1524                Route::new("/other", |_| sized(30.0, 30.0)),
1525            ],
1526        );
1527
1528        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1529        let mut app = {
1530            let ctrl = controller.clone();
1531            move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
1532        };
1533        let mut state = ();
1534        // No `.root_route(...)`: the very first page is seeded by `router.go`
1535        // below, not the navigator's own initial builder.
1536        root.rebuild(&mut app, &mut state);
1537
1538        router.go("/home");
1539        root.rebuild(&mut app, &mut state);
1540        assert_eq!(
1541            controller.route_stack().entries(),
1542            &[Some(Location::parse("/home"))],
1543            "Router::go stamps the resolved location via ReplaceOptions::route"
1544        );
1545
1546        router.push("/detail");
1547        root.rebuild(&mut app, &mut state);
1548        assert_eq!(
1549            controller.route_stack().entries(),
1550            &[
1551                Some(Location::parse("/home")),
1552                Some(Location::parse("/detail"))
1553            ],
1554            "Router::push stamps the resolved location via PushOptions::route"
1555        );
1556        assert_eq!(controller.route_stack().change(), NavChange::Push);
1557
1558        router.replace("/other");
1559        root.rebuild(&mut app, &mut state);
1560        assert_eq!(
1561            controller.route_stack().entries(),
1562            &[
1563                Some(Location::parse("/home")),
1564                Some(Location::parse("/other"))
1565            ],
1566            "Router::replace stamps the resolved location via ReplaceOptions::route"
1567        );
1568        assert_eq!(controller.route_stack().change(), NavChange::Replace);
1569    }
1570
1571    // ================= RouteNavigator seam (pump) =================
1572
1573    /// The build closure [`RenderRoot::rebuild`] drives, boxed so
1574    /// [`PumpHarness`] can store it as a field.
1575    type Build = Box<dyn FnMut(&mut ()) -> NavigatorView<()>>;
1576
1577    /// A router + navigator harness for the pump tests: the same shape the
1578    /// other stack tests use, bundled so a test can queue → pump → rebuild →
1579    /// paint in one line each.
1580    struct PumpHarness {
1581        router: Router<()>,
1582        root: RenderRoot<(), NavigatorView<()>>,
1583        app: Build,
1584        state: (),
1585    }
1586
1587    impl PumpHarness {
1588        fn new(routes: Vec<Route<()>>) -> Self {
1589            let controller: NavigatorController<()> = NavigatorController::new();
1590            // A GPU/text-free error page: the default one builds a `Text`,
1591            // which panics without a threaded `TextContext`.
1592            let router =
1593                Router::with_controller(&controller, routes).error_builder(|_| sized(99.0, 99.0));
1594            let app: Build =
1595                Box::new(move |_: &mut ()| navigator(&controller, || sized(10.0, 10.0)));
1596            let mut harness = PumpHarness {
1597                router,
1598                root: RenderRoot::new(),
1599                app,
1600                state: (),
1601            };
1602            harness.rebuild();
1603            harness
1604        }
1605
1606        fn rebuild(&mut self) {
1607            self.root.rebuild(&mut self.app, &mut self.state);
1608        }
1609
1610        /// One frame: pump the queue (as the facade's `track()` does at the top
1611        /// of `build`), rebuild, paint.
1612        fn frame(&mut self) -> Vec<(Point, Size)> {
1613            self.router.pump();
1614            self.rebuild();
1615            drive_paint(&mut self.root)
1616        }
1617    }
1618
1619    fn pump_routes() -> Vec<Route<()>> {
1620        vec![
1621            Route::new("/home", |_| sized(10.0, 10.0)),
1622            Route::new("/detail", |_| sized(20.0, 20.0)),
1623            Route::new("/other", |_| sized(30.0, 30.0)),
1624        ]
1625    }
1626
1627    #[test]
1628    fn pump_applies_a_queued_request_on_the_next_frame() {
1629        let mut h = PumpHarness::new(pump_routes());
1630        let nav = h.router.route_navigator();
1631        assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1632
1633        // Recorded the way an event handler would: through the context-safe
1634        // handle, with no router in sight.
1635        nav.push("/detail");
1636        assert_eq!(
1637            h.frame(),
1638            vec![(Point::ZERO, Size::new(20.0, 20.0))],
1639            "a request queued between frames lands on the very next one"
1640        );
1641
1642        nav.pop();
1643        assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1644    }
1645
1646    #[test]
1647    fn pump_applies_every_request_variant_in_order() {
1648        let mut h = PumpHarness::new(pump_routes());
1649        let nav = h.router.route_navigator();
1650
1651        nav.push("/detail");
1652        nav.replace("/other");
1653        assert_eq!(
1654            h.frame(),
1655            vec![(Point::ZERO, Size::new(30.0, 30.0))],
1656            "push then replace leaves /other on top, not /detail"
1657        );
1658        // The replace swapped the top rather than stacking: one pop is back home.
1659        nav.pop();
1660        assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1661
1662        nav.go("/detail");
1663        assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
1664    }
1665
1666    #[test]
1667    fn pump_applies_named_requests_with_their_params() {
1668        let seen: Rc<RefCell<RouteParams>> = Rc::new(RefCell::new(RouteParams::new()));
1669        let recorder = seen.clone();
1670        let mut h = PumpHarness::new(vec![
1671            Route::new("/home", |_| sized(10.0, 10.0)),
1672            Route::new("/users/:id", move |p| {
1673                *recorder.borrow_mut() = p.clone();
1674                sized(20.0, 20.0)
1675            })
1676            .name("user"),
1677        ]);
1678        let nav = h.router.route_navigator();
1679
1680        nav.push_named("user", params(&[("id", "42"), ("tab", "posts")]));
1681        assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
1682        // Round-trip: the extra param rode the generated query string back
1683        // through `resolve` into the page's own params (R47).
1684        assert_eq!(seen.borrow().get("id").map(String::as_str), Some("42"));
1685        assert_eq!(seen.borrow().get("tab").map(String::as_str), Some("posts"));
1686    }
1687
1688    #[test]
1689    fn pump_is_a_no_op_when_the_queue_is_empty() {
1690        let mut h = PumpHarness::new(pump_routes());
1691        assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1692        // Called unconditionally every rebuild — repeated pumps must not
1693        // re-apply the last request.
1694        for _ in 0..3 {
1695            assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1696        }
1697    }
1698
1699    #[test]
1700    fn a_request_from_another_thread_pumps_normally() {
1701        // The payoff of the `Send + Sync` handle shape: the handle crosses a
1702        // thread boundary, and the resolution still happens on the UI thread.
1703        let mut h = PumpHarness::new(pump_routes());
1704        let nav = h.router.route_navigator();
1705        let woke = Arc::new(AtomicUsize::new(0));
1706        let counter = woke.clone();
1707        nav.set_waker(Arc::new(move || {
1708            counter.fetch_add(1, Ordering::SeqCst);
1709        }));
1710
1711        std::thread::spawn(move || nav.push("/detail"))
1712            .join()
1713            .expect("off-thread request must not panic");
1714
1715        assert_eq!(
1716            woke.load(Ordering::SeqCst),
1717            1,
1718            "the waker asked for a frame"
1719        );
1720        assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
1721    }
1722
1723    #[test]
1724    fn route_navigator_publishes_the_resolved_location() {
1725        let mut h = PumpHarness::new(pump_routes());
1726        let nav = h.router.route_navigator();
1727        assert!(nav.location().is_none(), "nothing resolved yet");
1728
1729        nav.push("/detail?tab=posts");
1730        let _ = h.frame();
1731        let loc = nav.location().expect("published after the pump");
1732        assert_eq!(loc.path, "/detail");
1733        assert_eq!(loc.query.get("tab").map(String::as_str), Some("posts"));
1734
1735        // An unmatched location still reports where the router ended up.
1736        nav.go("/nope");
1737        let _ = h.frame();
1738        assert_eq!(nav.location().expect("published").path, "/nope");
1739    }
1740
1741    #[test]
1742    fn route_navigator_clones_share_one_queue() {
1743        // Every `route_navigator()` call hands out the same underlying queue, so
1744        // a screen holding an old clone still reaches the same router.
1745        let mut h = PumpHarness::new(pump_routes());
1746        let first = h.router.route_navigator();
1747        let second = h.router.route_navigator();
1748        first.push("/detail");
1749        second.replace("/other");
1750        assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(30.0, 30.0))]);
1751    }
1752
1753    // ================= Shell routes =================
1754
1755    /// A leaf counting how many times it was **built** (never how often it was
1756    /// rebuilt) — the keep rule's tripwire. A retained widget is rebuilt in
1757    /// place; one whose page was replaced is built again from scratch, taking
1758    /// the shell's inner navigator (and every page in it) with it.
1759    struct BuildCounter {
1760        builds: Rc<Cell<u32>>,
1761    }
1762    struct BuildCounterWidget;
1763    impl<S: 'static> View<S> for BuildCounter {
1764        type Element = BuildCounterWidget;
1765        fn build(&self, _ctx: &mut BuildCtx<'_>) -> BuildCounterWidget {
1766            self.builds.set(self.builds.get() + 1);
1767            BuildCounterWidget
1768        }
1769        fn rebuild(
1770            &self,
1771            _prev: &Self,
1772            _element: &mut BuildCounterWidget,
1773            _ctx: &mut BuildCtx<'_>,
1774        ) -> ChangeFlags {
1775            ChangeFlags::NONE
1776        }
1777    }
1778    impl Widget for BuildCounterWidget {
1779        fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1780            bc.constrain(Size::ZERO)
1781        }
1782        fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1783    }
1784
1785    /// The shell shape this whole construct exists for: a `/` root declared
1786    /// **before** a pathless shell (so it keeps the empty segment list) whose
1787    /// children are the screens that live inside the shell's chrome.
1788    struct ShellHarness {
1789        router: Router<()>,
1790        outer: NavigatorController<()>,
1791        inner: NavigatorController<()>,
1792        root: RenderRoot<(), NavigatorView<()>>,
1793        app: Build,
1794        /// Builds of the shell page's chrome — see [`BuildCounter`].
1795        shell_builds: Rc<Cell<u32>>,
1796    }
1797
1798    impl ShellHarness {
1799        fn new() -> Self {
1800            let outer: NavigatorController<()> = NavigatorController::new();
1801            let inner: NavigatorController<()> = NavigatorController::new();
1802            let shell_builds = Rc::new(Cell::new(0u32));
1803
1804            let shell_page = {
1805                let inner = inner.clone();
1806                let builds = shell_builds.clone();
1807                move |_: &RouteParams| {
1808                    let inner = inner.clone();
1809                    let builds = builds.clone();
1810                    any(crate::column()
1811                        .child(BuildCounter { builds })
1812                        .child(navigator(&inner, || sized(11.0, 11.0))))
1813                }
1814            };
1815            let routes = vec![
1816                Route::new("/", |_| sized(10.0, 10.0)),
1817                shell_route(
1818                    &inner,
1819                    shell_page,
1820                    vec![
1821                        Route::new("/sessions", |_| sized(20.0, 20.0)),
1822                        Route::new("/terminal", |_| sized(30.0, 30.0)),
1823                    ],
1824                ),
1825            ];
1826            let router =
1827                Router::with_controller(&outer, routes).error_builder(|_| sized(99.0, 99.0));
1828            let app: Build = {
1829                let c = outer.clone();
1830                Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
1831            };
1832            let mut harness = ShellHarness {
1833                router,
1834                outer,
1835                inner,
1836                root: RenderRoot::new(),
1837                app,
1838                shell_builds,
1839            };
1840            harness.rebuild();
1841            harness
1842        }
1843
1844        fn rebuild(&mut self) {
1845            self.root.rebuild(&mut self.app, &mut ());
1846        }
1847
1848        /// The locations each controller's published stack currently holds.
1849        fn stacks(&self) -> (Vec<String>, Vec<String>) {
1850            (paths(&self.outer), paths(&self.inner))
1851        }
1852    }
1853
1854    /// The published route stack of `controller` as plain paths (a route-less
1855    /// page reads as `-`), which is what every shell assertion below compares.
1856    fn paths(controller: &NavigatorController<()>) -> Vec<String> {
1857        controller
1858            .route_stack()
1859            .entries()
1860            .iter()
1861            .map(|entry| match entry {
1862                Some(location) => location.path.clone(),
1863                None => "-".to_string(),
1864            })
1865            .collect()
1866    }
1867
1868    #[test]
1869    fn a_shell_chain_splits_the_outer_page_from_the_inner_leaf() {
1870        let mut h = ShellHarness::new();
1871        h.router.go("/terminal");
1872        h.rebuild();
1873
1874        let (outer, inner) = h.stacks();
1875        assert_eq!(
1876            outer,
1877            vec!["/terminal".to_string()],
1878            "the outer controller got the SHELL page (stamped with the location \
1879             that placed it), never the leaf"
1880        );
1881        assert_eq!(
1882            inner,
1883            vec!["/terminal".to_string()],
1884            "the leaf landed on the shell's inner controller"
1885        );
1886        assert_eq!(h.inner.depth(), 1, "the inner leaf replaced its root");
1887        assert!(h.inner.is_mounted(), "the shell page mounted its navigator");
1888        assert_eq!(h.shell_builds.get(), 1, "the shell page was built once");
1889    }
1890
1891    #[test]
1892    fn a_sibling_navigation_inside_the_shell_issues_zero_outer_ops() {
1893        // THE keep rule: re-navigating within the shell's subtree must not
1894        // touch the outer stack, because replacing the shell page would drop
1895        // the retained inner navigator and every page in it.
1896        let mut h = ShellHarness::new();
1897        h.router.go("/sessions");
1898        h.rebuild();
1899        let outer_generation = h.outer.route_generation();
1900        assert_eq!(h.shell_builds.get(), 1);
1901
1902        h.router.go("/terminal");
1903        h.rebuild();
1904
1905        assert_eq!(
1906            h.outer.route_generation(),
1907            outer_generation,
1908            "no outer op ran at all — an unchanged stack publishes nothing"
1909        );
1910        assert_eq!(
1911            h.shell_builds.get(),
1912            1,
1913            "the shell page's widget was retained, not rebuilt"
1914        );
1915        assert_eq!(
1916            h.stacks().1,
1917            vec!["/terminal".to_string()],
1918            "only the inner navigator moved"
1919        );
1920        assert_eq!(
1921            h.stacks().0,
1922            vec!["/sessions".to_string()],
1923            "the outer entry keeps naming the location that PLACED the shell — \
1924             live in-shell state is read from the inner navigator"
1925        );
1926    }
1927
1928    #[test]
1929    fn a_push_inside_a_placed_shell_stacks_on_the_inner_navigator() {
1930        let mut h = ShellHarness::new();
1931        h.router.go("/sessions");
1932        h.rebuild();
1933
1934        h.router.push("/terminal");
1935        h.rebuild();
1936        assert_eq!(h.outer.depth(), 1, "the outer stack never moved");
1937        assert_eq!(
1938            h.stacks().1,
1939            vec!["/sessions".to_string(), "/terminal".to_string()],
1940            "the leaf stacked inside the shell"
1941        );
1942        assert_eq!(h.shell_builds.get(), 1);
1943    }
1944
1945    #[test]
1946    fn a_push_that_must_place_the_shell_retains_the_page_below_and_resets_the_inner() {
1947        // muxr's connect → shell shape: the page the user came from stays on
1948        // the outer stack (so back leaves the shell), and the freshly created
1949        // inner navigator lands at depth 1 rather than stacking the leaf over
1950        // a placeholder root.
1951        let mut h = ShellHarness::new();
1952        h.router.go("/");
1953        h.rebuild();
1954        assert_eq!(h.stacks().0, vec!["/".to_string()]);
1955
1956        h.router.push("/sessions");
1957        h.rebuild();
1958        assert_eq!(
1959            h.stacks().0,
1960            vec!["/".to_string(), "/sessions".to_string()],
1961            "the shell page stacked over the root page, which is retained"
1962        );
1963        assert_eq!(
1964            h.inner.depth(),
1965            1,
1966            "the fresh inner navigator is at its root"
1967        );
1968        assert_eq!(h.stacks().1, vec!["/sessions".to_string()]);
1969    }
1970
1971    #[test]
1972    fn a_page_pushed_over_the_shell_still_counts_as_placed() {
1973        // The shell page is retained BELOW an outer push, so a navigation into
1974        // its subtree must not place a second one (two live navigators on one
1975        // controller); it lands under the covering page instead.
1976        let mut h = ShellHarness::new();
1977        h.router.go("/sessions");
1978        h.rebuild();
1979        h.outer.push(|| sized(77.0, 77.0));
1980        h.rebuild();
1981        assert_eq!(h.outer.depth(), 2);
1982
1983        h.router.go("/terminal");
1984        h.rebuild();
1985        assert_eq!(h.outer.depth(), 2, "no second shell page was placed");
1986        assert_eq!(h.shell_builds.get(), 1);
1987        assert_eq!(h.stacks().1, vec!["/terminal".to_string()]);
1988    }
1989
1990    #[test]
1991    fn two_shell_navigations_in_one_frame_place_the_shell_page_once() {
1992        // The intent-vs-fact gap: the second navigation resolves before any
1993        // rebuild published the first one's placement, so the committed stack
1994        // alone cannot answer the keep rule.
1995        let mut h = ShellHarness::new();
1996        h.router.go("/");
1997        h.rebuild();
1998
1999        h.router.push("/sessions");
2000        h.router.push("/terminal");
2001        h.rebuild();
2002
2003        assert_eq!(
2004            h.stacks().0,
2005            vec!["/".to_string(), "/sessions".to_string()],
2006            "exactly one shell page, placed by the first navigation"
2007        );
2008        assert_eq!(
2009            h.stacks().1,
2010            vec!["/sessions".to_string(), "/terminal".to_string()],
2011            "both in-shell ops landed on the one inner navigator"
2012        );
2013        assert_eq!(h.shell_builds.get(), 1);
2014    }
2015
2016    #[test]
2017    fn the_root_route_beats_a_pathless_shell_for_the_empty_location() {
2018        // A zero-consuming shell also matches the empty segment list, so `/`
2019        // must be declared before it — `match_routes` takes the first match.
2020        let mut h = ShellHarness::new();
2021        h.router.go("/");
2022        h.rebuild();
2023        assert_eq!(h.stacks().0, vec!["/".to_string()]);
2024        assert!(
2025            !h.inner.is_mounted(),
2026            "the shell was never placed, so its navigator never mounted"
2027        );
2028    }
2029
2030    #[test]
2031    fn a_redirect_out_of_the_shell_splits_the_post_redirect_chain() {
2032        // A guard bouncing a child out of its shell resolves BEFORE the split,
2033        // so the shell page is never placed at all.
2034        let outer: NavigatorController<()> = NavigatorController::new();
2035        let inner: NavigatorController<()> = NavigatorController::new();
2036        let router = {
2037            let inner_for_shell = inner.clone();
2038            Router::with_controller(
2039                &outer,
2040                vec![
2041                    Route::new("/", |_| sized(10.0, 10.0)),
2042                    shell_route(
2043                        &inner,
2044                        move |_| {
2045                            let inner = inner_for_shell.clone();
2046                            any(navigator(&inner, || sized(11.0, 11.0)))
2047                        },
2048                        vec![
2049                            Route::new("/sessions", |_| sized(20.0, 20.0))
2050                                .redirect(|_| Some("/".to_string())),
2051                        ],
2052                    ),
2053                ],
2054            )
2055        };
2056        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
2057        let mut app: Build = {
2058            let c = outer.clone();
2059            Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
2060        };
2061        root.rebuild(&mut app, &mut ());
2062
2063        router.go("/sessions");
2064        root.rebuild(&mut app, &mut ());
2065        assert_eq!(paths(&outer), vec!["/".to_string()], "the redirect won");
2066        assert!(!inner.is_mounted(), "no shell page, no inner navigator");
2067    }
2068
2069    #[test]
2070    fn a_non_shell_chain_still_flattens_onto_one_controller() {
2071        // The no-regression half: a nested chain with no shell binding lands
2072        // entirely on the router's own controller, exactly as before.
2073        let controller: NavigatorController<()> = NavigatorController::new();
2074        let router: Router<()> = Router::with_controller(
2075            &controller,
2076            vec![
2077                Route::new("/home", |_| sized(10.0, 10.0)),
2078                Route::new("/users", |_| sized(20.0, 20.0))
2079                    .child(Route::new(":id", |_| sized(30.0, 30.0))),
2080            ],
2081        );
2082        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
2083        let mut app: Build = {
2084            let c = controller.clone();
2085            Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
2086        };
2087        root.rebuild(&mut app, &mut ());
2088
2089        router.go("/users/42");
2090        root.rebuild(&mut app, &mut ());
2091        assert_eq!(
2092            paths(&controller),
2093            vec!["/users/42".to_string(), "/users/42".to_string()],
2094            "go replaced the top with the chain root and pushed the rest — both \
2095             pages on the one controller"
2096        );
2097
2098        router.push("/users/7");
2099        root.rebuild(&mut app, &mut ());
2100        assert_eq!(
2101            controller.depth(),
2102            3,
2103            "push stacked the leaf alone, unchanged"
2104        );
2105
2106        router.replace("/home");
2107        root.rebuild(&mut app, &mut ());
2108        assert_eq!(
2109            paths(&controller).last().map(String::as_str),
2110            Some("/home"),
2111            "replace swapped the top alone, unchanged"
2112        );
2113        assert_eq!(controller.depth(), 3);
2114    }
2115}