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}