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