Skip to main content

frust/
back_glue.rs

1//! Back-press ⇄ navigator glue (predictive-back + facade auto-wiring): the
2//! facade is the only crate seeing both `frust-widgets`'
3//! [`NavigatorController`] and `frust-reactive`'s back-press source, mirroring
4//! [`router_glue`](crate::router_glue) — `frust-widgets` stays reactive-free,
5//! `frust-reactive` navigator-free.
6//!
7//! # The Android back contract
8//!
9//! Shell delivers a hardware/gesture back press → framework attempts an async
10//! back → framework maintains a "handles back" boolean the shell reads, so a
11//! *root-level* press falls through to the platform (activity finish). This
12//! module is the framework half: a press is consumed exactly once and routed
13//! through [`NavigatorController::request_back`] — which applies the top page's
14//! `BackPolicy` (`Pop` pops, `DismissAnimated` fires the overlay's dismiss
15//! signal, `Veto` swallows it) — never a bare [`pop`](NavigatorController::pop).
16//! "Handles back" reads [`back_interest`](NavigatorController::back_interest),
17//! not [`can_pop`](NavigatorController::can_pop), so a dismissable/veto overlay
18//! at the *root* claims the press ahead-of-time (predictive-back parity) instead
19//! of exiting the app.
20//!
21//! # Two entry points, one consumption source
22//!
23//! [`auto_wire`], called from the facade's [`navigator`](crate::navigator)
24//! wrapper every rebuild (back handling with ZERO back-specific app code), and
25//! the explicit [`BackHandler`]/[`attach_back_handler`], constructed in
26//! `Component::init` and driven by [`track`](BackHandler::track) from every
27//! `Component::build` (Huddle's shape). Both funnel through one process-wide
28//! (UI-thread-affine) [`SHARED`] marker, so a press is consumed **exactly
29//! once**: whichever entry point runs first in a rebuild consumes it, the other
30//! sees the same already-consumed count and no-ops — never a double-pop.
31//!
32//! # R44-back: arbitration across more than one navigator
33//!
34//! An app with a root [`overlay_host`](crate::overlay_host) wires **two**
35//! navigators — the host and the navigator inside it — and both want the press.
36//! `frust-reactive`'s live-provider slot stays single-registrant (see
37//! `frust_reactive::back`); this module owns that registration and multiplexes:
38//!
39//! > **R44-back — a root overlay host outranks every plain navigator; among
40//! > [`Role::Navigator`] peers the innermost (last-wired) ranks first, LIFO —
41//! > mirroring Android's `OnBackPressedDispatcher`; a back press goes to the
42//! > first registrant reporting
43//! > [`back_interest()`](NavigatorController::back_interest)` == true`.**
44//!
45//! [`SharedBack::registrants`] is that ordered list, sorted by ([`Role`], wire
46//! sequence): build order among hosts, *reversed* among navigators so the
47//! innermost wins. **Rank comes from the call site, not wire timing** —
48//! [`auto_wire_overlay_host`] registers [`Role::Host`], every other entry point
49//! [`Role::Navigator`] — because both *when* and *how often* a controller wires
50//! genuinely vary: a [`BackHandler`] built in `Component::init` wires before any
51//! view exists, and Huddle's documented pattern wires one controller **twice in
52//! one build pass** (`state.back.track()` beside
53//! `frust::navigator(&controller, …)`), so neither first-wire order nor "one
54//! wire per pass" is a premise anything here may rest on. `handles_back`/the
55//! live provider answer `any(interest)`, and a consumed press goes to the
56//! *first* registrant with interest — so one registrant (or a host with no
57//! overlays, `compute_back_interest` `false` at depth 1) behaves exactly as if
58//! arbitration did not exist.
59//!
60//! ## Releasing a navigator that went away
61//!
62//! A navigator that goes away (a screen with its own nested navigator, popped)
63//! must stop claiming presses and release its controller clone. Two rules, in
64//! priority order:
65//!
66//! 1. **Mounted is live.** [`NavigatorController::is_mounted`] is exact (the
67//!    navigator's `View::build`/`teardown` write it), so a *mounted* registrant
68//!    is in the retained tree by definition and is never pruned, however the
69//!    wire sequence looks — the load-bearing half: a root overlay host is
70//!    mounted from its own `build`, which runs before its page builder and hence
71//!    before the inner navigator wires at all.
72//! 2. **The one-cycle rule, for registrants never mounted.** A wire from an
73//!    already-registered controller ends a window; an unmounted registrant that
74//!    did not wire within it is dropped — the one case rule 1 cannot see (a
75//!    `BackHandler` whose navigator view never made it into the tree).
76//!
77//! **Rule 2 must not be trusted for a mounted navigator**: it infers a build
78//! cycle from a repeat wire, and the double-wire-per-pass pattern above breaks
79//! that inference (the second wire of one pass looks exactly like the first of
80//! the next). Before rule 1, that inference pruned the root overlay host
81//! mid-pass and re-created the root-modal double-claim bug.
82//!
83//! ## Reachability is upstream of arbitration, by design (R23)
84//!
85//! Ranking decides who wins *among the claimants*, never who may claim. A
86//! navigator input cannot reach — one nested inside a page the outer navigator
87//! has covered — reports `back_interest() == false` at the source:
88//! `frust-widgets` gates it on the hosting page being in the host navigator's
89//! `input_routed_pages()` (rule **R23**: navigator reach follows input routing,
90//! exactly; see `frust_widgets::NavigatorController::back_interest`). So nothing
91//! here filters on visibility and there is **no second notion of reachability**
92//! to keep in sync with the widget's — load-bearing for innermost-first ranking,
93//! since an off-screen innermost navigator would otherwise outrank the one the
94//! user is actually looking at and swallow every press. Reach says *whether* a
95//! navigator is in the running, rank *which* of those gets it.
96//!
97//! # Timing: the live provider closes the stale window
98//!
99//! Wiring runs during a rebuild's *build* pass, **before** the navigator's
100//! `apply_ops` publishes the new stack depth, so the polled [`set_handles_back`]
101//! flag lags a stack change by one frame — and a frame gate skipping the settle
102//! frame lets that stale value persist. So the wiring also registers a live
103//! provider ([`set_can_pop_provider`]) reading `controller.back_interest()`,
104//! which `frust_reactive::handles_back` prefers to the flag: it reads CURRENT
105//! interest at press time, after `apply_ops` published it. UI-thread-affine (it
106//! reads an `Rc`-backed controller through [`SHARED`]); `frust_reactive::back`'s
107//! module docs carry the source-side contract.
108
109use std::cell::RefCell;
110use std::rc::Rc;
111
112use frust_reactive::{CanPopRegistration, back_presses, set_can_pop_provider, set_handles_back};
113use frust_widgets::{NavigatorController, NavigatorId};
114use reactive_graph::traits::{Get, GetUntracked};
115
116thread_local! {
117    /// The process-wide (UI-thread-affine) back-press wiring shared by BOTH the
118    /// automatic [`navigator`](crate::navigator) path and the explicit
119    /// [`BackHandler`]. A single shared marker is the "single consumption
120    /// source" that makes an auto-wired `navigator()` and a manual
121    /// `BackHandler` on the same controller consume a press EXACTLY once — no
122    /// double-pop — and a single ordered registrant list is what arbitrates a
123    /// press across a root overlay host and the navigator inside it (R44-back;
124    /// see the module docs).
125    ///
126    /// UI-thread-affine because every [`Registrant`] holds an `Rc`-backed
127    /// `NavigatorController` (`!Send`), exactly like frust-reactive's live
128    /// provider slot it feeds.
129    static SHARED: RefCell<SharedBack> = const { RefCell::new(SharedBack::new()) };
130}
131
132/// What a registrant *is*, which is what R44-back ranks it by — taken from the
133/// facade entry point that wired it, never inferred from wire timing.
134///
135/// `Ord` is the arbitration order: [`Host`](Self::Host) sorts first.
136#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
137enum Role {
138    /// A root [`overlay_host`](crate::overlay_host). Its overlays are drawn over
139    /// every piece of chrome, including any inner navigator, so an open one owns
140    /// the back press outright — R44-back's host-outranks-navigator half.
141    Host,
142    /// Any other wired navigator: [`navigator`](crate::navigator)'s auto-wiring
143    /// or an explicit [`BackHandler`].
144    Navigator,
145}
146
147/// One wired navigator in [`SharedBack::registrants`] — the unit R44-back
148/// arbitrates over. The closures hold a controller clone, so a registrant keeps
149/// its navigator's op queue alive until it is pruned.
150struct Registrant {
151    /// Which controller this entry stands for, so a re-wire refreshes it in
152    /// place instead of appending a duplicate.
153    id: NavigatorId,
154    /// This entry's arbitration rank (see [`Role`]). Sticky-upgrades to
155    /// [`Role::Host`]: a controller wired *once* as an overlay host is one, even
156    /// on a re-wire that names it through a different entry point.
157    role: Role,
158    /// The wire sequence number of this entry's FIRST wire — the tiebreak within
159    /// a role, so two navigators of equal rank keep build order.
160    birth: u64,
161    /// The wire sequence number of this entry's most recent refresh; the
162    /// one-cycle prune compares against it (see [`refresh_interest`]).
163    seq: u64,
164    /// `controller.is_mounted()` — is this navigator in the retained tree right
165    /// now? A `true` here vetoes the one-cycle prune (see the module docs'
166    /// release rules).
167    mounted: Box<dyn Fn() -> bool>,
168    /// `controller.back_interest()` — does this navigator claim the next press?
169    /// Already `false` for a navigator whose hosting page input cannot reach
170    /// (the R23 gate in `frust-widgets`; see the module docs), which is why
171    /// nothing here filters on visibility.
172    interest: Box<dyn Fn() -> bool>,
173    /// `controller.request_back()` — deliver the press to this navigator,
174    /// honoring its top page's `BackPolicy`. An `Rc` rather than a `Box` so
175    /// [`route_back`] can clone it out and call it with **no** [`SHARED`] borrow
176    /// outstanding.
177    request_back: Rc<dyn Fn()>,
178}
179
180impl Registrant {
181    /// The sort key R44-back orders the arbitration list by: rank first
182    /// ([`Role`], `Host` sorts ahead of every `Navigator`), then within
183    /// `Role::Navigator` **innermost-first** (highest `birth` — a nested
184    /// navigator always wires after the navigator hosting it, so the highest
185    /// `birth` among `Navigator` peers is the innermost one); `Role::Host`
186    /// peers keep first-wire order (`birth` ascending), unchanged.
187    ///
188    /// Innermost-first is what lets a nested navigator take the press at all:
189    /// ordering every rank by first wire (`birth` ascending) would leave an
190    /// outer navigator at depth > 1 — exactly the case where a pushed page hosts
191    /// a nested navigator — permanently outranking the nested navigator inside
192    /// it, so back would pop the outer stack (discarding the whole page the
193    /// nested navigator lives on) instead of reaching in. `Role::Host` peers
194    /// keep first-wire order: R44-back only ever requires Host to rank above
195    /// every Navigator, never a particular order among multiple hosts.
196    fn key(&self) -> (Role, u64) {
197        match self.role {
198            Role::Host => (self.role, self.birth),
199            // `u64::MAX - birth`: ascending sort on this puts the HIGHEST
200            // birth (innermost, wired last) first among `Navigator` peers —
201            // LIFO, mirroring Android's `OnBackPressedDispatcher`.
202            Role::Navigator => (self.role, u64::MAX - self.birth),
203        }
204    }
205}
206
207/// The shared back state behind [`SHARED`] (see its docs).
208struct SharedBack {
209    /// The last back-press count consumed by *any* entry point; `None` until
210    /// the first observation. A press up to this count has already been routed
211    /// through `request_back`, so a second entry point (or a re-run rebuild)
212    /// observing the same count does not re-fire (the `RouterDeepLinks`
213    /// consumed-marker pattern, shared across both back entry points).
214    consumed: Option<u64>,
215    /// Every wired navigator, sorted by [`Registrant::key`] — [`Role::Host`]
216    /// first (build order among hosts, unchanged), then [`Role::Navigator`]
217    /// peers innermost-first (LIFO: last-wired sorts first, the reverse of
218    /// build order) — the R44-back arbitration list. Kept sorted on every
219    /// wire, so a registrant's position never depends on how often its
220    /// controller wires per pass; an unmounted registrant that misses a
221    /// build cycle is pruned (see [`refresh_interest`]). The live provider
222    /// (below) reads through this list, so a press is decided against the
223    /// CURRENT navigators' interest.
224    registrants: Vec<Registrant>,
225    /// Monotonic wire counter stamped into [`Registrant::birth`]/
226    /// [`Registrant::seq`]; ordering and the one-cycle prune are both expressed
227    /// against it.
228    seq: u64,
229    /// The live can-pop provider registration, made once on the first wire and
230    /// kept for the process lifetime. Its closure reads
231    /// [`registrants`](Self::registrants) live, so `handles_back()` answers
232    /// against the current navigators with no one-frame lag (see the module
233    /// docs' timing note). `None` until the first wire registers it.
234    provider: Option<CanPopRegistration>,
235}
236
237impl SharedBack {
238    const fn new() -> Self {
239        Self {
240            consumed: None,
241            registrants: Vec::new(),
242            seq: 0,
243            provider: None,
244        }
245    }
246}
247
248/// Whether **any** wired navigator claims the next back press — the value both
249/// the live provider and the polled `handles_back` flag publish.
250///
251/// Only ever called with no outstanding [`SHARED`] borrow: an `interest` probe
252/// reads an `Rc<Cell<bool>>` on its controller and never re-enters this module.
253fn any_interest() -> bool {
254    SHARED.with(|shared| shared.borrow().registrants.iter().any(|r| (r.interest)()))
255}
256
257/// Register (or refresh) `controller` in the R44-back arbitration list at
258/// `role`'s rank and republish the polled `handles_back` fallback flag,
259/// registering the live provider once. Called on every wire from both entry
260/// points ([`auto_wire`]/[`auto_wire_overlay_host`] and
261/// [`BackHandler::new`]/[`track`](BackHandler::track)).
262///
263/// Ordering and pruning are the whole mechanism — see the module docs' R44-back
264/// section. In short: the entry is (re-)inserted at its ([`Role`], first-wire)
265/// sort position, so neither *when* nor *how often* a controller wires can move
266/// it; and a repeat wire drops every **unmounted** registrant that did not wire
267/// since this controller's previous wire, a mounted one being in the tree by
268/// definition.
269///
270/// # Panics
271///
272/// The first call registers the live provider via [`set_can_pop_provider`],
273/// which panics off the UI thread — always the case here (a rebuild / a
274/// `Component::init` runs on the UI thread).
275fn refresh_interest<State: 'static>(controller: &NavigatorController<State>, role: Role) {
276    let id = controller.id();
277    let mounted_probe = controller.clone();
278    let interest_probe = controller.clone();
279    let request_probe = controller.clone();
280    SHARED.with(|shared| {
281        let mut shared = shared.borrow_mut();
282        shared.seq += 1;
283        let seq = shared.seq;
284        // Carry the existing entry's identity forward (rank sticky-upgrades to
285        // `Host`, first-wire order is preserved), and prune against its previous
286        // wire while it is still in the list.
287        let (role, birth) = match shared.registrants.iter().position(|r| r.id == id) {
288            Some(index) => {
289                let previous = shared.registrants[index].seq;
290                let carried = (
291                    // `Host` sorts first, so `min` IS the sticky upgrade: a
292                    // controller ever wired as an overlay host stays one.
293                    shared.registrants[index].role.min(role),
294                    shared.registrants[index].birth,
295                );
296                // A repeat wire closes a window. Any registrant that neither
297                // wired within it nor is currently mounted is no longer in the
298                // tree — drop it rather than let it keep claiming presses. The
299                // mounted probe is a `Cell` read on the registrant's controller
300                // and never re-enters this module, so calling it under the
301                // `SHARED` borrow is safe (same contract as `any_interest`).
302                shared
303                    .registrants
304                    .retain(|r| r.id == id || r.seq >= previous || (r.mounted)());
305                carried
306            }
307            // First wire: this seq is the entry's permanent tiebreak.
308            None => (role, seq),
309        };
310        let entry = Registrant {
311            id,
312            role,
313            birth,
314            seq,
315            mounted: Box::new(move || mounted_probe.is_mounted()),
316            interest: Box::new(move || interest_probe.back_interest()),
317            request_back: Rc::new(move || request_probe.request_back()),
318        };
319        // Re-insert at the sort position, never "wherever it already was": that
320        // is what keeps arbitration order a function of what a registrant IS
321        // (its role and first wire) rather than of the wire sequence.
322        shared.registrants.retain(|r| r.id != id);
323        let at = shared
324            .registrants
325            .iter()
326            .position(|r| r.key() > entry.key())
327            .unwrap_or(shared.registrants.len());
328        shared.registrants.insert(at, entry);
329        if shared.provider.is_none() {
330            // Register the stable live provider ONCE. It reads whatever
331            // registrants are currently wired from `SHARED`, so a new
332            // controller joining the list needs no re-registration.
333            shared.provider = Some(set_can_pop_provider(Box::new(any_interest)));
334        }
335    });
336    // Refresh the polled fallback flag (the live provider is authoritative; this
337    // only keeps the no-provider fallback roughly in sync — see the module docs).
338    set_handles_back(any_interest());
339}
340
341/// Deliver a consumed back press under **R44-back**: to the first registrant in
342/// arbitration order (hosts first, then plain navigators innermost-first) that
343/// claims it via `back_interest()`.
344///
345/// No visibility/reachability filter here on purpose: `back_interest()` is
346/// already gated on the claimant's hosting page being input-routed (see the
347/// module docs' R23 note), so a navigator the user cannot reach is never in this
348/// list's answer to begin with.
349///
350/// `fallback` is the controller whose wire consumed the press, used only when
351/// *no* registrant claims one — which preserves the pre-arbitration behaviour
352/// exactly (a press was always delivered to the wiring controller). Delivery
353/// there is a no-op by construction: `back_interest()` is false only at depth 1
354/// with a `BackPolicy::Pop` top page, and `request_back` on that stack pops
355/// nothing.
356fn route_back<State: 'static>(fallback: &NavigatorController<State>) {
357    // The claimant's `request_back` is CLONED OUT of the list (that is what the
358    // `Rc<dyn Fn()>` is for) and called with NO `SHARED` borrow outstanding.
359    // `request_back` only enqueues an op or bumps a dismiss signal today, but it
360    // is the one call here that reaches app-reachable machinery, and a re-entry
361    // into this module would panic on the already-borrowed `RefCell` — so the
362    // borrow ends before the call rather than that staying load-bearing. (The
363    // `interest` probe above is a plain `Cell` read, as `any_interest` notes.)
364    let claimant = SHARED.with(|shared| {
365        let shared = shared.borrow();
366        shared
367            .registrants
368            .iter()
369            .find(|r| (r.interest)())
370            .map(|r| Rc::clone(&r.request_back))
371    });
372    match claimant {
373        Some(request_back) => request_back(),
374        None => fallback.request_back(),
375    }
376}
377
378/// Consume any new back press against the shared marker, routing it through
379/// [`NavigatorController::request_back`] (applies the top page's `BackPolicy`)
380/// on the navigator [`route_back`] arbitrates to. The tracked read of the
381/// back-press counter subscribes THIS rebuild, so a later `push_back_press`
382/// wakes it (State & Reactivity: the track-per-rebuild contract).
383///
384/// The shared marker is the single consumption source (see the module docs): if
385/// both entry points run in the same rebuild on the same controller, the first
386/// fires `request_back` and the second no-ops on the already-consumed count.
387///
388/// `controller` is the *wiring* controller, not necessarily the recipient —
389/// under R44-back the press goes to the first registrant claiming it, and
390/// `controller` is only the fallback when none does.
391fn consume_back<State: 'static>(controller: &NavigatorController<State>) {
392    // Tracked read — subscribes the rebuild to the back-press counter.
393    let count = back_presses().count.get();
394    let fire = SHARED.with(|shared| {
395        let mut shared = shared.borrow_mut();
396        match shared.consumed {
397            // A fresh process/marker: seed to the current count WITHOUT firing,
398            // so a press delivered before any handler existed does not trigger a
399            // spurious back on the first wire.
400            None => {
401                shared.consumed = Some(count);
402                false
403            }
404            // Already caught up — a re-run rebuild or the second entry point.
405            Some(prev) if prev == count => false,
406            // A new press (a burst collapses to one `request_back`: catch up to
407            // the current depth, don't replay each event — the back semantics).
408            Some(_) => {
409                shared.consumed = Some(count);
410                true
411            }
412        }
413    });
414    if fire {
415        route_back(controller);
416    }
417}
418
419/// Seed the shared consumption marker to the current back-press count if it is
420/// not seeded yet, so a press delivered before a [`BackHandler`] existed does
421/// not trigger a spurious `request_back` on the first [`track`](BackHandler::track).
422/// Idempotent (only-if-unseeded), so it composes with a `navigator()` that
423/// already seeded the marker on an earlier rebuild.
424fn seed_consumed() {
425    let count = back_presses().count.get_untracked();
426    SHARED.with(|shared| {
427        let mut shared = shared.borrow_mut();
428        if shared.consumed.is_none() {
429            shared.consumed = Some(count);
430        }
431    });
432}
433
434/// Auto-wire back handling for `controller`: the entry point the
435/// facade's [`navigator`](crate::navigator) wrapper calls on every rebuild.
436/// Consumes a new back press (routing it through `request_back`) and refreshes
437/// the `handles_back` interest — so an app using `frust::navigator` gets the
438/// full Android back contract (overlay dismiss → pop → app exit) with ZERO
439/// back-specific app code, and with no double-pop against a manual
440/// [`BackHandler`] on the same controller (see the module docs).
441///
442/// # Panics
443///
444/// Runs during a rebuild's build pass; the first call registers the live
445/// provider, which panics off the UI thread (always the UI thread here).
446pub(crate) fn auto_wire<State: 'static>(controller: &NavigatorController<State>) {
447    consume_back(controller);
448    refresh_interest(controller, Role::Navigator);
449}
450
451/// [`auto_wire`] for a root [`overlay_host`](crate::overlay_host): identical,
452/// except the registrant takes [`Role::Host`] — the rank that puts an open
453/// root overlay ahead of the inner navigator whatever the wire sequence looks
454/// like (R44-back; see the module docs).
455///
456/// # Panics
457///
458/// As [`auto_wire`].
459pub(crate) fn auto_wire_overlay_host<State: 'static>(controller: &NavigatorController<State>) {
460    consume_back(controller);
461    refresh_interest(controller, Role::Host);
462}
463
464/// A [`NavigatorController`] wired to the process-wide back-press source (see
465/// the module docs) — the **explicit** back-wiring surface predating the
466/// automatic [`navigator`](crate::navigator) auto-wiring.
467///
468/// App code using `frust::navigator` no longer needs this: back handling is
469/// automatic. It stays fully supported for apps that want an explicit handle
470/// (Huddle constructs one), and now shares the same single consumption source
471/// and `request_back`/`back_interest` routing as the automatic path — so
472/// constructing a `BackHandler` *and* calling `frust::navigator` on the same
473/// controller still consumes each press exactly once.
474///
475/// Construct once with [`attach_back_handler`] (or [`BackHandler::new`]) —
476/// typically from `Component::init`, storing the result in `Component::State` —
477/// then call [`track`](Self::track) from every `Component::build`.
478pub struct BackHandler<State: 'static> {
479    controller: NavigatorController<State>,
480}
481
482impl<State: 'static> BackHandler<State> {
483    /// Wire `controller` to the back-press source. Seeds the shared consumption
484    /// marker to the current back-press count (so a press delivered before this
485    /// handler existed does not trigger a spurious back on the first
486    /// [`track`](Self::track)), publishes the initial `handles_back` fallback
487    /// flag, and registers the live `back_interest` provider (see the module
488    /// docs' timing note).
489    ///
490    /// Call this once per controller (e.g. from `Component::init`) — from the UI
491    /// thread, since the provider slot is UI-thread-affine (it reads the
492    /// `Rc`-backed controller). A `Component::init` always runs on the UI thread.
493    ///
494    /// **Registration position does not depend on when you construct this**
495    /// (see the module docs' R44-back section): a handler built in
496    /// `Component::init` wires before any view exists, and a root
497    /// [`overlay_host`](crate::overlay_host) still outranks the navigator it
498    /// wraps — the host's rank comes from its own entry point, not from wire
499    /// order. Where you call [`track`](Self::track) from is likewise free.
500    pub fn new(controller: NavigatorController<State>) -> Self {
501        seed_consumed();
502        refresh_interest(&controller, Role::Navigator);
503        Self { controller }
504    }
505
506    /// Consume a new back press (routing it through
507    /// [`request_back`](NavigatorController::request_back)) and refresh the
508    /// framework's `handles_back` interest from the current stack. Call from
509    /// every `Component::build`.
510    ///
511    /// Shares the single consumption source with the automatic
512    /// [`navigator`](crate::navigator) path (see the module docs): a rebuild
513    /// re-run that observes the same already-consumed count neither re-fires nor
514    /// double-counts, and a `frust::navigator` on the same controller in the
515    /// same rebuild cannot double-pop.
516    pub fn track(&self) {
517        consume_back(&self.controller);
518        refresh_interest(&self.controller, Role::Navigator);
519    }
520
521    /// The wired controller — hand it to
522    /// [`navigator`](frust_widgets::navigator), or drive it directly.
523    pub fn controller(&self) -> &NavigatorController<State> {
524        &self.controller
525    }
526}
527
528/// Convenience constructor equivalent to [`BackHandler::new`] — see its docs and
529/// [`RouterDeepLinks`](crate::RouterDeepLinks)/[`router_with_deep_links`](crate::router_with_deep_links)
530/// for the mirrored API shape.
531///
532/// Note that back handling is **automatic** for any app using
533/// [`frust::navigator`](crate::navigator); this explicit surface is
534/// only needed when an app wants a handle it drives directly (e.g. Huddle).
535///
536/// ```no_run
537/// use frust::{
538///     AnyView, BackHandler, Component, NavigatorController, any, attach_back_handler,
539///     navigator, text,
540/// };
541///
542/// #[derive(Default)]
543/// struct App;
544///
545/// struct AppState {
546///     back: BackHandler<AppState>,
547/// }
548///
549/// impl Component for App {
550///     type State = AppState;
551///
552///     fn init(&self) -> AppState {
553///         let nav: NavigatorController<AppState> = NavigatorController::new();
554///         AppState {
555///             back: attach_back_handler(nav),
556///         }
557///     }
558///
559///     fn build(&self, state: &mut AppState) -> AnyView<AppState> {
560///         // Called every rebuild: consumes a new back press (via `request_back`)
561///         // and keeps the framework's handles-back interest in sync. The
562///         // `frust::navigator` call auto-wires the SAME controller — one press
563///         // still pops exactly once (shared consumption source).
564///         state.back.track();
565///         let controller = state.back.controller();
566///         any(navigator(controller, || any(text("home"))))
567///     }
568/// }
569///
570/// frust::app!(App);
571/// # fn main() {}
572/// ```
573pub fn attach_back_handler<State: 'static>(
574    controller: NavigatorController<State>,
575) -> BackHandler<State> {
576    BackHandler::new(controller)
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use frust_core::{AnyView, RenderRoot, any};
583    use frust_reactive::{
584        ReactiveRuntime, clear_can_pop_provider, handles_back, push_back_press, set_handles_back,
585    };
586    use frust_widgets::{
587        BackPolicy, NavigatorController, NavigatorView, PushOptions, navigator as raw_navigator,
588        overlay_host as raw_overlay_host,
589    };
590    use std::sync::{Arc, Mutex};
591
592    /// Serializes every test in this module: they all touch `frust-reactive`'s
593    /// process-wide back-press counter / `handles_back` flag / live-provider slot
594    /// AND this module's `SHARED` marker, so concurrent runs would race the
595    /// shared counter (one test's `push_back_press` advancing it under another's
596    /// feet). `reset()` (below) additionally clears the per-thread state a reused
597    /// harness thread would otherwise carry between tests.
598    static TEST_LOCK: Mutex<()> = Mutex::new(());
599
600    /// Reset the per-thread wiring so each test starts clean: force-clear
601    /// frust-reactive's live provider, drop the shared marker/interest/provider,
602    /// and reset the polled flag. Call at the top of every test (after taking
603    /// [`TEST_LOCK`], under an initialized runtime).
604    fn reset() {
605        clear_can_pop_provider();
606        set_handles_back(false);
607        SHARED.with(|shared| *shared.borrow_mut() = SharedBack::new());
608    }
609
610    fn page() -> AnyView<()> {
611        any(frust_widgets::text("x"))
612    }
613
614    /// The build closure type [`RenderRoot::rebuild`] drives, boxed so
615    /// [`Harness`] can store it (mirroring `router_glue`'s `AppLogic`).
616    type AppLogic = Box<dyn FnMut(&mut ()) -> NavigatorView<()>>;
617
618    /// A `RenderRoot`/app closure driving a controller's `navigator`. The
619    /// `wire` closure runs each rebuild BEFORE reconciliation to exercise a back
620    /// entry point (the automatic `auto_wire`, an explicit `BackHandler::track`,
621    /// or both) exactly as a `Component::build` would.
622    struct Harness {
623        controller: NavigatorController<()>,
624        root: RenderRoot<(), NavigatorView<()>>,
625        app: AppLogic,
626        wire: Box<dyn FnMut()>,
627    }
628
629    impl Harness {
630        /// A fresh-controller harness whose per-rebuild wiring runs `wire`
631        /// (given a clone of that controller) — the seam each test uses to pick
632        /// its back entry point.
633        fn new(wire: impl FnMut(&NavigatorController<()>) + 'static) -> Self {
634            Self::with_controller(NavigatorController::new(), wire)
635        }
636
637        /// As [`new`](Self::new) but drives an EXISTING `controller` — so a test
638        /// can share one controller between a `BackHandler` and the navigator
639        /// (the manual + auto criterion).
640        fn with_controller(
641            controller: NavigatorController<()>,
642            mut wire: impl FnMut(&NavigatorController<()>) + 'static,
643        ) -> Self {
644            let app_controller = controller.clone();
645            let app: AppLogic = Box::new(move |_: &mut ()| raw_navigator(&app_controller, page));
646            let wire_controller = controller.clone();
647            let wire: Box<dyn FnMut()> = Box::new(move || wire(&wire_controller));
648            let mut harness = Harness {
649                controller,
650                root: RenderRoot::new(),
651                app,
652                wire,
653            };
654            harness.rebuild();
655            harness
656        }
657
658        fn rebuild(&mut self) {
659            // Wire under the build pass, exactly as a Component::build would,
660            // then reconcile (which applies the navigator's queued ops).
661            (self.wire)();
662            self.root.rebuild(&mut self.app, &mut ());
663        }
664
665        fn depth(&self) -> usize {
666            self.controller.depth()
667        }
668    }
669
670    /// Criterion 1: an app using the AUTOMATIC `frust::navigator` auto-wiring
671    /// (no `BackHandler` in app code) pops one page on a back press. Also
672    /// covers criterion 2 (depth 1, no overlay → `handles_back()` false: the
673    /// press falls through to the platform).
674    #[test]
675    fn auto_wire_pops_without_a_backhandler() {
676        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
677        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
678        reset();
679
680        let mut h = Harness::new(auto_wire);
681
682        // Criterion 2: at the root (depth 1, no overlay) the framework does NOT
683        // handle back — a root-level press bubbles to the platform.
684        assert_eq!(h.depth(), 1, "starts at the root page");
685        assert!(!handles_back(), "depth 1 + no overlay: handles_back false");
686
687        // Push to depth 2 (a normal opaque push → BackPolicy::Pop).
688        h.controller.push(page);
689        h.rebuild();
690        assert_eq!(h.depth(), 2, "pushed to depth 2");
691        assert!(
692            handles_back(),
693            "poppable stack: handles_back true, same rebuild"
694        );
695
696        // Criterion 1: one back press pops one page — with NO BackHandler.
697        push_back_press();
698        h.rebuild();
699        assert_eq!(h.depth(), 1, "auto-wired back press pops one page");
700        assert!(
701            !handles_back(),
702            "back at the root: handles_back false again"
703        );
704    }
705
706    /// Criterion 3: a `Veto` overlay claims the back press (`handles_back()`
707    /// true — predictive-back parity via `back_interest`), the press is
708    /// consumed, and the stack is UNCHANGED — the routing-through-`request_back`
709    /// (not a bare `pop`) contract. This is the meaningful facade-reachable
710    /// form of "depth 1 + Veto overlay": the overlay sits over the root, and
711    /// even though a raw `pop()` *would* remove it (the stack is poppable),
712    /// `request_back` honors the Veto policy and pops nothing. (A Veto policy on
713    /// the depth-1 *root* itself is not expressible through the public push API
714    /// — the root is always `BackPolicy::Pop` — and is covered by a
715    /// widget-level `compute_back_interest` test.)
716    #[test]
717    fn veto_overlay_claims_back_but_does_not_pop() {
718        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
719        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
720        reset();
721
722        let mut h = Harness::new(auto_wire);
723
724        // Push a transparent Veto overlay over the root.
725        h.controller
726            .push_with_options(page, PushOptions::transparent().back(BackPolicy::Veto));
727        h.rebuild();
728
729        // The overlay claims back ahead-of-time (facade reads `back_interest`).
730        assert!(handles_back(), "a Veto overlay claims the back press");
731
732        let before = h.depth();
733        // A back press is consumed but Veto swallows it — the stack is unchanged.
734        // A bare `pop()` here would have removed the overlay (regression guard
735        // for the request_back-not-pop switch).
736        push_back_press();
737        h.rebuild();
738        assert_eq!(
739            h.depth(),
740            before,
741            "Veto consumes the press via request_back: stack unchanged (not popped)"
742        );
743        assert!(handles_back(), "still claiming back (overlay still on top)");
744    }
745
746    /// The EXPLICIT `BackHandler` path still works on its own (regression for
747    /// the original surface, now routing through `request_back`): a press
748    /// pops one page and `handles_back()` tracks the depth with no settle frame
749    /// (the live provider closes the stale-window).
750    #[test]
751    fn explicit_backhandler_pops_and_tracks_handles_back() {
752        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
753        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
754        reset();
755
756        let controller: NavigatorController<()> = NavigatorController::new();
757        let back = BackHandler::new(controller.clone());
758        // Only the explicit handler drives back here — on the SAME controller the
759        // harness's navigator uses (a raw navigator view, no auto-wiring).
760        let mut h = Harness::with_controller(controller, move |_c| back.track());
761
762        assert_eq!(h.depth(), 1, "starts at the root");
763        assert!(
764            !handles_back(),
765            "root-level back bubbles: handles_back false"
766        );
767
768        h.controller.push(page);
769        h.rebuild();
770        assert_eq!(h.depth(), 2, "push published to depth immediately");
771        assert!(
772            handles_back(),
773            "poppable stack reports handles_back true on the SAME rebuild, no settle frame"
774        );
775
776        push_back_press();
777        h.rebuild();
778        assert_eq!(h.depth(), 1, "one back press pops one page");
779        assert!(
780            !handles_back(),
781            "root again: handles_back false immediately"
782        );
783
784        // A back press at the root is a safe no-op (consumed, pops nothing).
785        push_back_press();
786        h.rebuild();
787        assert_eq!(h.depth(), 1, "pop-at-root is a no-op");
788
789        // After a force-clear the polled fallback flag answers (depth-1 false).
790        clear_can_pop_provider();
791        assert!(
792            !handles_back(),
793            "after clear, handles_back reads the polled fallback"
794        );
795    }
796
797    /// Criterion 4 (regression): a manual `BackHandler` AND the automatic
798    /// `navigator()` auto-wiring on the SAME controller consume one press
799    /// exactly once — no double-pop. Both run every rebuild (as Huddle does:
800    /// `state.back.track()` then `frust::navigator(&controller, ...)`).
801    #[test]
802    fn manual_backhandler_plus_auto_wire_pops_once() {
803        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
804        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
805        reset();
806
807        // Construct the manual handler first (as an app would, in init).
808        let controller: NavigatorController<()> = NavigatorController::new();
809        let back = BackHandler::new(controller.clone());
810
811        // The harness's navigator AND both back entry points drive the SAME
812        // controller. Each rebuild runs BOTH entry points in Huddle's order: the
813        // manual `track()` then the auto-wiring (`c` is the harness controller).
814        let mut h = Harness::with_controller(controller, move |c| {
815            back.track();
816            auto_wire(c);
817        });
818
819        // Push to depth 3 so a single-vs-double pop is unambiguous.
820        h.controller.push(page);
821        h.rebuild();
822        h.controller.push(page);
823        h.rebuild();
824        assert_eq!(h.depth(), 3, "pushed to depth 3");
825
826        // ONE back press: with a shared consumption source this pops exactly one
827        // page (depth 2). A double-consumption bug would pop two (depth 1).
828        push_back_press();
829        h.rebuild();
830        assert_eq!(
831            h.depth(),
832            2,
833            "manual + auto share one consumption source: one press pops once"
834        );
835
836        // A rebuild with no new press does not re-pop (dedupe).
837        h.rebuild();
838        assert_eq!(h.depth(), 2, "no new press -> no extra pop");
839    }
840
841    // -----------------------------------------------------------------------
842    // R44-back — arbitration across a root overlay host and an inner navigator.
843    // -----------------------------------------------------------------------
844
845    /// A root `overlay_host` wrapping an inner `navigator`, each auto-wired
846    /// where its view is constructed: the host from the app's build pass, the
847    /// inner navigator from the host's root-page builder — which runs inside
848    /// the host's own reconcile, i.e. strictly later. That *is* the build order
849    /// R44-back arbitrates on, so the harness reproduces the real ordering
850    /// rather than hand-declaring it.
851    struct HostHarness {
852        host: NavigatorController<()>,
853        inner: NavigatorController<()>,
854        root: RenderRoot<(), NavigatorView<()>>,
855        app: AppLogic,
856    }
857
858    impl HostHarness {
859        fn new() -> Self {
860            let host: NavigatorController<()> = NavigatorController::new();
861            let inner: NavigatorController<()> = NavigatorController::new();
862            let app: AppLogic = {
863                let host = host.clone();
864                let inner = inner.clone();
865                Box::new(move |_: &mut ()| {
866                    // Outermost: exactly what `frust::overlay_host` does.
867                    auto_wire_overlay_host(&host);
868                    let inner = inner.clone();
869                    raw_overlay_host(&host, move || {
870                        // Innermost: exactly what `frust::navigator` does.
871                        auto_wire(&inner);
872                        any(raw_navigator(&inner, page))
873                    })
874                })
875            };
876            let mut harness = Self {
877                host,
878                inner,
879                root: RenderRoot::new(),
880                app,
881            };
882            harness.rebuild();
883            harness
884        }
885
886        fn rebuild(&mut self) {
887            self.root.rebuild(&mut self.app, &mut ());
888        }
889    }
890
891    /// The R44-back arbitration list, in order — the whitebox view the ordering
892    /// and pruning rules are pinned against.
893    fn registrant_ids() -> Vec<frust_widgets::NavigatorId> {
894        SHARED.with(|shared| shared.borrow().registrants.iter().map(|r| r.id).collect())
895    }
896
897    /// **R44-back**: with a root overlay open, the press goes to the HOST even
898    /// though the inner navigator is poppable and wired *later*. Without the
899    /// rank the last wire would win outright and this press would pop the inner
900    /// navigator — a page vanishing under an open modal.
901    #[test]
902    fn a_root_overlay_takes_the_back_press_from_the_inner_navigator() {
903        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
904        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
905        reset();
906
907        let mut h = HostHarness::new();
908        // Registration order is build order: the host first, then the navigator
909        // its root page builds.
910        assert_eq!(
911            registrant_ids(),
912            vec![h.host.id(), h.inner.id()],
913            "outermost first"
914        );
915
916        // Both stacks are poppable: the inner navigator has a page pushed, and
917        // an overlay sits on the host.
918        h.inner.push(page);
919        h.rebuild();
920        h.host
921            .push_with_options(page, PushOptions::transparent().back(BackPolicy::Pop));
922        h.rebuild();
923        assert_eq!((h.host.depth(), h.inner.depth()), (2, 2));
924        assert!(handles_back(), "some registrant claims the press");
925
926        push_back_press();
927        h.rebuild();
928        assert_eq!(h.host.depth(), 1, "the press went to the root overlay host");
929        assert_eq!(
930            h.inner.depth(),
931            2,
932            "and NOT to the inner navigator (the root-modal back bug)"
933        );
934    }
935
936    /// **The degenerate case, bit for bit.** A host with no overlays sits at
937    /// depth 1 with the default `BackPolicy::Pop`, so `compute_back_interest`
938    /// is false and it claims nothing — the press falls straight through to the
939    /// inner navigator, exactly as before the host existed. This is the
940    /// single-navigator (huddle) regression guard.
941    #[test]
942    fn a_host_with_no_overlay_defers_the_back_press_to_the_inner_navigator() {
943        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
944        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
945        reset();
946
947        let mut h = HostHarness::new();
948        assert!(
949            !handles_back(),
950            "two navigators, both at the root: back bubbles to the platform"
951        );
952
953        h.inner.push(page);
954        h.rebuild();
955        assert_eq!(h.host.depth(), 1, "the host stays empty");
956        assert!(
957            handles_back(),
958            "the inner navigator's interest answers for the whole app"
959        );
960
961        push_back_press();
962        h.rebuild();
963        assert_eq!(h.inner.depth(), 1, "the press popped the inner navigator");
964        assert_eq!(h.host.depth(), 1, "the empty host was untouched");
965        assert!(!handles_back(), "back at the root of both: bubbles again");
966
967        // A press with nothing to do stays a safe no-op (nobody claims it, so it
968        // routes to the wiring controller, whose stack cannot pop).
969        push_back_press();
970        h.rebuild();
971        assert_eq!((h.host.depth(), h.inner.depth()), (1, 1));
972    }
973
974    /// The predictive-back half of R44-back: an overlay on the host claims the
975    /// press even when the inner navigator has nothing to pop. `handles_back`
976    /// answers `any(interest)`, not "the last navigator wired" — under the
977    /// latter the shell would be told nobody handles back and would *exit the
978    /// app* with a root modal open.
979    #[test]
980    fn a_root_overlay_claims_back_even_with_the_inner_navigator_at_its_root() {
981        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
982        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
983        reset();
984
985        let mut h = HostHarness::new();
986        h.host
987            .push_with_options(page, PushOptions::transparent().back(BackPolicy::Pop));
988        h.rebuild();
989        assert_eq!((h.host.depth(), h.inner.depth()), (2, 1));
990        assert!(
991            handles_back(),
992            "the root overlay claims the press though the inner navigator cannot pop"
993        );
994
995        push_back_press();
996        h.rebuild();
997        assert_eq!(h.host.depth(), 1, "the press dismissed the root overlay");
998        assert!(!handles_back(), "nothing left to claim it");
999    }
1000
1001    /// The one-cycle prune: a navigator that stops wiring (its screen torn down)
1002    /// is dropped from the arbitration list rather than claiming presses
1003    /// forever from a stack nothing renders. These controllers are never mounted
1004    /// (no widget builds them), so the mounted veto never applies and this is
1005    /// the pure one-cycle path.
1006    ///
1007    /// All three controllers here register as plain `Role::Navigator` peers —
1008    /// despite the `outer`/`inner`/`nested` names, nothing wires one *inside*
1009    /// another — so this test pins only the birth tiebreak among equal-rank
1010    /// registrants (innermost/last-wire first), not `Role::Host`-first ranking
1011    /// (that is
1012    /// `an_init_registered_backhandler_does_not_outrank_a_later_overlay_host`).
1013    /// The expected order below is therefore the REVERSE of build order, by
1014    /// design.
1015    #[test]
1016    fn a_navigator_that_stops_wiring_is_pruned_after_one_full_cycle() {
1017        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1018        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1019        reset();
1020
1021        let outer: NavigatorController<()> = NavigatorController::new();
1022        let inner: NavigatorController<()> = NavigatorController::new();
1023        let nested: NavigatorController<()> = NavigatorController::new();
1024
1025        // Frame 1: three navigators wire, in build order.
1026        refresh_interest(&outer, Role::Navigator);
1027        refresh_interest(&inner, Role::Navigator);
1028        refresh_interest(&nested, Role::Navigator);
1029        assert_eq!(
1030            registrant_ids(),
1031            vec![nested.id(), inner.id(), outer.id()],
1032            "innermost/last-wired first: the reverse of build order"
1033        );
1034
1035        // Frame 2: the nested navigator's screen is gone — it never wires again.
1036        refresh_interest(&outer, Role::Navigator);
1037        refresh_interest(&inner, Role::Navigator);
1038        assert_eq!(
1039            registrant_ids(),
1040            vec![nested.id(), inner.id(), outer.id()],
1041            "not yet: a full cycle has not elapsed without it"
1042        );
1043
1044        // Frame 3: the outer wire now spans a whole cycle in which `nested`
1045        // never appeared.
1046        refresh_interest(&outer, Role::Navigator);
1047        assert_eq!(
1048            registrant_ids(),
1049            vec![inner.id(), outer.id()],
1050            "the departed navigator is pruned, and its controller clone released"
1051        );
1052
1053        // Re-wiring an existing registrant never re-orders it.
1054        refresh_interest(&inner, Role::Navigator);
1055        refresh_interest(&outer, Role::Navigator);
1056        refresh_interest(&inner, Role::Navigator);
1057        assert_eq!(registrant_ids(), vec![inner.id(), outer.id()]);
1058    }
1059
1060    // -----------------------------------------------------------------------
1061    // The two ways a "a repeat wire proves a build cycle" premise breaks — a
1062    // controller wiring TWICE per pass, and a `BackHandler` wiring from
1063    // `Component::init` before any view exists.
1064    // -----------------------------------------------------------------------
1065
1066    /// The REAL Huddle shape under a root overlay host: the inner navigator is
1067    /// wired **twice in one build pass** — an explicit `BackHandler` built in
1068    /// `Component::init` and `track()`ed from the host's page builder, plus the
1069    /// automatic `navigator()` wiring right beside it
1070    /// (`examples/huddle/src/lib.rs` does exactly this pair).
1071    ///
1072    /// Without the mounted veto, the second wire's one-cycle prune reads the
1073    /// FIRST wire of the same pass as a cycle boundary and drops the host, which
1074    /// then re-appends *after* the inner navigator — permanently inverting
1075    /// arbitration and re-creating the root-modal back bug (a press with a root
1076    /// modal open popping the page underneath it).
1077    #[test]
1078    fn an_overlay_host_survives_an_inner_navigator_wiring_twice_per_pass() {
1079        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1080        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1081        reset();
1082
1083        let host: NavigatorController<()> = NavigatorController::new();
1084        let inner: NavigatorController<()> = NavigatorController::new();
1085        // `Component::init`: the explicit handler exists before any view does.
1086        let back = Rc::new(BackHandler::new(inner.clone()));
1087
1088        let mut app: AppLogic = {
1089            let host = host.clone();
1090            let inner = inner.clone();
1091            Box::new(move |_: &mut ()| {
1092                // The app's build pass: `frust::overlay_host`.
1093                auto_wire_overlay_host(&host);
1094                let inner = inner.clone();
1095                let back = Rc::clone(&back);
1096                raw_overlay_host(&host, move || {
1097                    // The host's page builder — Huddle's own build body, moved
1098                    // inside the host: `state.back.track()` then
1099                    // `frust::navigator(&controller, …)`, one pass, one
1100                    // controller, TWO wires.
1101                    back.track();
1102                    auto_wire(&inner);
1103                    any(raw_navigator(&inner, page))
1104                })
1105            })
1106        };
1107        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1108        root.rebuild(&mut app, &mut ());
1109
1110        assert_eq!(
1111            registrant_ids(),
1112            vec![host.id(), inner.id()],
1113            "the host outranks the inner navigator despite wiring once to its twice"
1114        );
1115
1116        // An overlay over the whole app, and a poppable inner stack under it.
1117        inner.push(page);
1118        host.push_with_options(page, PushOptions::transparent().back(BackPolicy::Pop));
1119        // Two more passes: the ordering must survive repeated double-wiring.
1120        for _ in 0..2 {
1121            root.rebuild(&mut app, &mut ());
1122        }
1123        assert_eq!((host.depth(), inner.depth()), (2, 2));
1124        assert_eq!(
1125            registrant_ids(),
1126            vec![host.id(), inner.id()],
1127            "still outermost-first after repeated passes"
1128        );
1129        assert!(
1130            handles_back(),
1131            "the shell is told the app handles back (a root modal is open)"
1132        );
1133
1134        push_back_press();
1135        root.rebuild(&mut app, &mut ());
1136        assert_eq!(host.depth(), 1, "the press dismissed the root overlay");
1137        assert_eq!(
1138            inner.depth(),
1139            2,
1140            "and NOT the page under it (the root-modal back bug)"
1141        );
1142    }
1143
1144    /// The second ordering break: `BackHandler::new` wires from
1145    /// `Component::init`, which runs before ANY view is constructed — so a
1146    /// first-wire-order rule alone would give the inner navigator position 0
1147    /// from frame 1, and no amount of "track from inside the host's page
1148    /// builder" advice could fix a registration that already happened. Rank
1149    /// comes from the entry point instead, so the host still sorts first.
1150    #[test]
1151    fn an_init_registered_backhandler_does_not_outrank_a_later_overlay_host() {
1152        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1153        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1154        reset();
1155
1156        let host: NavigatorController<()> = NavigatorController::new();
1157        let inner: NavigatorController<()> = NavigatorController::new();
1158
1159        // `Component::init` — the inner navigator's handler registers FIRST.
1160        let back = BackHandler::new(inner.clone());
1161        assert_eq!(registrant_ids(), vec![inner.id()], "init wired it alone");
1162
1163        // The first `Component::build`: the host wires only now.
1164        auto_wire_overlay_host(&host);
1165        assert_eq!(
1166            registrant_ids(),
1167            vec![host.id(), inner.id()],
1168            "the host claims the outermost slot even though it wired second"
1169        );
1170
1171        // And a repeat pass keeps it there.
1172        auto_wire_overlay_host(&host);
1173        back.track();
1174        assert_eq!(registrant_ids(), vec![host.id(), inner.id()]);
1175    }
1176
1177    /// The mounted veto's other half: it only ever *delays* release. A
1178    /// navigator whose widget is torn down (its page rebuilt without it) reports
1179    /// unmounted, so the one-cycle rule drops it and releases the controller
1180    /// clone the registrant holds.
1181    ///
1182    /// `nested` genuinely lives inside `outer`'s own page here, so it wires
1183    /// after `outer` and — under innermost-first `Role::Navigator` order — sorts
1184    /// AHEAD of it; that ordering is incidental to this test's own point
1185    /// (mount-liveness pruning).
1186    #[test]
1187    fn a_torn_down_navigator_stops_vetoing_the_prune_and_is_released() {
1188        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1189        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1190        reset();
1191
1192        let outer: NavigatorController<()> = NavigatorController::new();
1193        let nested: NavigatorController<()> = NavigatorController::new();
1194        // The nested navigator lives inside the outer navigator's root page,
1195        // until this flag turns it off.
1196        let show_nested = Rc::new(std::cell::Cell::new(true));
1197        // The arbitration list as seen MID-PASS, at the moment the outer
1198        // navigator's page rebuilds — i.e. inside the window a spurious prune
1199        // opens, when the shell may read `handles_back` or a press may route.
1200        let mid_pass: Rc<RefCell<Vec<NavigatorId>>> = Rc::new(RefCell::new(Vec::new()));
1201
1202        let mut app: AppLogic = {
1203            let outer = outer.clone();
1204            let nested = nested.clone();
1205            let show_nested = Rc::clone(&show_nested);
1206            let mid_pass = Rc::clone(&mid_pass);
1207            Box::new(move |_: &mut ()| {
1208                // The outer navigator wires TWICE per pass (Huddle's
1209                // `track()` + `navigator()` pair), so the one-cycle rule alone
1210                // would prune the nested navigator on every second wire — the
1211                // mounted veto is the only reason it survives below.
1212                auto_wire(&outer);
1213                auto_wire(&outer);
1214                let nested = nested.clone();
1215                let show_nested = Rc::clone(&show_nested);
1216                let mid_pass = Rc::clone(&mid_pass);
1217                raw_navigator(&outer, move || {
1218                    *mid_pass.borrow_mut() = registrant_ids();
1219                    if show_nested.get() {
1220                        auto_wire(&nested);
1221                        any(raw_navigator(&nested, page))
1222                    } else {
1223                        page()
1224                    }
1225                })
1226            })
1227        };
1228        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1229        root.rebuild(&mut app, &mut ());
1230        assert!(nested.is_mounted(), "the nested navigator is in the tree");
1231        assert_eq!(registrant_ids(), vec![nested.id(), outer.id()]);
1232
1233        // While mounted it survives the outer navigator's double wire — the
1234        // exact sequence the one-cycle rule alone misreads as a build cycle.
1235        root.rebuild(&mut app, &mut ());
1236        root.rebuild(&mut app, &mut ());
1237        assert_eq!(
1238            registrant_ids(),
1239            vec![nested.id(), outer.id()],
1240            "a mounted navigator is in the tree by definition: never pruned"
1241        );
1242        assert_eq!(
1243            *mid_pass.borrow(),
1244            vec![nested.id(), outer.id()],
1245            "and it is never MISSING mid-pass either — the window in which a \
1246             spurious prune would mis-answer handles_back / mis-route a press"
1247        );
1248
1249        // Its page rebuilds without it: the widget tears down.
1250        show_nested.set(false);
1251        root.rebuild(&mut app, &mut ());
1252        assert!(!nested.is_mounted(), "teardown dropped the liveness");
1253
1254        // Now the one-cycle rule reaches it (the outer wire that spans a whole
1255        // cycle in which the nested navigator never appeared).
1256        root.rebuild(&mut app, &mut ());
1257        root.rebuild(&mut app, &mut ());
1258        assert_eq!(
1259            registrant_ids(),
1260            vec![outer.id()],
1261            "the departed navigator is released, not kept alive by the veto"
1262        );
1263    }
1264
1265    // -----------------------------------------------------------------------
1266    // Innermost-first: a nested navigator must receive the back press.
1267    // -----------------------------------------------------------------------
1268
1269    /// An outer navigator PUSHES a page that hosts a nested navigator (the outer
1270    /// navigator is at depth > 1 — exactly what makes `compute_back_interest`
1271    /// true for it too), and the nested navigator's own stack is also poppable.
1272    /// A back press must reach the INNERMOST (nested) navigator, popping its
1273    /// stack, and must leave the outer navigator's depth untouched — the reverse
1274    /// of what plain build-order (outermost-first) arbitration gives, where the
1275    /// outer navigator, wired first, claims the press and pops the whole page
1276    /// hosting the nested navigator instead of reaching into it.
1277    #[test]
1278    fn a_nested_navigator_inside_a_pushed_page_gets_the_back_press() {
1279        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1280        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1281        reset();
1282
1283        let outer: NavigatorController<()> = NavigatorController::new();
1284        let inner: NavigatorController<()> = NavigatorController::new();
1285
1286        // The outer navigator's ROOT page is plain — no nested navigator
1287        // exists at depth 1, so `compute_back_interest` starts false for it
1288        // (matching the module docs: the bug only appears once the outer
1289        // navigator itself is poppable).
1290        let mut app: AppLogic = {
1291            let outer_c = outer.clone();
1292            Box::new(move |_: &mut ()| {
1293                auto_wire(&outer_c);
1294                raw_navigator(&outer_c, page)
1295            })
1296        };
1297        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1298        root.rebuild(&mut app, &mut ());
1299        assert_eq!(outer.depth(), 1, "outer starts at its own root");
1300        assert!(
1301            !handles_back(),
1302            "root-level, no nested navigator yet: back bubbles"
1303        );
1304
1305        // Push a page onto the OUTER navigator that hosts the nested
1306        // navigator — outer is now at depth 2, so it claims back interest too:
1307        // the shape the ranking has to arbitrate.
1308        {
1309            let inner_for_push = inner.clone();
1310            outer.push(move || {
1311                auto_wire(&inner_for_push);
1312                any(raw_navigator(&inner_for_push, page))
1313            });
1314        }
1315        root.rebuild(&mut app, &mut ());
1316        assert_eq!(
1317            outer.depth(),
1318            2,
1319            "outer pushed to depth 2: the page hosting the nested navigator"
1320        );
1321        assert_eq!(
1322            inner.depth(),
1323            1,
1324            "the nested navigator starts at its own root"
1325        );
1326
1327        // Push inside the nested navigator too, so BOTH stacks are poppable —
1328        // the exact ambiguity R44-back must arbitrate between two
1329        // `Role::Navigator` peers.
1330        inner.push(page);
1331        root.rebuild(&mut app, &mut ());
1332        assert_eq!((outer.depth(), inner.depth()), (2, 2));
1333        assert!(handles_back(), "some registrant claims the press");
1334
1335        push_back_press();
1336        root.rebuild(&mut app, &mut ());
1337        assert_eq!(
1338            inner.depth(),
1339            1,
1340            "the back press popped the NESTED navigator's stack"
1341        );
1342        assert_eq!(
1343            outer.depth(),
1344            2,
1345            "and left the outer navigator's depth UNCHANGED — a bare \
1346             build-order (outermost-first) arbitration would instead have \
1347             popped the outer stack, discarding the whole page the nested \
1348             navigator lives on"
1349        );
1350    }
1351
1352    // -----------------------------------------------------------------------
1353    // R23, the converse of innermost-first: a nested navigator whose hosting
1354    // page input can no longer reach must NOT take the press.
1355    // -----------------------------------------------------------------------
1356
1357    /// An outer navigator whose ROOT page hosts a nested navigator, each
1358    /// auto-wired where its view is constructed (the outer from the app's build
1359    /// pass, the nested from the page builder the outer runs during its own
1360    /// reconcile) — the canonical tab/section-stack shape. `cull` sets the outer
1361    /// navigator's [`cull_covered_builds`], the switch that decides whether the
1362    /// covered page keeps re-running its builder at all.
1363    struct NestedHarness {
1364        outer: NavigatorController<()>,
1365        nested: NavigatorController<()>,
1366        root: RenderRoot<(), NavigatorView<()>>,
1367        app: AppLogic,
1368    }
1369
1370    impl NestedHarness {
1371        fn new(cull: bool) -> Self {
1372            let outer: NavigatorController<()> = NavigatorController::new();
1373            let nested: NavigatorController<()> = NavigatorController::new();
1374            let app: AppLogic = {
1375                let outer = outer.clone();
1376                let nested = nested.clone();
1377                Box::new(move |_: &mut ()| {
1378                    // Exactly what `frust::navigator` does, outermost first.
1379                    auto_wire(&outer);
1380                    let nested = nested.clone();
1381                    raw_navigator(&outer, move || {
1382                        auto_wire(&nested);
1383                        any(raw_navigator(&nested, page))
1384                    })
1385                    .cull_covered_builds(cull)
1386                })
1387            };
1388            let mut harness = Self {
1389                outer,
1390                nested,
1391                root: RenderRoot::new(),
1392                app,
1393            };
1394            harness.rebuild();
1395            harness
1396        }
1397
1398        fn rebuild(&mut self) {
1399            self.root.rebuild(&mut self.app, &mut ());
1400        }
1401    }
1402
1403    /// The outer navigator pushes a page OVER the one hosting the nested
1404    /// navigator. Input can reach only the outer navigator's top page
1405    /// (`input_routed_pages`), so back must follow: the press pops the OUTER
1406    /// stack, and the nested (invisible) stack is untouched.
1407    ///
1408    /// Without the R23 reach gate at the source, `route_back`'s
1409    /// innermost-first `find(|r| interest())` would hand the press to the nested
1410    /// navigator — the covered page keeps reconciling (the default) or stays
1411    /// `is_mounted()` (under the cull) either way — popping a stack nobody can
1412    /// see while the back button appears to do nothing.
1413    ///
1414    /// Run under BOTH `cull_covered_builds` settings: they are exactly the two
1415    /// ways the covered page's builder does or does not keep running, and the
1416    /// answer must not depend on that.
1417    #[test]
1418    fn a_nested_navigator_on_a_covered_page_does_not_take_the_back_press() {
1419        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1420        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1421
1422        for cull in [false, true] {
1423            reset();
1424            let mut h = NestedHarness::new(cull);
1425
1426            // The nested stack is poppable while its page is still current —
1427            // the innermost-first case, which must keep working (below too).
1428            h.nested.push(page);
1429            h.rebuild();
1430            assert_eq!((h.outer.depth(), h.nested.depth()), (1, 2));
1431            assert!(
1432                handles_back(),
1433                "the nested navigator claims the press while its page is current (cull={cull})"
1434            );
1435
1436            // Now push a page over it on the OUTER navigator: the nested
1437            // navigator's page is covered and routed no input.
1438            h.outer.push(page);
1439            h.rebuild();
1440            assert_eq!((h.outer.depth(), h.nested.depth()), (2, 2));
1441            // A few idle frames: under `cull_covered_builds(true)` the covered
1442            // page stops rebuilding entirely here, so this is where a
1443            // wire-refreshed (rather than live) reachability flag would go stale.
1444            for _ in 0..3 {
1445                h.rebuild();
1446            }
1447            assert!(
1448                handles_back(),
1449                "the outer navigator claims it (cull={cull})"
1450            );
1451
1452            push_back_press();
1453            h.rebuild();
1454            assert_eq!(
1455                h.outer.depth(),
1456                1,
1457                "the press popped the OUTER stack — the page the user is \
1458                 actually looking at came off (cull={cull})"
1459            );
1460            assert_eq!(
1461                h.nested.depth(),
1462                2,
1463                "and the covered navigator's invisible stack is UNCHANGED \
1464                 (cull={cull})"
1465            );
1466
1467            // Revealed again, the nested navigator takes the next press — the
1468            // innermost-first behaviour, unregressed, in the same harness.
1469            push_back_press();
1470            h.rebuild();
1471            assert_eq!(
1472                (h.outer.depth(), h.nested.depth()),
1473                (1, 1),
1474                "back reaches the nested navigator again once its page is \
1475                 current (cull={cull})"
1476            );
1477        }
1478    }
1479
1480    /// The reach gate is **input routing**, not painting: a *transparent*
1481    /// overlay pushed on the outer navigator leaves the page below it painted
1482    /// (`PageVisibility::Visible`) but routes it no input, so the nested
1483    /// navigator on that page must not take the press either. This is the same
1484    /// divergence R23 pins for semantics.
1485    #[test]
1486    fn a_nested_navigator_under_a_transparent_overlay_does_not_take_the_back_press() {
1487        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1488        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1489        reset();
1490
1491        let mut h = NestedHarness::new(false);
1492        h.nested.push(page);
1493        h.rebuild();
1494        h.outer
1495            .push_with_options(page, PushOptions::transparent().back(BackPolicy::Pop));
1496        h.rebuild();
1497        assert_eq!((h.outer.depth(), h.nested.depth()), (2, 2));
1498
1499        push_back_press();
1500        h.rebuild();
1501        assert_eq!(h.outer.depth(), 1, "the overlay came off");
1502        assert_eq!(
1503            h.nested.depth(),
1504            2,
1505            "the painted-but-inert page's navigator kept its stack"
1506        );
1507    }
1508
1509    // -----------------------------------------------------------------------
1510    // A design-claim stress test: wiring-order independence between two
1511    // `Role::Navigator` peers. `Registrant::key`'s own doc states the
1512    // premise plainly — "a nested navigator always wires after the navigator
1513    // hosting it, so the highest `birth` among `Navigator` peers is the
1514    // innermost one" — and the two tests above this section
1515    // (`an_overlay_host_survives_an_inner_navigator_wiring_twice_per_pass`,
1516    // `an_init_registered_backhandler_does_not_outrank_a_later_overlay_host`)
1517    // already prove that premise does NOT hold for wire order in general
1518    // (`Role::Host` is decoupled from it on purpose), but only for a `Host`
1519    // vs. a `Navigator`. Nothing pins the analogous case for two `Navigator`
1520    // peers — this section does. `refresh_interest`/`Role`/`registrant_ids`
1521    // are crate-private, so this composition cannot be built from an
1522    // integration test the way the rest of the shell matrix is.
1523    // -----------------------------------------------------------------------
1524
1525    /// `inner` gets an explicit [`BackHandler`], built and FIRST wired before
1526    /// `outer` (its eventual host) has any view at all — the
1527    /// `Component::init` shape — and `track()`ed every rebuild from then on,
1528    /// so its earliest `birth` is never dropped by the one-cycle prune even
1529    /// while its own navigator widget doesn't exist yet (a bare, never-tracked
1530    /// pre-registration IS pruned before outer's page ever mounts inner for
1531    /// real, which self-heals the ordering — tried first, and not what this
1532    /// test pins). The REST of the composition is the honest structural
1533    /// nesting the shell matrix uses elsewhere: outer's plain root, then a
1534    /// page it pushes that hosts `inner`'s real navigator. With BOTH
1535    /// navigators poppable, the innermost-first rule says `inner` must win.
1536    ///
1537    /// **Observed vs. designed:** a `Role::Navigator` peer's sort key is
1538    /// `u64::MAX - birth`, and `birth` never changes after a registrant's
1539    /// FIRST wire (only `seq` refreshes) — so pinning `inner`'s birth ahead of
1540    /// `outer`'s gives `inner` the numerically LARGER key, and `route_back`'s
1541    /// first-match search finds `outer` first. `outer` wins, inverting the
1542    /// documented "structurally innermost wins" rule for this ordering. This
1543    /// is a real, reachable divergence between the documented rule (nesting
1544    /// depth) and its actual implementation (raw wire sequence), not a test
1545    /// bug — see the `#[ignore]` reason for the acceptance criterion this
1546    /// pins for future ranking work.
1547    #[test]
1548    #[ignore = "pins a design case that FAILS against current ranking: an inner \
1549                navigator whose explicit BackHandler wires (and keeps tracking) \
1550                before its outer host's first view exists (the Component::init \
1551                shape) loses the innermost-first tie-break to the outer \
1552                navigator instead of winning it — Registrant::key ranks \
1553                Role::Navigator peers by raw wire sequence (birth), not by \
1554                structural nesting depth. Expected to start passing once \
1555                ranking accounts for nesting depth rather than birth order \
1556                alone; rerun with `cargo test -p frust-ui --lib \
1557                wiring_order_does_not_flip -- --ignored` to observe the \
1558                current failure."]
1559    fn wiring_order_does_not_flip_the_innermost_navigator_when_it_is_still_poppable() {
1560        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1561        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1562        reset();
1563
1564        let outer: NavigatorController<()> = NavigatorController::new();
1565        let inner: NavigatorController<()> = NavigatorController::new();
1566
1567        // `Component::init`: inner's explicit handler wires FIRST, before
1568        // outer's own view exists.
1569        let inner_back = BackHandler::new(inner.clone());
1570        assert_eq!(registrant_ids(), vec![inner.id()], "inner wired alone");
1571
1572        let mut app: AppLogic = {
1573            let outer_c = outer.clone();
1574            Box::new(move |_: &mut ()| {
1575                // Tracked every pass, exactly like Huddle's own
1576                // `state.back.track()` — this is what keeps inner's early
1577                // birth from ever being pruned as stale.
1578                inner_back.track();
1579                auto_wire(&outer_c);
1580                raw_navigator(&outer_c, page)
1581            })
1582        };
1583        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1584        root.rebuild(&mut app, &mut ());
1585        assert_eq!(outer.depth(), 1, "outer starts at its own root");
1586
1587        {
1588            let inner_for_push = inner.clone();
1589            outer.push(move || {
1590                auto_wire(&inner_for_push);
1591                any(raw_navigator(&inner_for_push, page))
1592            });
1593        }
1594        root.rebuild(&mut app, &mut ());
1595        assert_eq!(outer.depth(), 2, "outer pushed the page hosting inner");
1596
1597        // The case-5 shape: BOTH navigators poppable.
1598        inner.push(page);
1599        root.rebuild(&mut app, &mut ());
1600        assert_eq!((outer.depth(), inner.depth()), (2, 2));
1601
1602        push_back_press();
1603        root.rebuild(&mut app, &mut ());
1604        assert_eq!(
1605            inner.depth(),
1606            1,
1607            "the structurally-innermost navigator should win the tie \
1608             regardless of which one registered first"
1609        );
1610        assert_eq!(
1611            outer.depth(),
1612            2,
1613            "and the outer stack should stay untouched"
1614        );
1615    }
1616
1617    /// The harmless half of the same stress test: at inner depth 1 (default
1618    /// `BackPolicy::Pop`, no interest either way), wire order cannot matter
1619    /// because ranking is never even consulted — `route_back` only compares
1620    /// registrants that both claim interest, and `inner` claims none here.
1621    /// Unlike its sibling above, this one is expected to (and does) pass
1622    /// against unchanged code.
1623    #[test]
1624    fn wiring_order_is_irrelevant_when_the_inner_navigator_has_no_interest() {
1625        let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1626        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1627        reset();
1628
1629        let outer: NavigatorController<()> = NavigatorController::new();
1630        let inner: NavigatorController<()> = NavigatorController::new();
1631
1632        let inner_back = BackHandler::new(inner.clone());
1633
1634        let mut app: AppLogic = {
1635            let outer_c = outer.clone();
1636            Box::new(move |_: &mut ()| {
1637                inner_back.track();
1638                auto_wire(&outer_c);
1639                raw_navigator(&outer_c, page)
1640            })
1641        };
1642        let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1643        root.rebuild(&mut app, &mut ());
1644
1645        {
1646            let inner_for_push = inner.clone();
1647            outer.push(move || {
1648                auto_wire(&inner_for_push);
1649                any(raw_navigator(&inner_for_push, page))
1650            });
1651        }
1652        root.rebuild(&mut app, &mut ());
1653        assert_eq!(
1654            (outer.depth(), inner.depth()),
1655            (2, 1),
1656            "outer poppable, inner still at its own root"
1657        );
1658
1659        push_back_press();
1660        root.rebuild(&mut app, &mut ());
1661        assert_eq!(outer.depth(), 1, "outer popped its own shell page");
1662    }
1663}