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 [`BackFallback`]
42//! > ([`Role::Fallback`]) ranks after every navigator, so it claims a press
43//! > only when no navigator does; a back press goes to the first registrant
44//! > reporting interest
45//! > ([`back_interest()`](NavigatorController::back_interest)` == true` for a
46//! > navigator, the app's interest closure for a fallback).**
47//!
48//! [`SharedBack::registrants`] is that ordered list, sorted by ([`Role`], wire
49//! sequence): build order among hosts, *reversed* among navigators so the
50//! innermost wins, build order again among fallbacks. **Rank comes from the
51//! call site, not wire timing** — [`auto_wire_overlay_host`] registers
52//! [`Role::Host`], [`back_fallback`] registers [`Role::Fallback`], every other
53//! entry point [`Role::Navigator`] — because both *when* and *how often* a
54//! controller wires
55//! genuinely vary: a [`BackHandler`] built in `Component::init` wires before any
56//! view exists, and Huddle's documented pattern wires one controller **twice in
57//! one build pass** (`state.back.track()` beside
58//! `frust::navigator(&controller, …)`), so neither first-wire order nor "one
59//! wire per pass" is a premise anything here may rest on. `handles_back`/the
60//! live provider answer `any(interest)`, and a consumed press goes to the
61//! *first* registrant with interest — so one registrant (or a host with no
62//! overlays, `compute_back_interest` `false` at depth 1) behaves exactly as if
63//! arbitration did not exist.
64//!
65//! ## Releasing a navigator that went away
66//!
67//! A navigator that goes away (a screen with its own nested navigator, popped)
68//! must stop claiming presses and release its controller clone. Two rules, in
69//! priority order:
70//!
71//! 1. **Mounted is live.** [`NavigatorController::is_mounted`] is exact (the
72//! navigator's `View::build`/`teardown` write it), so a *mounted* registrant
73//! is in the retained tree by definition and is never pruned, however the
74//! wire sequence looks — the load-bearing half: a root overlay host is
75//! mounted from its own `build`, which runs before its page builder and hence
76//! before the inner navigator wires at all.
77//! 2. **The one-cycle rule, for registrants never mounted.** A wire from an
78//! already-registered controller ends a window; an unmounted registrant that
79//! did not wire within it is dropped — the one case rule 1 cannot see (a
80//! `BackHandler` whose navigator view never made it into the tree).
81//!
82//! **Rule 2 must not be trusted for a mounted navigator**: it infers a build
83//! cycle from a repeat wire, and the double-wire-per-pass pattern above breaks
84//! that inference (the second wire of one pass looks exactly like the first of
85//! the next). Before rule 1, that inference pruned the root overlay host
86//! mid-pass and re-created the root-modal double-claim bug.
87//!
88//! **A [`BackFallback`] obeys the same two rules**, which is why it is
89//! view-scoped: its liveness is the mount count of its [`back_fallback`]
90//! wrappers (rule 1 — `build` increments it, `teardown` decrements it, as a
91//! navigator's own `build`/`teardown` do), and rule 2 removes it once every
92//! wrapper is unmounted. An unmounted fallback also stops claiming at once:
93//! its interest is gated on that same liveness, so it never claims a press in
94//! the window before rule 2 reaches it.
95//!
96//! **First-wire grace (a fallback only).** [`back_fallback`] wires during view
97//! *construction*, before its wrapper's `build` runs — unlike a navigator,
98//! whose `build` publishes liveness before anything nested wires. So in the
99//! very pass that first mounts a wrapper, a navigator double-wiring later in
100//! that pass would see an unbuilt, hence unmounted, fallback and prune it.
101//! Rule: **a fallback wired but not yet built, rebuilt or torn down counts as
102//! mounted until one of those happens** (each clears the grace; the next
103//! [`back_fallback`] call over that handle sets it again). A wrapper is always
104//! reconciled in the pass that constructed it, so the grace never outlives that
105//! pass in practice; the one residual is a [`back_fallback`] view constructed
106//! and then discarded unbuilt, which keeps its fallback live until a wrapper
107//! over the same handle is reconciled or the handle is dropped.
108//!
109//! ## Reachability is upstream of arbitration, by design (R23)
110//!
111//! Ranking decides who wins *among the claimants*, never who may claim. A
112//! navigator input cannot reach — one nested inside a page the outer navigator
113//! has covered — reports `back_interest() == false` at the source:
114//! `frust-widgets` gates it on the hosting page being in the host navigator's
115//! `input_routed_pages()` (rule **R23**: navigator reach follows input routing,
116//! exactly; see `frust_widgets::NavigatorController::back_interest`). So nothing
117//! here filters on visibility and there is **no second notion of reachability**
118//! to keep in sync with the widget's — load-bearing for innermost-first ranking,
119//! since an off-screen innermost navigator would otherwise outrank the one the
120//! user is actually looking at and swallow every press. Reach says *whether* a
121//! navigator is in the running, rank *which* of those gets it.
122//!
123//! # Timing: the live provider closes the stale window
124//!
125//! Wiring runs during a rebuild's *build* pass, **before** the navigator's
126//! `apply_ops` publishes the new stack depth, so the polled [`set_handles_back`]
127//! flag lags a stack change by one frame — and a frame gate skipping the settle
128//! frame lets that stale value persist. So the wiring also registers a live
129//! provider ([`set_can_pop_provider`]) reading `controller.back_interest()`,
130//! which `frust_reactive::handles_back` prefers to the flag: it reads CURRENT
131//! interest at press time, after `apply_ops` published it. UI-thread-affine (it
132//! reads an `Rc`-backed controller through [`SHARED`]); `frust_reactive::back`'s
133//! module docs carry the source-side contract.
134
135use std::cell::{Cell, RefCell};
136use std::rc::Rc;
137
138use frust_core::{BuildCtx, ChangeFlags, View};
139use frust_reactive::{CanPopRegistration, back_presses, set_can_pop_provider, set_handles_back};
140use frust_widgets::{NavigatorController, NavigatorId};
141use reactive_graph::traits::{Get, GetUntracked};
142
143thread_local! {
144 /// The UI-thread monotonic source of [`BackFallback`] ids
145 /// ([`RegistrantId::Fallback`]). Kept apart from [`SHARED`] so nothing that
146 /// resets the arbitration state can ever hand out an id twice.
147 static NEXT_FALLBACK_ID: Cell<u64> = const { Cell::new(0) };
148}
149
150thread_local! {
151 /// The process-wide (UI-thread-affine) back-press wiring shared by BOTH the
152 /// automatic [`navigator`](crate::navigator) path and the explicit
153 /// [`BackHandler`]. A single shared marker is the "single consumption
154 /// source" that makes an auto-wired `navigator()` and a manual
155 /// `BackHandler` on the same controller consume a press EXACTLY once — no
156 /// double-pop — and a single ordered registrant list is what arbitrates a
157 /// press across a root overlay host and the navigator inside it (R44-back;
158 /// see the module docs).
159 ///
160 /// UI-thread-affine because every [`Registrant`] holds an `Rc`-backed
161 /// `NavigatorController` or [`BackFallback`] (`!Send`), exactly like
162 /// frust-reactive's live provider slot it feeds.
163 static SHARED: RefCell<SharedBack> = const { RefCell::new(SharedBack::new()) };
164}
165
166/// What a registrant *is*, which is what R44-back ranks it by — taken from the
167/// facade entry point that wired it, never inferred from wire timing.
168///
169/// `Ord` is the arbitration order: [`Host`](Self::Host) sorts first,
170/// [`Fallback`](Self::Fallback) last.
171#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
172enum Role {
173 /// A root [`overlay_host`](crate::overlay_host). Its overlays are drawn over
174 /// every piece of chrome, including any inner navigator, so an open one owns
175 /// the back press outright — R44-back's host-outranks-navigator half.
176 Host,
177 /// Any other wired navigator: [`navigator`](crate::navigator)'s auto-wiring
178 /// or an explicit [`BackHandler`].
179 Navigator,
180 /// A [`BackFallback`] wired by [`back_fallback`]: not a navigator at all,
181 /// but an app rule ("back at a tab root returns to the home tab"). Declared
182 /// last so it sorts after every navigator **whatever the wire order** — a
183 /// fallback claims a press only when no navigator does. Its liveness is its
184 /// wrappers' mount count (rule 1), and rule 2 removes it once unmounted (see
185 /// the module docs' release rules).
186 Fallback,
187}
188
189/// Who a [`Registrant`] stands for — the identity a re-wire matches on to
190/// refresh an entry in place instead of appending a duplicate.
191#[derive(Clone, Copy, PartialEq, Eq, Debug)]
192enum RegistrantId {
193 /// A navigator, by its controller's id.
194 Navigator(NavigatorId),
195 /// A [`BackFallback`], by the id [`NEXT_FALLBACK_ID`] gave it.
196 Fallback(u64),
197}
198
199/// One wired navigator or fallback in [`SharedBack::registrants`] — the unit
200/// R44-back arbitrates over. A navigator's closures hold a controller clone, so
201/// a registrant keeps its navigator's op queue alive until it is pruned; a
202/// fallback's hold only a weak reference to its [`BackFallback`].
203struct Registrant {
204 /// Which controller (or fallback) this entry stands for, so a re-wire
205 /// refreshes it in place instead of appending a duplicate.
206 id: RegistrantId,
207 /// This entry's arbitration rank (see [`Role`]). Sticky-upgrades to
208 /// [`Role::Host`]: a controller wired *once* as an overlay host is one, even
209 /// on a re-wire that names it through a different entry point.
210 role: Role,
211 /// The wire sequence number of this entry's FIRST wire — the tiebreak within
212 /// a role, so two navigators of equal rank keep build order.
213 birth: u64,
214 /// The wire sequence number of this entry's most recent refresh; the
215 /// one-cycle prune compares against it (see [`refresh_interest`]).
216 seq: u64,
217 /// `controller.is_mounted()` — is this navigator in the retained tree right
218 /// now? For a fallback, its wrappers' mount count > 0 or the first-wire
219 /// grace (see the module docs). A `true` here vetoes the one-cycle prune
220 /// (see the module docs' release rules). Only ever reads `Cell`s, so it is
221 /// safe to call under the [`SHARED`] borrow.
222 mounted: Box<dyn Fn() -> bool>,
223 /// `controller.back_interest()` — does this navigator claim the next press?
224 /// Already `false` for a navigator whose hosting page input cannot reach
225 /// (the R23 gate in `frust-widgets`; see the module docs), which is why
226 /// nothing here filters on visibility. For a fallback, the app's interest
227 /// closure, gated on the fallback's liveness. An `Rc` so it is cloned out
228 /// and called with **no** [`SHARED`] borrow outstanding — a fallback's
229 /// probe runs app code.
230 interest: Rc<dyn Fn() -> bool>,
231 /// `controller.request_back()` — deliver the press to this navigator,
232 /// honoring its top page's `BackPolicy` (a fallback's `on_back`). An `Rc`
233 /// rather than a `Box` so [`route_back`] can clone it out and call it with
234 /// **no** [`SHARED`] borrow outstanding.
235 request_back: Rc<dyn Fn()>,
236}
237
238impl Registrant {
239 /// The sort key R44-back orders the arbitration list by: rank first
240 /// ([`Role`], `Host` sorts ahead of every `Navigator`, `Fallback` after
241 /// every one), then within `Role::Navigator` **innermost-first** (highest
242 /// `birth` — a nested navigator always wires after the navigator hosting
243 /// it, so the highest `birth` among `Navigator` peers is the innermost
244 /// one); `Role::Host` and `Role::Fallback` peers keep first-wire order
245 /// (`birth` ascending). A fallback's rank against a navigator never
246 /// depends on wire order: `Role` alone decides it.
247 ///
248 /// Innermost-first is what lets a nested navigator take the press at all:
249 /// ordering every rank by first wire (`birth` ascending) would leave an
250 /// outer navigator at depth > 1 — exactly the case where a pushed page hosts
251 /// a nested navigator — permanently outranking the nested navigator inside
252 /// it, so back would pop the outer stack (discarding the whole page the
253 /// nested navigator lives on) instead of reaching in. `Role::Host` peers
254 /// keep first-wire order: R44-back only ever requires Host to rank above
255 /// every Navigator, never a particular order among multiple hosts.
256 fn key(&self) -> (Role, u64) {
257 match self.role {
258 Role::Host | Role::Fallback => (self.role, self.birth),
259 // `u64::MAX - birth`: ascending sort on this puts the HIGHEST
260 // birth (innermost, wired last) first among `Navigator` peers —
261 // LIFO, mirroring Android's `OnBackPressedDispatcher`.
262 Role::Navigator => (self.role, u64::MAX - self.birth),
263 }
264 }
265}
266
267/// The shared back state behind [`SHARED`] (see its docs).
268struct SharedBack {
269 /// The last back-press count consumed by *any* entry point; `None` until
270 /// the first observation. A press up to this count has already been routed
271 /// through `request_back`, so a second entry point (or a re-run rebuild)
272 /// observing the same count does not re-fire (the `RouterDeepLinks`
273 /// consumed-marker pattern, shared across both back entry points).
274 consumed: Option<u64>,
275 /// Every wired navigator and fallback, sorted by [`Registrant::key`] —
276 /// [`Role::Host`] first (build order among hosts, unchanged), then
277 /// [`Role::Navigator`] peers innermost-first (LIFO: last-wired sorts first,
278 /// the reverse of build order), then [`Role::Fallback`] peers in build
279 /// order, after every navigator — the R44-back arbitration list. Kept
280 /// sorted on every wire, so a registrant's position never depends on how
281 /// often it wires per pass; an unmounted registrant that misses a build
282 /// cycle is pruned (see [`register`]), a fallback included once all its
283 /// wrappers are unmounted. The live provider (below) reads through this
284 /// list, so a press is decided against the CURRENT registrants' interest.
285 registrants: Vec<Registrant>,
286 /// Monotonic wire counter stamped into [`Registrant::birth`]/
287 /// [`Registrant::seq`]; ordering and the one-cycle prune are both expressed
288 /// against it.
289 seq: u64,
290 /// The live can-pop provider registration, made once on the first wire and
291 /// kept for the process lifetime. Its closure reads
292 /// [`registrants`](Self::registrants) live, so `handles_back()` answers
293 /// against the current navigators with no one-frame lag (see the module
294 /// docs' timing note). `None` until the first wire registers it.
295 provider: Option<CanPopRegistration>,
296}
297
298impl SharedBack {
299 const fn new() -> Self {
300 Self {
301 consumed: None,
302 registrants: Vec::new(),
303 seq: 0,
304 provider: None,
305 }
306 }
307}
308
309/// Whether **any** wired registrant claims the next back press — the value both
310/// the live provider and the polled `handles_back` flag publish.
311///
312/// The interest probes are CLONED OUT of the list and called with **no**
313/// [`SHARED`] borrow outstanding: a navigator's probe is a plain `Cell` read,
314/// but a [`BackFallback`]'s runs the app's interest closure, which must be free
315/// to read anything — this module included — without a `RefCell` panic.
316fn any_interest() -> bool {
317 interest_probes().iter().any(|interest| interest())
318}
319
320/// Every registrant's interest probe, in arbitration order, cloned out of
321/// [`SHARED`] so the caller can run them with no borrow outstanding.
322fn interest_probes() -> Vec<Rc<dyn Fn() -> bool>> {
323 SHARED.with(|shared| {
324 shared
325 .borrow()
326 .registrants
327 .iter()
328 .map(|r| Rc::clone(&r.interest))
329 .collect()
330 })
331}
332
333/// Register (or refresh) `controller` in the R44-back arbitration list at
334/// `role`'s rank — the controller-flavoured wrapper over [`register`]. Called on
335/// every wire from both navigator entry points
336/// ([`auto_wire`]/[`auto_wire_overlay_host`] and
337/// [`BackHandler::new`]/[`track`](BackHandler::track)).
338///
339/// # Panics
340///
341/// As [`register`].
342fn refresh_interest<State: 'static>(controller: &NavigatorController<State>, role: Role) {
343 let mounted_probe = controller.clone();
344 let interest_probe = controller.clone();
345 let request_probe = controller.clone();
346 register(
347 RegistrantId::Navigator(controller.id()),
348 role,
349 Box::new(move || mounted_probe.is_mounted()),
350 Rc::new(move || interest_probe.back_interest()),
351 Rc::new(move || request_probe.request_back()),
352 );
353}
354
355/// Register (or refresh) the registrant `id` in the R44-back arbitration list at
356/// `role`'s rank and republish the polled `handles_back` flag, registering the
357/// live provider once. The core every entry point funnels through — navigators
358/// via [`refresh_interest`], fallbacks via [`back_fallback`].
359///
360/// Ordering and pruning are the whole mechanism — see the module docs' R44-back
361/// section. In short: the entry is (re-)inserted at its ([`Role`], first-wire)
362/// sort position, so neither *when* nor *how often* a registrant wires can move
363/// it; and a repeat wire drops every **unmounted** registrant that did not wire
364/// since this registrant's previous wire, a mounted one being in the tree by
365/// definition.
366///
367/// # Panics
368///
369/// The first call registers the live provider via [`set_can_pop_provider`],
370/// which panics off the UI thread — always the case here (a rebuild / a
371/// `Component::init` runs on the UI thread).
372fn register(
373 id: RegistrantId,
374 role: Role,
375 mounted: Box<dyn Fn() -> bool>,
376 interest: Rc<dyn Fn() -> bool>,
377 request_back: Rc<dyn Fn()>,
378) {
379 SHARED.with(|shared| {
380 let mut shared = shared.borrow_mut();
381 shared.seq += 1;
382 let seq = shared.seq;
383 // Carry the existing entry's identity forward (rank sticky-upgrades to
384 // `Host`, first-wire order is preserved), and prune against its previous
385 // wire while it is still in the list.
386 let (role, birth) = match shared.registrants.iter().position(|r| r.id == id) {
387 Some(index) => {
388 let previous = shared.registrants[index].seq;
389 let carried = (
390 // `Host` sorts first, so `min` IS the sticky upgrade: a
391 // controller ever wired as an overlay host stays one. (A
392 // fallback id only ever wires as `Fallback`, so `min` is a
393 // no-op for it.)
394 shared.registrants[index].role.min(role),
395 shared.registrants[index].birth,
396 );
397 // A repeat wire closes a window. Any registrant that neither
398 // wired within it nor is currently mounted is no longer in the
399 // tree — drop it rather than let it keep claiming presses. The
400 // mounted probe only reads `Cell`s (a navigator's controller, a
401 // fallback's mount count and grace) and never re-enters this
402 // module, so calling it under the `SHARED` borrow is safe.
403 shared
404 .registrants
405 .retain(|r| r.id == id || r.seq >= previous || (r.mounted)());
406 carried
407 }
408 // First wire: this seq is the entry's permanent tiebreak.
409 None => (role, seq),
410 };
411 let entry = Registrant {
412 id,
413 role,
414 birth,
415 seq,
416 mounted,
417 interest,
418 request_back,
419 };
420 // Re-insert at the sort position, never "wherever it already was": that
421 // is what keeps arbitration order a function of what a registrant IS
422 // (its role and first wire) rather than of the wire sequence.
423 shared.registrants.retain(|r| r.id != id);
424 let at = shared
425 .registrants
426 .iter()
427 .position(|r| r.key() > entry.key())
428 .unwrap_or(shared.registrants.len());
429 shared.registrants.insert(at, entry);
430 if shared.provider.is_none() {
431 // Register the stable live provider ONCE. It reads whatever
432 // registrants are currently wired from `SHARED`, so a new
433 // registrant joining the list needs no re-registration.
434 shared.provider = Some(set_can_pop_provider(Box::new(any_interest)));
435 }
436 });
437 // Refresh the polled fallback flag (the live provider is authoritative; this
438 // only keeps the no-provider flag roughly in sync — see the module docs).
439 set_handles_back(any_interest());
440}
441
442/// Deliver a consumed back press under **R44-back**: to the first registrant in
443/// arbitration order (hosts first, then plain navigators innermost-first, then
444/// fallbacks) that claims it.
445///
446/// No visibility/reachability filter here on purpose: `back_interest()` is
447/// already gated on the claimant's hosting page being input-routed (see the
448/// module docs' R23 note), so a navigator the user cannot reach is never in this
449/// list's answer to begin with.
450///
451/// A press no registrant claims at this point is dropped: delivering it to the
452/// navigator whose wire consumed it would not be a no-op (that navigator may be
453/// deep yet unreachable, e.g. a hidden `IndexedStack` tab, or torn down). The
454/// shell already consumed the press via `handles_back`, so if every interest
455/// lapsed between that decision and delivery the press is swallowed, not
456/// returned to the platform.
457fn route_back() {
458 // Both the interest probes and the claimant's `request_back` are CLONED OUT
459 // of the list (that is what the `Rc`s are for) and called with NO `SHARED`
460 // borrow outstanding. A navigator's are a `Cell` read and an op enqueue, but
461 // a fallback's are app closures, and a re-entry into this module under a
462 // borrow would panic on the already-borrowed `RefCell`.
463 let claimant = SHARED
464 .with(|shared| {
465 shared
466 .borrow()
467 .registrants
468 .iter()
469 .map(|r| (Rc::clone(&r.interest), Rc::clone(&r.request_back)))
470 .collect::<Vec<_>>()
471 })
472 .into_iter()
473 .find(|(interest, _)| interest())
474 .map(|(_, request_back)| request_back);
475 if let Some(request_back) = claimant {
476 request_back();
477 }
478}
479
480/// Consume any new back press against the shared marker, reporting whether
481/// this call is the one that consumed it (and so must route it). The tracked
482/// read of the back-press counter subscribes THIS rebuild, so a later
483/// `push_back_press` wakes it (State & Reactivity: the track-per-rebuild
484/// contract).
485///
486/// The shared marker is the single consumption source (see the module docs): if
487/// several entry points run in the same rebuild, the first consumes the press
488/// and every later one no-ops on the already-consumed count.
489fn consume_press() -> bool {
490 // Tracked read — subscribes the rebuild to the back-press counter.
491 let count = back_presses().count.get();
492 SHARED.with(|shared| {
493 let mut shared = shared.borrow_mut();
494 match shared.consumed {
495 // A fresh process/marker: seed to the current count WITHOUT firing,
496 // so a press delivered before any handler existed does not trigger a
497 // spurious back on the first wire.
498 None => {
499 shared.consumed = Some(count);
500 false
501 }
502 // Already caught up — a re-run rebuild or the second entry point.
503 Some(prev) if prev == count => false,
504 // A new press (a burst collapses to one `request_back`: catch up to
505 // the current depth, don't replay each event — the back semantics).
506 Some(_) => {
507 shared.consumed = Some(count);
508 true
509 }
510 }
511 })
512}
513
514/// Consume any new back press ([`consume_press`]), routing it through
515/// [`NavigatorController::request_back`] (applies the top page's `BackPolicy`)
516/// on the registrant [`route_back`] arbitrates to.
517///
518/// Under R44-back the press goes to the first registrant claiming it; the
519/// wiring controller gets no special delivery, and an unclaimed press is
520/// dropped.
521fn consume_back() {
522 if consume_press() {
523 route_back();
524 }
525}
526
527/// Seed the shared consumption marker to the current back-press count if it is
528/// not seeded yet, so a press delivered before a [`BackHandler`] existed does
529/// not trigger a spurious `request_back` on the first [`track`](BackHandler::track).
530/// Idempotent (only-if-unseeded), so it composes with a `navigator()` that
531/// already seeded the marker on an earlier rebuild.
532fn seed_consumed() {
533 let count = back_presses().count.get_untracked();
534 SHARED.with(|shared| {
535 let mut shared = shared.borrow_mut();
536 if shared.consumed.is_none() {
537 shared.consumed = Some(count);
538 }
539 });
540}
541
542/// Auto-wire back handling for `controller`: the entry point the
543/// facade's [`navigator`](crate::navigator) wrapper calls on every rebuild.
544/// Consumes a new back press (routing it through `request_back`) and refreshes
545/// the `handles_back` interest — so an app using `frust::navigator` gets the
546/// full Android back contract (overlay dismiss → pop → app exit) with ZERO
547/// back-specific app code, and with no double-pop against a manual
548/// [`BackHandler`] on the same controller (see the module docs).
549///
550/// # Panics
551///
552/// Runs during a rebuild's build pass; the first call registers the live
553/// provider, which panics off the UI thread (always the UI thread here).
554pub(crate) fn auto_wire<State: 'static>(controller: &NavigatorController<State>) {
555 consume_back();
556 refresh_interest(controller, Role::Navigator);
557}
558
559/// [`auto_wire`] for a root [`overlay_host`](crate::overlay_host): identical,
560/// except the registrant takes [`Role::Host`] — the rank that puts an open
561/// root overlay ahead of the inner navigator whatever the wire sequence looks
562/// like (R44-back; see the module docs).
563///
564/// # Panics
565///
566/// As [`auto_wire`].
567pub(crate) fn auto_wire_overlay_host<State: 'static>(controller: &NavigatorController<State>) {
568 consume_back();
569 refresh_interest(controller, Role::Host);
570}
571
572/// A [`NavigatorController`] wired to the process-wide back-press source (see
573/// the module docs) — the **explicit** back-wiring surface predating the
574/// automatic [`navigator`](crate::navigator) auto-wiring.
575///
576/// App code using `frust::navigator` no longer needs this: back handling is
577/// automatic. It stays fully supported for apps that want an explicit handle
578/// (Huddle constructs one), and now shares the same single consumption source
579/// and `request_back`/`back_interest` routing as the automatic path — so
580/// constructing a `BackHandler` *and* calling `frust::navigator` on the same
581/// controller still consumes each press exactly once.
582///
583/// Construct once with [`attach_back_handler`] (or [`BackHandler::new`]) —
584/// typically from `Component::init`, storing the result in `Component::State` —
585/// then call [`track`](Self::track) from every `Component::build`.
586pub struct BackHandler<State: 'static> {
587 controller: NavigatorController<State>,
588}
589
590impl<State: 'static> BackHandler<State> {
591 /// Wire `controller` to the back-press source. Seeds the shared consumption
592 /// marker to the current back-press count (so a press delivered before this
593 /// handler existed does not trigger a spurious back on the first
594 /// [`track`](Self::track)), publishes the initial `handles_back` fallback
595 /// flag, and registers the live `back_interest` provider (see the module
596 /// docs' timing note).
597 ///
598 /// Call this once per controller (e.g. from `Component::init`) — from the UI
599 /// thread, since the provider slot is UI-thread-affine (it reads the
600 /// `Rc`-backed controller). A `Component::init` always runs on the UI thread.
601 ///
602 /// **Registration position does not depend on when you construct this**
603 /// (see the module docs' R44-back section): a handler built in
604 /// `Component::init` wires before any view exists, and a root
605 /// [`overlay_host`](crate::overlay_host) still outranks the navigator it
606 /// wraps — the host's rank comes from its own entry point, not from wire
607 /// order. Where you call [`track`](Self::track) from is likewise free.
608 pub fn new(controller: NavigatorController<State>) -> Self {
609 seed_consumed();
610 refresh_interest(&controller, Role::Navigator);
611 Self { controller }
612 }
613
614 /// Consume a new back press (routing it through
615 /// [`request_back`](NavigatorController::request_back)) and refresh the
616 /// framework's `handles_back` interest from the current stack. Call from
617 /// every `Component::build`.
618 ///
619 /// Shares the single consumption source with the automatic
620 /// [`navigator`](crate::navigator) path (see the module docs): a rebuild
621 /// re-run that observes the same already-consumed count neither re-fires nor
622 /// double-counts, and a `frust::navigator` on the same controller in the
623 /// same rebuild cannot double-pop.
624 pub fn track(&self) {
625 consume_back();
626 refresh_interest(&self.controller, Role::Navigator);
627 }
628
629 /// The wired controller — hand it to
630 /// [`navigator`](frust_widgets::navigator), or drive it directly.
631 pub fn controller(&self) -> &NavigatorController<State> {
632 &self.controller
633 }
634}
635
636/// Convenience constructor equivalent to [`BackHandler::new`] — see its docs and
637/// [`RouterDeepLinks`](crate::RouterDeepLinks)/[`router_with_deep_links`](crate::router_with_deep_links)
638/// for the mirrored API shape.
639///
640/// Note that back handling is **automatic** for any app using
641/// [`frust::navigator`](crate::navigator); this explicit surface is
642/// only needed when an app wants a handle it drives directly (e.g. Huddle).
643///
644/// ```no_run
645/// use frust::{
646/// BackHandler, Component, NavigatorController, View, attach_back_handler, navigator,
647/// text,
648/// };
649///
650/// #[derive(Default)]
651/// struct App;
652///
653/// struct AppState {
654/// back: BackHandler<AppState>,
655/// }
656///
657/// impl Component for App {
658/// type State = AppState;
659///
660/// fn init(&self) -> AppState {
661/// let nav: NavigatorController<AppState> = NavigatorController::new();
662/// AppState {
663/// back: attach_back_handler(nav),
664/// }
665/// }
666///
667/// fn build(&self, state: &mut AppState) -> impl View<AppState> {
668/// // Called every rebuild: consumes a new back press (via `request_back`)
669/// // and keeps the framework's handles-back interest in sync. The
670/// // `frust::navigator` call auto-wires the SAME controller — one press
671/// // still pops exactly once (shared consumption source).
672/// state.back.track();
673/// let controller = state.back.controller();
674/// navigator(controller, || text("home"))
675/// }
676/// }
677///
678/// frust::app!(App);
679/// # fn main() {}
680/// ```
681pub fn attach_back_handler<State: 'static>(
682 controller: NavigatorController<State>,
683) -> BackHandler<State> {
684 BackHandler::new(controller)
685}
686
687/// An app-level back rule that claims a press only when **no navigator does** —
688/// the Android/Material "back at a tab root returns to the home tab; back at the
689/// home tab root exits" convention for per-tab navigator stacks.
690///
691/// Not a navigator: it ranks after every navigator in the back arbitration
692/// (R44-back's fallback rank), whatever the wire order, so a selected tab
693/// that can still pop (or a root overlay) always takes the press first.
694///
695/// Construct once — in `Component::init` or app state — and wrap the subtree it
696/// belongs to with [`back_fallback`] from every `Component::build`. Cheaply
697/// [`Clone`] (`Rc`-backed); every clone is the same fallback.
698///
699/// **Liveness is view-scoped.** The fallback claims presses only while at least
700/// one [`back_fallback`] wrapper over it is mounted: leaving the screen unmounts
701/// the wrapper, the fallback stops claiming at once, and the arbitration list
702/// releases it (see the module docs' release rules). Merely holding the handle
703/// claims nothing.
704///
705/// The facade's re-export docs carry a back-to-home-tab example and the
706/// accepted residual (a wrapper inside a hidden branch still counts as live).
707#[derive(Clone)]
708pub struct BackFallback {
709 inner: Rc<FallbackInner>,
710}
711
712/// The shared state behind a [`BackFallback`] and all its clones.
713struct FallbackInner {
714 /// This fallback's arbitration identity ([`RegistrantId::Fallback`]).
715 id: u64,
716 /// How many [`BackFallbackView`] wrappers over this fallback are mounted
717 /// right now — `build` increments it, `teardown` decrements it. The
718 /// fallback's rule-1 liveness: `> 0` is "in the retained tree".
719 mounted: Cell<usize>,
720 /// The first-wire grace (see the module docs): set by every
721 /// [`back_fallback`] wire, cleared by any wrapper's `build`, `rebuild` or
722 /// `teardown`. While set, the fallback counts as mounted even with
723 /// `mounted == 0` — it was wired by a wrapper that has not been built yet.
724 awaiting_build: Cell<bool>,
725 /// The app's "do I claim the next press?" closure.
726 interest: Box<dyn Fn() -> bool>,
727 /// The app's back action, run when this fallback receives a press.
728 on_back: Box<dyn Fn()>,
729}
730
731impl FallbackInner {
732 /// Rule-1 liveness plus the first-wire grace: a mounted wrapper, or a wire
733 /// whose wrapper has not been built yet.
734 fn live(&self) -> bool {
735 self.mounted.get() > 0 || self.awaiting_build.get()
736 }
737
738 /// One wrapper reconciled (built, rebuilt or torn down): the grace ends.
739 fn reconciled(&self) {
740 self.awaiting_build.set(false);
741 }
742}
743
744impl BackFallback {
745 /// Create a fallback that claims a back press while `interest()` is true
746 /// (and a [`back_fallback`] wrapper over it is mounted), running `on_back`
747 /// for each press it receives.
748 ///
749 /// Call once — from `Component::init` or wherever app state is built, on
750 /// the UI thread. Seeds the shared consumption marker to the current
751 /// back-press count, like [`BackHandler::new`], so a press delivered before
752 /// this fallback existed does not fire it. Registers nothing yet: a
753 /// fallback joins the arbitration when [`back_fallback`] first wraps a view
754 /// with it.
755 ///
756 /// `interest` is polled whenever the shell asks whether the app handles
757 /// back, and before each press is routed, with no back-arbitration borrow
758 /// outstanding; keep it a cheap, untracked read (`get_untracked`,
759 /// [`NavigatorController::depth`]). `on_back` runs during a rebuild's build
760 /// pass, like a navigator's `request_back`.
761 pub fn new(interest: impl Fn() -> bool + 'static, on_back: impl Fn() + 'static) -> Self {
762 seed_consumed();
763 let id = NEXT_FALLBACK_ID.with(|next| {
764 let id = next.get();
765 next.set(id + 1);
766 id
767 });
768 Self {
769 inner: Rc::new(FallbackInner {
770 id,
771 mounted: Cell::new(0),
772 awaiting_build: Cell::new(false),
773 interest: Box::new(interest),
774 on_back: Box::new(on_back),
775 }),
776 }
777 }
778
779 /// Whether this fallback currently counts as live: a wrapper over it is
780 /// mounted, or one was just wired and is awaiting its first build.
781 #[cfg(test)]
782 fn is_live(&self) -> bool {
783 self.inner.live()
784 }
785
786 /// Register (or refresh) this fallback in the R44-back arbitration list at
787 /// [`Role::Fallback`], and open the first-wire grace (see the module docs).
788 /// The probes hold only a [`Weak`](std::rc::Weak) reference, so the
789 /// arbitration list never keeps the app's closures alive.
790 fn wire(&self) {
791 self.inner.awaiting_build.set(true);
792 let mounted_probe = Rc::downgrade(&self.inner);
793 let interest_probe = Rc::downgrade(&self.inner);
794 let request_probe = Rc::downgrade(&self.inner);
795 register(
796 RegistrantId::Fallback(self.inner.id),
797 Role::Fallback,
798 Box::new(move || mounted_probe.upgrade().is_some_and(|inner| inner.live())),
799 Rc::new(move || {
800 interest_probe
801 .upgrade()
802 .is_some_and(|inner| inner.live() && (inner.interest)())
803 }),
804 Rc::new(move || {
805 if let Some(inner) = request_probe.upgrade() {
806 (inner.on_back)();
807 }
808 }),
809 );
810 }
811}
812
813/// Wrap `child` so `fallback` takes part in back arbitration while this view is
814/// mounted — the view-scoped half of [`BackFallback`].
815///
816/// Call from every `Component::build`, exactly like
817/// [`navigator`](crate::navigator): at call time it consumes any new back press
818/// (the tracked read that subscribes the rebuild, routed under R44-back, so the
819/// fallback receives it only if no navigator claims it) and then registers or
820/// refreshes the fallback at the fallback rank. The returned
821/// [`BackFallbackView`] is a pass-through: it renders `child` unchanged and
822/// counts its own mounts, which is the fallback's liveness. It is a [`View`]
823/// for every `State` its child is one for, so the function itself names no
824/// `State` and a state-agnostic child (a `text`) needs no annotation.
825///
826/// The facade's re-export docs carry a back-to-home-tab example.
827///
828/// # Panics
829///
830/// Runs during a rebuild's build pass; the first wire registers the live
831/// `handles_back` provider, which panics off the UI thread (always the UI
832/// thread here).
833pub fn back_fallback<V>(fallback: &BackFallback, child: V) -> BackFallbackView<V> {
834 if consume_press() {
835 route_back();
836 }
837 fallback.wire();
838 BackFallbackView {
839 fallback: fallback.clone(),
840 child,
841 }
842}
843
844/// The pass-through view [`back_fallback`] returns: renders its child unchanged
845/// (`Element` is the child's own) and counts its own mounts into the
846/// [`BackFallback`]'s liveness — `build` increments it before the child builds,
847/// `teardown` decrements it after the child is torn down.
848pub struct BackFallbackView<V> {
849 fallback: BackFallback,
850 child: V,
851}
852
853impl<State: 'static, V: View<State>> View<State> for BackFallbackView<V> {
854 type Element = V::Element;
855
856 fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
857 // Publish liveness FIRST, like a navigator's `build`: anything wiring
858 // from inside the child already sees this fallback as mounted.
859 let inner = &self.fallback.inner;
860 inner.mounted.set(inner.mounted.get() + 1);
861 inner.reconciled();
862 self.child.build(ctx)
863 }
864
865 fn rebuild(
866 &self,
867 prev: &Self,
868 element: &mut Self::Element,
869 ctx: &mut BuildCtx<'_>,
870 ) -> ChangeFlags {
871 let (next, old) = (&self.fallback.inner, &prev.fallback.inner);
872 if !Rc::ptr_eq(next, old) {
873 // The slot now wraps a DIFFERENT fallback: move this mount from the
874 // old handle to the new one, so `teardown` (which runs through the
875 // latest view) decrements the count it pairs with.
876 next.mounted.set(next.mounted.get() + 1);
877 old.mounted.set(old.mounted.get().saturating_sub(1));
878 old.reconciled();
879 }
880 next.reconciled();
881 self.child.rebuild(&prev.child, element, ctx)
882 }
883
884 fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
885 self.child.teardown(element, ctx);
886 let inner = &self.fallback.inner;
887 inner.mounted.set(inner.mounted.get().saturating_sub(1));
888 inner.reconciled();
889 }
890
891 fn message(&self, element: &mut Self::Element, state: &mut State) {
892 self.child.message(element, state);
893 }
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899 use crate::motion::patterns::SharedAxis;
900 use crate::motion::switcher::PatternSwitcherView;
901 use frust_core::{AnyView, RenderRoot, any};
902 use frust_reactive::{
903 ReactiveRuntime, clear_can_pop_provider, handles_back, push_back_press, set_handles_back,
904 };
905 use frust_widgets::{
906 BackPolicy, NavigatorController, NavigatorView, PushOptions, navigator as raw_navigator,
907 overlay_host as raw_overlay_host,
908 };
909 use std::sync::{Arc, Mutex};
910
911 /// Serializes every test in this module: they all touch `frust-reactive`'s
912 /// process-wide back-press counter / `handles_back` flag / live-provider slot
913 /// AND this module's `SHARED` marker, so concurrent runs would race the
914 /// shared counter (one test's `push_back_press` advancing it under another's
915 /// feet). `reset()` (below) additionally clears the per-thread state a reused
916 /// harness thread would otherwise carry between tests.
917 static TEST_LOCK: Mutex<()> = Mutex::new(());
918
919 /// Reset the per-thread wiring so each test starts clean: force-clear
920 /// frust-reactive's live provider, drop the shared marker/interest/provider,
921 /// and reset the polled flag. Call at the top of every test (after taking
922 /// [`TEST_LOCK`], under an initialized runtime).
923 fn reset() {
924 clear_can_pop_provider();
925 set_handles_back(false);
926 SHARED.with(|shared| *shared.borrow_mut() = SharedBack::new());
927 }
928
929 fn page() -> AnyView<()> {
930 any(frust_widgets::text("x"))
931 }
932
933 /// The build closure type [`RenderRoot::rebuild`] drives, boxed so
934 /// [`Harness`] can store it (mirroring `router_glue`'s `AppLogic`).
935 type AppLogic = Box<dyn FnMut(&mut ()) -> NavigatorView<()>>;
936
937 /// A `RenderRoot`/app closure driving a controller's `navigator`. The
938 /// `wire` closure runs each rebuild BEFORE reconciliation to exercise a back
939 /// entry point (the automatic `auto_wire`, an explicit `BackHandler::track`,
940 /// or both) exactly as a `Component::build` would.
941 struct Harness {
942 controller: NavigatorController<()>,
943 root: RenderRoot<(), NavigatorView<()>>,
944 app: AppLogic,
945 wire: Box<dyn FnMut()>,
946 }
947
948 impl Harness {
949 /// A fresh-controller harness whose per-rebuild wiring runs `wire`
950 /// (given a clone of that controller) — the seam each test uses to pick
951 /// its back entry point.
952 fn new(wire: impl FnMut(&NavigatorController<()>) + 'static) -> Self {
953 Self::with_controller(NavigatorController::new(), wire)
954 }
955
956 /// As [`new`](Self::new) but drives an EXISTING `controller` — so a test
957 /// can share one controller between a `BackHandler` and the navigator
958 /// (the manual + auto criterion).
959 fn with_controller(
960 controller: NavigatorController<()>,
961 mut wire: impl FnMut(&NavigatorController<()>) + 'static,
962 ) -> Self {
963 let app_controller = controller.clone();
964 let app: AppLogic = Box::new(move |_: &mut ()| raw_navigator(&app_controller, page));
965 let wire_controller = controller.clone();
966 let wire: Box<dyn FnMut()> = Box::new(move || wire(&wire_controller));
967 let mut harness = Harness {
968 controller,
969 root: RenderRoot::new(),
970 app,
971 wire,
972 };
973 harness.rebuild();
974 harness
975 }
976
977 fn rebuild(&mut self) {
978 // Wire under the build pass, exactly as a Component::build would,
979 // then reconcile (which applies the navigator's queued ops).
980 (self.wire)();
981 self.root.rebuild(&mut self.app, &mut ());
982 }
983
984 fn depth(&self) -> usize {
985 self.controller.depth()
986 }
987 }
988
989 /// Criterion 1: an app using the AUTOMATIC `frust::navigator` auto-wiring
990 /// (no `BackHandler` in app code) pops one page on a back press. Also
991 /// covers criterion 2 (depth 1, no overlay → `handles_back()` false: the
992 /// press falls through to the platform).
993 #[test]
994 fn auto_wire_pops_without_a_backhandler() {
995 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
996 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
997 reset();
998
999 let mut h = Harness::new(auto_wire);
1000
1001 // Criterion 2: at the root (depth 1, no overlay) the framework does NOT
1002 // handle back — a root-level press bubbles to the platform.
1003 assert_eq!(h.depth(), 1, "starts at the root page");
1004 assert!(!handles_back(), "depth 1 + no overlay: handles_back false");
1005
1006 // Push to depth 2 (a normal opaque push → BackPolicy::Pop).
1007 h.controller.push(page);
1008 h.rebuild();
1009 assert_eq!(h.depth(), 2, "pushed to depth 2");
1010 assert!(
1011 handles_back(),
1012 "poppable stack: handles_back true, same rebuild"
1013 );
1014
1015 // Criterion 1: one back press pops one page — with NO BackHandler.
1016 push_back_press();
1017 h.rebuild();
1018 assert_eq!(h.depth(), 1, "auto-wired back press pops one page");
1019 assert!(
1020 !handles_back(),
1021 "back at the root: handles_back false again"
1022 );
1023 }
1024
1025 /// Criterion 3: a `Veto` overlay claims the back press (`handles_back()`
1026 /// true — predictive-back parity via `back_interest`), the press is
1027 /// consumed, and the stack is UNCHANGED — the routing-through-`request_back`
1028 /// (not a bare `pop`) contract. This is the meaningful facade-reachable
1029 /// form of "depth 1 + Veto overlay": the overlay sits over the root, and
1030 /// even though a raw `pop()` *would* remove it (the stack is poppable),
1031 /// `request_back` honors the Veto policy and pops nothing. (A Veto policy on
1032 /// the depth-1 *root* itself is not expressible through the public push API
1033 /// — the root is always `BackPolicy::Pop` — and is covered by a
1034 /// widget-level `compute_back_interest` test.)
1035 #[test]
1036 fn veto_overlay_claims_back_but_does_not_pop() {
1037 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1038 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1039 reset();
1040
1041 let mut h = Harness::new(auto_wire);
1042
1043 // Push a transparent Veto overlay over the root.
1044 h.controller
1045 .push_with_options(page, PushOptions::transparent().back(BackPolicy::Veto));
1046 h.rebuild();
1047
1048 // The overlay claims back ahead-of-time (facade reads `back_interest`).
1049 assert!(handles_back(), "a Veto overlay claims the back press");
1050
1051 let before = h.depth();
1052 // A back press is consumed but Veto swallows it — the stack is unchanged.
1053 // A bare `pop()` here would have removed the overlay (regression guard
1054 // for the request_back-not-pop switch).
1055 push_back_press();
1056 h.rebuild();
1057 assert_eq!(
1058 h.depth(),
1059 before,
1060 "Veto consumes the press via request_back: stack unchanged (not popped)"
1061 );
1062 assert!(handles_back(), "still claiming back (overlay still on top)");
1063 }
1064
1065 /// The EXPLICIT `BackHandler` path still works on its own (regression for
1066 /// the original surface, now routing through `request_back`): a press
1067 /// pops one page and `handles_back()` tracks the depth with no settle frame
1068 /// (the live provider closes the stale-window).
1069 #[test]
1070 fn explicit_backhandler_pops_and_tracks_handles_back() {
1071 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1072 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1073 reset();
1074
1075 let controller: NavigatorController<()> = NavigatorController::new();
1076 let back = BackHandler::new(controller.clone());
1077 // Only the explicit handler drives back here — on the SAME controller the
1078 // harness's navigator uses (a raw navigator view, no auto-wiring).
1079 let mut h = Harness::with_controller(controller, move |_c| back.track());
1080
1081 assert_eq!(h.depth(), 1, "starts at the root");
1082 assert!(
1083 !handles_back(),
1084 "root-level back bubbles: handles_back false"
1085 );
1086
1087 h.controller.push(page);
1088 h.rebuild();
1089 assert_eq!(h.depth(), 2, "push published to depth immediately");
1090 assert!(
1091 handles_back(),
1092 "poppable stack reports handles_back true on the SAME rebuild, no settle frame"
1093 );
1094
1095 push_back_press();
1096 h.rebuild();
1097 assert_eq!(h.depth(), 1, "one back press pops one page");
1098 assert!(
1099 !handles_back(),
1100 "root again: handles_back false immediately"
1101 );
1102
1103 // A back press at the root is a safe no-op (consumed, pops nothing).
1104 push_back_press();
1105 h.rebuild();
1106 assert_eq!(h.depth(), 1, "pop-at-root is a no-op");
1107
1108 // After a force-clear the polled fallback flag answers (depth-1 false).
1109 clear_can_pop_provider();
1110 assert!(
1111 !handles_back(),
1112 "after clear, handles_back reads the polled fallback"
1113 );
1114 }
1115
1116 /// Criterion 4 (regression): a manual `BackHandler` AND the automatic
1117 /// `navigator()` auto-wiring on the SAME controller consume one press
1118 /// exactly once — no double-pop. Both run every rebuild (as Huddle does:
1119 /// `state.back.track()` then `frust::navigator(&controller, ...)`).
1120 #[test]
1121 fn manual_backhandler_plus_auto_wire_pops_once() {
1122 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1123 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1124 reset();
1125
1126 // Construct the manual handler first (as an app would, in init).
1127 let controller: NavigatorController<()> = NavigatorController::new();
1128 let back = BackHandler::new(controller.clone());
1129
1130 // The harness's navigator AND both back entry points drive the SAME
1131 // controller. Each rebuild runs BOTH entry points in Huddle's order: the
1132 // manual `track()` then the auto-wiring (`c` is the harness controller).
1133 let mut h = Harness::with_controller(controller, move |c| {
1134 back.track();
1135 auto_wire(c);
1136 });
1137
1138 // Push to depth 3 so a single-vs-double pop is unambiguous.
1139 h.controller.push(page);
1140 h.rebuild();
1141 h.controller.push(page);
1142 h.rebuild();
1143 assert_eq!(h.depth(), 3, "pushed to depth 3");
1144
1145 // ONE back press: with a shared consumption source this pops exactly one
1146 // page (depth 2). A double-consumption bug would pop two (depth 1).
1147 push_back_press();
1148 h.rebuild();
1149 assert_eq!(
1150 h.depth(),
1151 2,
1152 "manual + auto share one consumption source: one press pops once"
1153 );
1154
1155 // A rebuild with no new press does not re-pop (dedupe).
1156 h.rebuild();
1157 assert_eq!(h.depth(), 2, "no new press -> no extra pop");
1158 }
1159
1160 // -----------------------------------------------------------------------
1161 // R44-back — arbitration across a root overlay host and an inner navigator.
1162 // -----------------------------------------------------------------------
1163
1164 /// A root `overlay_host` wrapping an inner `navigator`, each auto-wired
1165 /// where its view is constructed: the host from the app's build pass, the
1166 /// inner navigator from the host's root-page builder — which runs inside
1167 /// the host's own reconcile, i.e. strictly later. That *is* the build order
1168 /// R44-back arbitrates on, so the harness reproduces the real ordering
1169 /// rather than hand-declaring it.
1170 struct HostHarness {
1171 host: NavigatorController<()>,
1172 inner: NavigatorController<()>,
1173 root: RenderRoot<(), NavigatorView<()>>,
1174 app: AppLogic,
1175 }
1176
1177 impl HostHarness {
1178 fn new() -> Self {
1179 let host: NavigatorController<()> = NavigatorController::new();
1180 let inner: NavigatorController<()> = NavigatorController::new();
1181 let app: AppLogic = {
1182 let host = host.clone();
1183 let inner = inner.clone();
1184 Box::new(move |_: &mut ()| {
1185 // Outermost: exactly what `frust::overlay_host` does.
1186 auto_wire_overlay_host(&host);
1187 let inner = inner.clone();
1188 raw_overlay_host(&host, move || {
1189 // Innermost: exactly what `frust::navigator` does.
1190 auto_wire(&inner);
1191 any(raw_navigator(&inner, page))
1192 })
1193 })
1194 };
1195 let mut harness = Self {
1196 host,
1197 inner,
1198 root: RenderRoot::new(),
1199 app,
1200 };
1201 harness.rebuild();
1202 harness
1203 }
1204
1205 fn rebuild(&mut self) {
1206 self.root.rebuild(&mut self.app, &mut ());
1207 }
1208 }
1209
1210 /// The R44-back arbitration list, in order — the whitebox view the ordering
1211 /// and pruning rules are pinned against.
1212 fn registrant_ids() -> Vec<frust_widgets::NavigatorId> {
1213 registrants()
1214 .into_iter()
1215 .filter_map(|id| match id {
1216 RegistrantId::Navigator(id) => Some(id),
1217 RegistrantId::Fallback(_) => None,
1218 })
1219 .collect()
1220 }
1221
1222 /// The whole arbitration list, fallbacks included, in order.
1223 fn registrants() -> Vec<RegistrantId> {
1224 SHARED.with(|shared| shared.borrow().registrants.iter().map(|r| r.id).collect())
1225 }
1226
1227 /// **R44-back**: with a root overlay open, the press goes to the HOST even
1228 /// though the inner navigator is poppable and wired *later*. Without the
1229 /// rank the last wire would win outright and this press would pop the inner
1230 /// navigator — a page vanishing under an open modal.
1231 #[test]
1232 fn a_root_overlay_takes_the_back_press_from_the_inner_navigator() {
1233 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1234 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1235 reset();
1236
1237 let mut h = HostHarness::new();
1238 // Registration order is build order: the host first, then the navigator
1239 // its root page builds.
1240 assert_eq!(
1241 registrant_ids(),
1242 vec![h.host.id(), h.inner.id()],
1243 "outermost first"
1244 );
1245
1246 // Both stacks are poppable: the inner navigator has a page pushed, and
1247 // an overlay sits on the host.
1248 h.inner.push(page);
1249 h.rebuild();
1250 h.host
1251 .push_with_options(page, PushOptions::transparent().back(BackPolicy::Pop));
1252 h.rebuild();
1253 assert_eq!((h.host.depth(), h.inner.depth()), (2, 2));
1254 assert!(handles_back(), "some registrant claims the press");
1255
1256 push_back_press();
1257 h.rebuild();
1258 assert_eq!(h.host.depth(), 1, "the press went to the root overlay host");
1259 assert_eq!(
1260 h.inner.depth(),
1261 2,
1262 "and NOT to the inner navigator (the root-modal back bug)"
1263 );
1264 }
1265
1266 /// **The degenerate case, bit for bit.** A host with no overlays sits at
1267 /// depth 1 with the default `BackPolicy::Pop`, so `compute_back_interest`
1268 /// is false and it claims nothing — the press falls straight through to the
1269 /// inner navigator, exactly as before the host existed. This is the
1270 /// single-navigator (huddle) regression guard.
1271 #[test]
1272 fn a_host_with_no_overlay_defers_the_back_press_to_the_inner_navigator() {
1273 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1274 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1275 reset();
1276
1277 let mut h = HostHarness::new();
1278 assert!(
1279 !handles_back(),
1280 "two navigators, both at the root: back bubbles to the platform"
1281 );
1282
1283 h.inner.push(page);
1284 h.rebuild();
1285 assert_eq!(h.host.depth(), 1, "the host stays empty");
1286 assert!(
1287 handles_back(),
1288 "the inner navigator's interest answers for the whole app"
1289 );
1290
1291 push_back_press();
1292 h.rebuild();
1293 assert_eq!(h.inner.depth(), 1, "the press popped the inner navigator");
1294 assert_eq!(h.host.depth(), 1, "the empty host was untouched");
1295 assert!(!handles_back(), "back at the root of both: bubbles again");
1296
1297 // A press with nothing to do stays a safe no-op (nobody claims it, so it
1298 // is dropped).
1299 push_back_press();
1300 h.rebuild();
1301 assert_eq!((h.host.depth(), h.inner.depth()), (1, 1));
1302 }
1303
1304 /// The predictive-back half of R44-back: an overlay on the host claims the
1305 /// press even when the inner navigator has nothing to pop. `handles_back`
1306 /// answers `any(interest)`, not "the last navigator wired" — under the
1307 /// latter the shell would be told nobody handles back and would *exit the
1308 /// app* with a root modal open.
1309 #[test]
1310 fn a_root_overlay_claims_back_even_with_the_inner_navigator_at_its_root() {
1311 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1312 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1313 reset();
1314
1315 let mut h = HostHarness::new();
1316 h.host
1317 .push_with_options(page, PushOptions::transparent().back(BackPolicy::Pop));
1318 h.rebuild();
1319 assert_eq!((h.host.depth(), h.inner.depth()), (2, 1));
1320 assert!(
1321 handles_back(),
1322 "the root overlay claims the press though the inner navigator cannot pop"
1323 );
1324
1325 push_back_press();
1326 h.rebuild();
1327 assert_eq!(h.host.depth(), 1, "the press dismissed the root overlay");
1328 assert!(!handles_back(), "nothing left to claim it");
1329 }
1330
1331 /// The one-cycle prune: a navigator that stops wiring (its screen torn down)
1332 /// is dropped from the arbitration list rather than claiming presses
1333 /// forever from a stack nothing renders. These controllers are never mounted
1334 /// (no widget builds them), so the mounted veto never applies and this is
1335 /// the pure one-cycle path.
1336 ///
1337 /// All three controllers here register as plain `Role::Navigator` peers —
1338 /// despite the `outer`/`inner`/`nested` names, nothing wires one *inside*
1339 /// another — so this test pins only the birth tiebreak among equal-rank
1340 /// registrants (innermost/last-wire first), not `Role::Host`-first ranking
1341 /// (that is
1342 /// `an_init_registered_backhandler_does_not_outrank_a_later_overlay_host`).
1343 /// The expected order below is therefore the REVERSE of build order, by
1344 /// design.
1345 #[test]
1346 fn a_navigator_that_stops_wiring_is_pruned_after_one_full_cycle() {
1347 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1348 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1349 reset();
1350
1351 let outer: NavigatorController<()> = NavigatorController::new();
1352 let inner: NavigatorController<()> = NavigatorController::new();
1353 let nested: NavigatorController<()> = NavigatorController::new();
1354
1355 // Frame 1: three navigators wire, in build order.
1356 refresh_interest(&outer, Role::Navigator);
1357 refresh_interest(&inner, Role::Navigator);
1358 refresh_interest(&nested, Role::Navigator);
1359 assert_eq!(
1360 registrant_ids(),
1361 vec![nested.id(), inner.id(), outer.id()],
1362 "innermost/last-wired first: the reverse of build order"
1363 );
1364
1365 // Frame 2: the nested navigator's screen is gone — it never wires again.
1366 refresh_interest(&outer, Role::Navigator);
1367 refresh_interest(&inner, Role::Navigator);
1368 assert_eq!(
1369 registrant_ids(),
1370 vec![nested.id(), inner.id(), outer.id()],
1371 "not yet: a full cycle has not elapsed without it"
1372 );
1373
1374 // Frame 3: the outer wire now spans a whole cycle in which `nested`
1375 // never appeared.
1376 refresh_interest(&outer, Role::Navigator);
1377 assert_eq!(
1378 registrant_ids(),
1379 vec![inner.id(), outer.id()],
1380 "the departed navigator is pruned, and its controller clone released"
1381 );
1382
1383 // Re-wiring an existing registrant never re-orders it.
1384 refresh_interest(&inner, Role::Navigator);
1385 refresh_interest(&outer, Role::Navigator);
1386 refresh_interest(&inner, Role::Navigator);
1387 assert_eq!(registrant_ids(), vec![inner.id(), outer.id()]);
1388 }
1389
1390 // -----------------------------------------------------------------------
1391 // The two ways a "a repeat wire proves a build cycle" premise breaks — a
1392 // controller wiring TWICE per pass, and a `BackHandler` wiring from
1393 // `Component::init` before any view exists.
1394 // -----------------------------------------------------------------------
1395
1396 /// The REAL Huddle shape under a root overlay host: the inner navigator is
1397 /// wired **twice in one build pass** — an explicit `BackHandler` built in
1398 /// `Component::init` and `track()`ed from the host's page builder, plus the
1399 /// automatic `navigator()` wiring right beside it
1400 /// (`examples/huddle/src/lib.rs` does exactly this pair).
1401 ///
1402 /// Without the mounted veto, the second wire's one-cycle prune reads the
1403 /// FIRST wire of the same pass as a cycle boundary and drops the host, which
1404 /// then re-appends *after* the inner navigator — permanently inverting
1405 /// arbitration and re-creating the root-modal back bug (a press with a root
1406 /// modal open popping the page underneath it).
1407 #[test]
1408 fn an_overlay_host_survives_an_inner_navigator_wiring_twice_per_pass() {
1409 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1410 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1411 reset();
1412
1413 let host: NavigatorController<()> = NavigatorController::new();
1414 let inner: NavigatorController<()> = NavigatorController::new();
1415 // `Component::init`: the explicit handler exists before any view does.
1416 let back = Rc::new(BackHandler::new(inner.clone()));
1417
1418 let mut app: AppLogic = {
1419 let host = host.clone();
1420 let inner = inner.clone();
1421 Box::new(move |_: &mut ()| {
1422 // The app's build pass: `frust::overlay_host`.
1423 auto_wire_overlay_host(&host);
1424 let inner = inner.clone();
1425 let back = Rc::clone(&back);
1426 raw_overlay_host(&host, move || {
1427 // The host's page builder — Huddle's own build body, moved
1428 // inside the host: `state.back.track()` then
1429 // `frust::navigator(&controller, …)`, one pass, one
1430 // controller, TWO wires.
1431 back.track();
1432 auto_wire(&inner);
1433 any(raw_navigator(&inner, page))
1434 })
1435 })
1436 };
1437 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1438 root.rebuild(&mut app, &mut ());
1439
1440 assert_eq!(
1441 registrant_ids(),
1442 vec![host.id(), inner.id()],
1443 "the host outranks the inner navigator despite wiring once to its twice"
1444 );
1445
1446 // An overlay over the whole app, and a poppable inner stack under it.
1447 inner.push(page);
1448 host.push_with_options(page, PushOptions::transparent().back(BackPolicy::Pop));
1449 // Two more passes: the ordering must survive repeated double-wiring.
1450 for _ in 0..2 {
1451 root.rebuild(&mut app, &mut ());
1452 }
1453 assert_eq!((host.depth(), inner.depth()), (2, 2));
1454 assert_eq!(
1455 registrant_ids(),
1456 vec![host.id(), inner.id()],
1457 "still outermost-first after repeated passes"
1458 );
1459 assert!(
1460 handles_back(),
1461 "the shell is told the app handles back (a root modal is open)"
1462 );
1463
1464 push_back_press();
1465 root.rebuild(&mut app, &mut ());
1466 assert_eq!(host.depth(), 1, "the press dismissed the root overlay");
1467 assert_eq!(
1468 inner.depth(),
1469 2,
1470 "and NOT the page under it (the root-modal back bug)"
1471 );
1472 }
1473
1474 /// The second ordering break: `BackHandler::new` wires from
1475 /// `Component::init`, which runs before ANY view is constructed — so a
1476 /// first-wire-order rule alone would give the inner navigator position 0
1477 /// from frame 1, and no amount of "track from inside the host's page
1478 /// builder" advice could fix a registration that already happened. Rank
1479 /// comes from the entry point instead, so the host still sorts first.
1480 #[test]
1481 fn an_init_registered_backhandler_does_not_outrank_a_later_overlay_host() {
1482 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1483 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1484 reset();
1485
1486 let host: NavigatorController<()> = NavigatorController::new();
1487 let inner: NavigatorController<()> = NavigatorController::new();
1488
1489 // `Component::init` — the inner navigator's handler registers FIRST.
1490 let back = BackHandler::new(inner.clone());
1491 assert_eq!(registrant_ids(), vec![inner.id()], "init wired it alone");
1492
1493 // The first `Component::build`: the host wires only now.
1494 auto_wire_overlay_host(&host);
1495 assert_eq!(
1496 registrant_ids(),
1497 vec![host.id(), inner.id()],
1498 "the host claims the outermost slot even though it wired second"
1499 );
1500
1501 // And a repeat pass keeps it there.
1502 auto_wire_overlay_host(&host);
1503 back.track();
1504 assert_eq!(registrant_ids(), vec![host.id(), inner.id()]);
1505 }
1506
1507 /// The mounted veto's other half: it only ever *delays* release. A
1508 /// navigator whose widget is torn down (its page rebuilt without it) reports
1509 /// unmounted, so the one-cycle rule drops it and releases the controller
1510 /// clone the registrant holds.
1511 ///
1512 /// `nested` genuinely lives inside `outer`'s own page here, so it wires
1513 /// after `outer` and — under innermost-first `Role::Navigator` order — sorts
1514 /// AHEAD of it; that ordering is incidental to this test's own point
1515 /// (mount-liveness pruning).
1516 #[test]
1517 fn a_torn_down_navigator_stops_vetoing_the_prune_and_is_released() {
1518 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1519 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1520 reset();
1521
1522 let outer: NavigatorController<()> = NavigatorController::new();
1523 let nested: NavigatorController<()> = NavigatorController::new();
1524 // The nested navigator lives inside the outer navigator's root page,
1525 // until this flag turns it off.
1526 let show_nested = Rc::new(std::cell::Cell::new(true));
1527 // The arbitration list as seen MID-PASS, at the moment the outer
1528 // navigator's page rebuilds — i.e. inside the window a spurious prune
1529 // opens, when the shell may read `handles_back` or a press may route.
1530 let mid_pass: Rc<RefCell<Vec<NavigatorId>>> = Rc::new(RefCell::new(Vec::new()));
1531
1532 let mut app: AppLogic = {
1533 let outer = outer.clone();
1534 let nested = nested.clone();
1535 let show_nested = Rc::clone(&show_nested);
1536 let mid_pass = Rc::clone(&mid_pass);
1537 Box::new(move |_: &mut ()| {
1538 // The outer navigator wires TWICE per pass (Huddle's
1539 // `track()` + `navigator()` pair), so the one-cycle rule alone
1540 // would prune the nested navigator on every second wire — the
1541 // mounted veto is the only reason it survives below.
1542 auto_wire(&outer);
1543 auto_wire(&outer);
1544 let nested = nested.clone();
1545 let show_nested = Rc::clone(&show_nested);
1546 let mid_pass = Rc::clone(&mid_pass);
1547 raw_navigator(&outer, move || {
1548 *mid_pass.borrow_mut() = registrant_ids();
1549 if show_nested.get() {
1550 auto_wire(&nested);
1551 any(raw_navigator(&nested, page))
1552 } else {
1553 page()
1554 }
1555 })
1556 })
1557 };
1558 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1559 root.rebuild(&mut app, &mut ());
1560 assert!(nested.is_mounted(), "the nested navigator is in the tree");
1561 assert_eq!(registrant_ids(), vec![nested.id(), outer.id()]);
1562
1563 // While mounted it survives the outer navigator's double wire — the
1564 // exact sequence the one-cycle rule alone misreads as a build cycle.
1565 root.rebuild(&mut app, &mut ());
1566 root.rebuild(&mut app, &mut ());
1567 assert_eq!(
1568 registrant_ids(),
1569 vec![nested.id(), outer.id()],
1570 "a mounted navigator is in the tree by definition: never pruned"
1571 );
1572 assert_eq!(
1573 *mid_pass.borrow(),
1574 vec![nested.id(), outer.id()],
1575 "and it is never MISSING mid-pass either — the window in which a \
1576 spurious prune would mis-answer handles_back / mis-route a press"
1577 );
1578
1579 // Its page rebuilds without it: the widget tears down.
1580 show_nested.set(false);
1581 root.rebuild(&mut app, &mut ());
1582 assert!(!nested.is_mounted(), "teardown dropped the liveness");
1583
1584 // Now the one-cycle rule reaches it (the outer wire that spans a whole
1585 // cycle in which the nested navigator never appeared).
1586 root.rebuild(&mut app, &mut ());
1587 root.rebuild(&mut app, &mut ());
1588 assert_eq!(
1589 registrant_ids(),
1590 vec![outer.id()],
1591 "the departed navigator is released, not kept alive by the veto"
1592 );
1593 }
1594
1595 // -----------------------------------------------------------------------
1596 // Innermost-first: a nested navigator must receive the back press.
1597 // -----------------------------------------------------------------------
1598
1599 /// An outer navigator PUSHES a page that hosts a nested navigator (the outer
1600 /// navigator is at depth > 1 — exactly what makes `compute_back_interest`
1601 /// true for it too), and the nested navigator's own stack is also poppable.
1602 /// A back press must reach the INNERMOST (nested) navigator, popping its
1603 /// stack, and must leave the outer navigator's depth untouched — the reverse
1604 /// of what plain build-order (outermost-first) arbitration gives, where the
1605 /// outer navigator, wired first, claims the press and pops the whole page
1606 /// hosting the nested navigator instead of reaching into it.
1607 #[test]
1608 fn a_nested_navigator_inside_a_pushed_page_gets_the_back_press() {
1609 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1610 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1611 reset();
1612
1613 let outer: NavigatorController<()> = NavigatorController::new();
1614 let inner: NavigatorController<()> = NavigatorController::new();
1615
1616 // The outer navigator's ROOT page is plain — no nested navigator
1617 // exists at depth 1, so `compute_back_interest` starts false for it
1618 // (matching the module docs: the bug only appears once the outer
1619 // navigator itself is poppable).
1620 let mut app: AppLogic = {
1621 let outer_c = outer.clone();
1622 Box::new(move |_: &mut ()| {
1623 auto_wire(&outer_c);
1624 raw_navigator(&outer_c, page)
1625 })
1626 };
1627 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1628 root.rebuild(&mut app, &mut ());
1629 assert_eq!(outer.depth(), 1, "outer starts at its own root");
1630 assert!(
1631 !handles_back(),
1632 "root-level, no nested navigator yet: back bubbles"
1633 );
1634
1635 // Push a page onto the OUTER navigator that hosts the nested
1636 // navigator — outer is now at depth 2, so it claims back interest too:
1637 // the shape the ranking has to arbitrate.
1638 {
1639 let inner_for_push = inner.clone();
1640 outer.push(move || {
1641 auto_wire(&inner_for_push);
1642 any(raw_navigator(&inner_for_push, page))
1643 });
1644 }
1645 root.rebuild(&mut app, &mut ());
1646 assert_eq!(
1647 outer.depth(),
1648 2,
1649 "outer pushed to depth 2: the page hosting the nested navigator"
1650 );
1651 assert_eq!(
1652 inner.depth(),
1653 1,
1654 "the nested navigator starts at its own root"
1655 );
1656
1657 // Push inside the nested navigator too, so BOTH stacks are poppable —
1658 // the exact ambiguity R44-back must arbitrate between two
1659 // `Role::Navigator` peers.
1660 inner.push(page);
1661 root.rebuild(&mut app, &mut ());
1662 assert_eq!((outer.depth(), inner.depth()), (2, 2));
1663 assert!(handles_back(), "some registrant claims the press");
1664
1665 push_back_press();
1666 root.rebuild(&mut app, &mut ());
1667 assert_eq!(
1668 inner.depth(),
1669 1,
1670 "the back press popped the NESTED navigator's stack"
1671 );
1672 assert_eq!(
1673 outer.depth(),
1674 2,
1675 "and left the outer navigator's depth UNCHANGED — a bare \
1676 build-order (outermost-first) arbitration would instead have \
1677 popped the outer stack, discarding the whole page the nested \
1678 navigator lives on"
1679 );
1680 }
1681
1682 // -----------------------------------------------------------------------
1683 // R23, the converse of innermost-first: a nested navigator whose hosting
1684 // page input can no longer reach must NOT take the press.
1685 // -----------------------------------------------------------------------
1686
1687 /// An outer navigator whose ROOT page hosts a nested navigator, each
1688 /// auto-wired where its view is constructed (the outer from the app's build
1689 /// pass, the nested from the page builder the outer runs during its own
1690 /// reconcile) — the canonical tab/section-stack shape. `cull` sets the outer
1691 /// navigator's [`cull_covered_builds`], the switch that decides whether the
1692 /// covered page keeps re-running its builder at all.
1693 struct NestedHarness {
1694 outer: NavigatorController<()>,
1695 nested: NavigatorController<()>,
1696 root: RenderRoot<(), NavigatorView<()>>,
1697 app: AppLogic,
1698 }
1699
1700 impl NestedHarness {
1701 fn new(cull: bool) -> Self {
1702 let outer: NavigatorController<()> = NavigatorController::new();
1703 let nested: NavigatorController<()> = NavigatorController::new();
1704 let app: AppLogic = {
1705 let outer = outer.clone();
1706 let nested = nested.clone();
1707 Box::new(move |_: &mut ()| {
1708 // Exactly what `frust::navigator` does, outermost first.
1709 auto_wire(&outer);
1710 let nested = nested.clone();
1711 raw_navigator(&outer, move || {
1712 auto_wire(&nested);
1713 any(raw_navigator(&nested, page))
1714 })
1715 .cull_covered_builds(cull)
1716 })
1717 };
1718 let mut harness = Self {
1719 outer,
1720 nested,
1721 root: RenderRoot::new(),
1722 app,
1723 };
1724 harness.rebuild();
1725 harness
1726 }
1727
1728 fn rebuild(&mut self) {
1729 self.root.rebuild(&mut self.app, &mut ());
1730 }
1731 }
1732
1733 /// The outer navigator pushes a page OVER the one hosting the nested
1734 /// navigator. Input can reach only the outer navigator's top page
1735 /// (`input_routed_pages`), so back must follow: the press pops the OUTER
1736 /// stack, and the nested (invisible) stack is untouched.
1737 ///
1738 /// Without the R23 reach gate at the source, `route_back`'s
1739 /// innermost-first `find(|r| interest())` would hand the press to the nested
1740 /// navigator — the covered page keeps reconciling (the default) or stays
1741 /// `is_mounted()` (under the cull) either way — popping a stack nobody can
1742 /// see while the back button appears to do nothing.
1743 ///
1744 /// Run under BOTH `cull_covered_builds` settings: they are exactly the two
1745 /// ways the covered page's builder does or does not keep running, and the
1746 /// answer must not depend on that.
1747 #[test]
1748 fn a_nested_navigator_on_a_covered_page_does_not_take_the_back_press() {
1749 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1750 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1751
1752 for cull in [false, true] {
1753 reset();
1754 let mut h = NestedHarness::new(cull);
1755
1756 // The nested stack is poppable while its page is still current —
1757 // the innermost-first case, which must keep working (below too).
1758 h.nested.push(page);
1759 h.rebuild();
1760 assert_eq!((h.outer.depth(), h.nested.depth()), (1, 2));
1761 assert!(
1762 handles_back(),
1763 "the nested navigator claims the press while its page is current (cull={cull})"
1764 );
1765
1766 // Now push a page over it on the OUTER navigator: the nested
1767 // navigator's page is covered and routed no input.
1768 h.outer.push(page);
1769 h.rebuild();
1770 assert_eq!((h.outer.depth(), h.nested.depth()), (2, 2));
1771 // A few idle frames: under `cull_covered_builds(true)` the covered
1772 // page stops rebuilding entirely here, so this is where a
1773 // wire-refreshed (rather than live) reachability flag would go stale.
1774 for _ in 0..3 {
1775 h.rebuild();
1776 }
1777 assert!(
1778 handles_back(),
1779 "the outer navigator claims it (cull={cull})"
1780 );
1781
1782 push_back_press();
1783 h.rebuild();
1784 assert_eq!(
1785 h.outer.depth(),
1786 1,
1787 "the press popped the OUTER stack — the page the user is \
1788 actually looking at came off (cull={cull})"
1789 );
1790 assert_eq!(
1791 h.nested.depth(),
1792 2,
1793 "and the covered navigator's invisible stack is UNCHANGED \
1794 (cull={cull})"
1795 );
1796
1797 // Revealed again, the nested navigator takes the next press — the
1798 // innermost-first behaviour, unregressed, in the same harness.
1799 push_back_press();
1800 h.rebuild();
1801 assert_eq!(
1802 (h.outer.depth(), h.nested.depth()),
1803 (1, 1),
1804 "back reaches the nested navigator again once its page is \
1805 current (cull={cull})"
1806 );
1807 }
1808 }
1809
1810 /// The reach gate is **input routing**, not painting: a *transparent*
1811 /// overlay pushed on the outer navigator leaves the page below it painted
1812 /// (`PageVisibility::Visible`) but routes it no input, so the nested
1813 /// navigator on that page must not take the press either. This is the same
1814 /// divergence R23 pins for semantics.
1815 #[test]
1816 fn a_nested_navigator_under_a_transparent_overlay_does_not_take_the_back_press() {
1817 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1818 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1819 reset();
1820
1821 let mut h = NestedHarness::new(false);
1822 h.nested.push(page);
1823 h.rebuild();
1824 h.outer
1825 .push_with_options(page, PushOptions::transparent().back(BackPolicy::Pop));
1826 h.rebuild();
1827 assert_eq!((h.outer.depth(), h.nested.depth()), (2, 2));
1828
1829 push_back_press();
1830 h.rebuild();
1831 assert_eq!(h.outer.depth(), 1, "the overlay came off");
1832 assert_eq!(
1833 h.nested.depth(),
1834 2,
1835 "the painted-but-inert page's navigator kept its stack"
1836 );
1837 }
1838
1839 // -----------------------------------------------------------------------
1840 // A design-claim stress test: wiring-order independence between two
1841 // `Role::Navigator` peers. `Registrant::key`'s own doc states the
1842 // premise plainly — "a nested navigator always wires after the navigator
1843 // hosting it, so the highest `birth` among `Navigator` peers is the
1844 // innermost one" — and the two tests above this section
1845 // (`an_overlay_host_survives_an_inner_navigator_wiring_twice_per_pass`,
1846 // `an_init_registered_backhandler_does_not_outrank_a_later_overlay_host`)
1847 // already prove that premise does NOT hold for wire order in general
1848 // (`Role::Host` is decoupled from it on purpose), but only for a `Host`
1849 // vs. a `Navigator`. Nothing pins the analogous case for two `Navigator`
1850 // peers — this section does. `refresh_interest`/`Role`/`registrant_ids`
1851 // are crate-private, so this composition cannot be built from an
1852 // integration test the way the rest of the shell matrix is.
1853 // -----------------------------------------------------------------------
1854
1855 /// `inner` gets an explicit [`BackHandler`], built and FIRST wired before
1856 /// `outer` (its eventual host) has any view at all — the
1857 /// `Component::init` shape — and `track()`ed every rebuild from then on,
1858 /// so its earliest `birth` is never dropped by the one-cycle prune even
1859 /// while its own navigator widget doesn't exist yet (a bare, never-tracked
1860 /// pre-registration IS pruned before outer's page ever mounts inner for
1861 /// real, which self-heals the ordering — tried first, and not what this
1862 /// test pins). The REST of the composition is the honest structural
1863 /// nesting the shell matrix uses elsewhere: outer's plain root, then a
1864 /// page it pushes that hosts `inner`'s real navigator. With BOTH
1865 /// navigators poppable, the innermost-first rule says `inner` must win.
1866 ///
1867 /// **Observed vs. designed:** a `Role::Navigator` peer's sort key is
1868 /// `u64::MAX - birth`, and `birth` never changes after a registrant's
1869 /// FIRST wire (only `seq` refreshes) — so pinning `inner`'s birth ahead of
1870 /// `outer`'s gives `inner` the numerically LARGER key, and `route_back`'s
1871 /// first-match search finds `outer` first. `outer` wins, inverting the
1872 /// documented "structurally innermost wins" rule for this ordering. This
1873 /// is a real, reachable divergence between the documented rule (nesting
1874 /// depth) and its actual implementation (raw wire sequence), not a test
1875 /// bug — see the `#[ignore]` reason for the acceptance criterion this
1876 /// pins for future ranking work.
1877 #[test]
1878 #[ignore = "pins a design case that FAILS against current ranking: an inner \
1879 navigator whose explicit BackHandler wires (and keeps tracking) \
1880 before its outer host's first view exists (the Component::init \
1881 shape) loses the innermost-first tie-break to the outer \
1882 navigator instead of winning it — Registrant::key ranks \
1883 Role::Navigator peers by raw wire sequence (birth), not by \
1884 structural nesting depth. Expected to start passing once \
1885 ranking accounts for nesting depth rather than birth order \
1886 alone; rerun with `cargo test -p frust-ui --lib \
1887 wiring_order_does_not_flip -- --ignored` to observe the \
1888 current failure."]
1889 fn wiring_order_does_not_flip_the_innermost_navigator_when_it_is_still_poppable() {
1890 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1891 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1892 reset();
1893
1894 let outer: NavigatorController<()> = NavigatorController::new();
1895 let inner: NavigatorController<()> = NavigatorController::new();
1896
1897 // `Component::init`: inner's explicit handler wires FIRST, before
1898 // outer's own view exists.
1899 let inner_back = BackHandler::new(inner.clone());
1900 assert_eq!(registrant_ids(), vec![inner.id()], "inner wired alone");
1901
1902 let mut app: AppLogic = {
1903 let outer_c = outer.clone();
1904 Box::new(move |_: &mut ()| {
1905 // Tracked every pass, exactly like Huddle's own
1906 // `state.back.track()` — this is what keeps inner's early
1907 // birth from ever being pruned as stale.
1908 inner_back.track();
1909 auto_wire(&outer_c);
1910 raw_navigator(&outer_c, page)
1911 })
1912 };
1913 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1914 root.rebuild(&mut app, &mut ());
1915 assert_eq!(outer.depth(), 1, "outer starts at its own root");
1916
1917 {
1918 let inner_for_push = inner.clone();
1919 outer.push(move || {
1920 auto_wire(&inner_for_push);
1921 any(raw_navigator(&inner_for_push, page))
1922 });
1923 }
1924 root.rebuild(&mut app, &mut ());
1925 assert_eq!(outer.depth(), 2, "outer pushed the page hosting inner");
1926
1927 // The case-5 shape: BOTH navigators poppable.
1928 inner.push(page);
1929 root.rebuild(&mut app, &mut ());
1930 assert_eq!((outer.depth(), inner.depth()), (2, 2));
1931
1932 push_back_press();
1933 root.rebuild(&mut app, &mut ());
1934 assert_eq!(
1935 inner.depth(),
1936 1,
1937 "the structurally-innermost navigator should win the tie \
1938 regardless of which one registered first"
1939 );
1940 assert_eq!(
1941 outer.depth(),
1942 2,
1943 "and the outer stack should stay untouched"
1944 );
1945 }
1946
1947 /// The harmless half of the same stress test: at inner depth 1 (default
1948 /// `BackPolicy::Pop`, no interest either way), wire order cannot matter
1949 /// because ranking is never even consulted — `route_back` only compares
1950 /// registrants that both claim interest, and `inner` claims none here.
1951 /// Unlike its sibling above, this one is expected to (and does) pass
1952 /// against unchanged code.
1953 #[test]
1954 fn wiring_order_is_irrelevant_when_the_inner_navigator_has_no_interest() {
1955 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1956 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
1957 reset();
1958
1959 let outer: NavigatorController<()> = NavigatorController::new();
1960 let inner: NavigatorController<()> = NavigatorController::new();
1961
1962 let inner_back = BackHandler::new(inner.clone());
1963
1964 let mut app: AppLogic = {
1965 let outer_c = outer.clone();
1966 Box::new(move |_: &mut ()| {
1967 inner_back.track();
1968 auto_wire(&outer_c);
1969 raw_navigator(&outer_c, page)
1970 })
1971 };
1972 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1973 root.rebuild(&mut app, &mut ());
1974
1975 {
1976 let inner_for_push = inner.clone();
1977 outer.push(move || {
1978 auto_wire(&inner_for_push);
1979 any(raw_navigator(&inner_for_push, page))
1980 });
1981 }
1982 root.rebuild(&mut app, &mut ());
1983 assert_eq!(
1984 (outer.depth(), inner.depth()),
1985 (2, 1),
1986 "outer poppable, inner still at its own root"
1987 );
1988
1989 push_back_press();
1990 root.rebuild(&mut app, &mut ());
1991 assert_eq!(outer.depth(), 1, "outer popped its own shell page");
1992 }
1993
1994 // -----------------------------------------------------------------------
1995 // Per-tab navigators kept mounted under an `IndexedStack`: only the
1996 // selected tab may claim the back press, whatever the wire order.
1997 // -----------------------------------------------------------------------
1998
1999 type TabsApp = Box<dyn FnMut(&mut ()) -> AnyView<()>>;
2000
2001 /// Two tab navigators inside `IndexedStack(selected, (tab0, tab1))`,
2002 /// optionally under a root `overlay_host`. `last_wired_is_tab1` picks
2003 /// which tab's `crate::navigator` call (and so its wiring) runs last.
2004 struct TabsHarness {
2005 tab0: NavigatorController<()>,
2006 tab1: NavigatorController<()>,
2007 host: NavigatorController<()>,
2008 selected: std::rc::Rc<std::cell::Cell<Option<usize>>>,
2009 root: RenderRoot<(), AnyView<()>>,
2010 app: TabsApp,
2011 }
2012
2013 impl TabsHarness {
2014 fn new(selected: usize, last_wired_is_tab1: bool, with_host: bool) -> Self {
2015 Self::build(selected, last_wired_is_tab1, with_host, |_, _, _| None)
2016 }
2017
2018 /// As [`new`](Self::new) (no host, tab 1 wired last), with the tabs
2019 /// wrapped in `back_fallback` over the fallback `make_fallback` builds
2020 /// from the two tab controllers and the selection cell.
2021 fn with_fallback(
2022 selected: usize,
2023 make_fallback: impl FnOnce(
2024 &NavigatorController<()>,
2025 &NavigatorController<()>,
2026 &std::rc::Rc<std::cell::Cell<Option<usize>>>,
2027 ) -> BackFallback,
2028 ) -> Self {
2029 Self::build(selected, true, false, |tab0, tab1, sel| {
2030 Some(make_fallback(tab0, tab1, sel))
2031 })
2032 }
2033
2034 fn build(
2035 selected: usize,
2036 last_wired_is_tab1: bool,
2037 with_host: bool,
2038 make_fallback: impl FnOnce(
2039 &NavigatorController<()>,
2040 &NavigatorController<()>,
2041 &std::rc::Rc<std::cell::Cell<Option<usize>>>,
2042 ) -> Option<BackFallback>,
2043 ) -> Self {
2044 let tab0: NavigatorController<()> = NavigatorController::new();
2045 let tab1: NavigatorController<()> = NavigatorController::new();
2046 let host: NavigatorController<()> = NavigatorController::new();
2047 let sel = std::rc::Rc::new(std::cell::Cell::new(Some(selected)));
2048 let fallback = make_fallback(&tab0, &tab1, &sel);
2049 let app: TabsApp = {
2050 let (tab0, tab1, host, sel) =
2051 (tab0.clone(), tab1.clone(), host.clone(), sel.clone());
2052 Box::new(move |_: &mut ()| {
2053 let tabs = {
2054 let (tab0, tab1, sel) = (tab0.clone(), tab1.clone(), sel.clone());
2055 move || {
2056 let (n0, n1);
2057 if last_wired_is_tab1 {
2058 n0 = crate::navigator(&tab0, page);
2059 n1 = crate::navigator(&tab1, page);
2060 } else {
2061 n1 = crate::navigator(&tab1, page);
2062 n0 = crate::navigator(&tab0, page);
2063 }
2064 frust_widgets::IndexedStack(sel.get(), (n0, n1))
2065 }
2066 };
2067 if with_host {
2068 auto_wire_overlay_host(&host);
2069 any(raw_overlay_host(&host, move || any(tabs())))
2070 } else if let Some(fallback) = &fallback {
2071 any(back_fallback(fallback, tabs()))
2072 } else {
2073 any(tabs())
2074 }
2075 })
2076 };
2077 let mut h = Self {
2078 tab0,
2079 tab1,
2080 host,
2081 selected: sel,
2082 root: RenderRoot::new(),
2083 app,
2084 };
2085 h.rebuild();
2086 h
2087 }
2088
2089 fn rebuild(&mut self) {
2090 self.root.rebuild(&mut self.app, &mut ());
2091 }
2092
2093 fn select(&mut self, i: usize) {
2094 self.selected.set(Some(i));
2095 self.rebuild();
2096 }
2097
2098 fn depths(&self) -> (usize, usize) {
2099 (self.tab0.depth(), self.tab1.depth())
2100 }
2101
2102 /// Tab 0 to depth `d0`, tab 1 to depth `d1`.
2103 fn deepen(&mut self, d0: usize, d1: usize) {
2104 // Depth only updates on rebuild, so push by count from the
2105 // current (settled) depth.
2106 for _ in self.tab0.depth()..d0 {
2107 self.tab0.push(page);
2108 }
2109 for _ in self.tab1.depth()..d1 {
2110 self.tab1.push(page);
2111 }
2112 self.rebuild();
2113 assert_eq!(self.depths(), (d0, d1));
2114 }
2115 }
2116
2117 /// (a) Selected tab 0 at its root: back bubbles to the platform even though
2118 /// the hidden tab 1 (wired last) is deep.
2119 #[test]
2120 fn a_selected_tab_at_its_root_exits_even_if_a_hidden_tab_is_deep() {
2121 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2122 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2123 reset();
2124
2125 let mut h = TabsHarness::new(0, true, false);
2126 h.deepen(1, 3);
2127 assert!(
2128 !handles_back(),
2129 "selected tab at its root: the app would exit"
2130 );
2131
2132 push_back_press();
2133 h.rebuild();
2134 assert_eq!(h.depths(), (1, 3), "the hidden tab was not popped");
2135 }
2136
2137 /// (b) Switching to tab 1 hands it the press; tab 0 is untouched.
2138 #[test]
2139 fn switching_tabs_hands_the_back_press_to_the_newly_selected_tab() {
2140 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2141 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2142 reset();
2143
2144 let mut h = TabsHarness::new(0, true, false);
2145 h.deepen(1, 3);
2146 h.select(1);
2147 assert!(handles_back(), "selected tab 1 is poppable");
2148
2149 push_back_press();
2150 h.rebuild();
2151 assert_eq!(h.depths(), (1, 2), "tab 1 popped one page, tab 0 untouched");
2152 }
2153
2154 /// (c) Wire-order independence, both orders: the deep NON-selected tab is
2155 /// wired last, yet the press goes to the selected tab.
2156 #[test]
2157 fn the_back_press_follows_selection_not_wire_order() {
2158 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2159 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2160
2161 for last_wired_is_tab1 in [true, false] {
2162 reset();
2163 // Selected tab 0 (depth 2) vs hidden tab 1 (depth 3).
2164 let mut h = TabsHarness::new(0, last_wired_is_tab1, false);
2165 h.deepen(2, 3);
2166 assert!(handles_back());
2167 push_back_press();
2168 h.rebuild();
2169 assert_eq!(
2170 h.depths(),
2171 (1, 3),
2172 "selected tab 0 took the press (tab1 wired last: {last_wired_is_tab1})"
2173 );
2174
2175 // And the mirror: select tab 1, which now wins whichever wired last.
2176 h.select(1);
2177 h.tab0.push(page);
2178 h.rebuild();
2179 assert_eq!(h.depths(), (2, 3));
2180 push_back_press();
2181 h.rebuild();
2182 assert_eq!(
2183 h.depths(),
2184 (2, 2),
2185 "selected tab 1 took the press (tab1 wired last: {last_wired_is_tab1})"
2186 );
2187 }
2188 }
2189
2190 /// (d) A root overlay host with an open overlay still outranks the
2191 /// selected tab.
2192 #[test]
2193 fn a_root_overlay_outranks_the_selected_tab() {
2194 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2195 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2196 reset();
2197
2198 let mut h = TabsHarness::new(1, false, true);
2199 h.deepen(1, 2);
2200 h.host
2201 .push_with_options(page, PushOptions::transparent().back(BackPolicy::Pop));
2202 h.rebuild();
2203 assert_eq!(h.host.depth(), 2);
2204 assert!(handles_back());
2205
2206 push_back_press();
2207 h.rebuild();
2208 assert_eq!(h.host.depth(), 1, "the overlay was dismissed first");
2209 assert_eq!(h.depths(), (1, 2), "the selected tab was untouched");
2210
2211 push_back_press();
2212 h.rebuild();
2213 assert_eq!(
2214 h.depths(),
2215 (1, 1),
2216 "the next press reaches the selected tab"
2217 );
2218 }
2219
2220 /// (e) Hidden tabs stay mounted and registered: depth survives a round
2221 /// trip with no re-push.
2222 #[test]
2223 fn a_hidden_tab_keeps_its_stack_and_registration() {
2224 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2225 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2226 reset();
2227
2228 let mut h = TabsHarness::new(0, true, false);
2229 h.deepen(1, 3);
2230 h.select(1);
2231 h.select(0);
2232 for _ in 0..3 {
2233 h.rebuild();
2234 }
2235 assert_eq!(h.depths(), (1, 3), "the hidden tab's depth survived");
2236 let ids = registrant_ids();
2237 assert!(
2238 ids.contains(&h.tab0.id()) && ids.contains(&h.tab1.id()),
2239 "both tab controllers stay registered while hidden"
2240 );
2241
2242 h.select(1);
2243 assert!(handles_back());
2244 push_back_press();
2245 h.rebuild();
2246 assert_eq!(h.depths(), (1, 2), "tab 1 resumes from its kept stack");
2247 }
2248
2249 // -----------------------------------------------------------------------
2250 // `BackFallback`: a non-navigator registrant ranked after every navigator,
2251 // live while a `back_fallback` wrapper over it is mounted.
2252 // -----------------------------------------------------------------------
2253
2254 /// A `RenderRoot` driving a concretely typed app closure — no erasure
2255 /// needed when the root view's type is fixed.
2256 struct Driver<V: View<()>, F: FnMut(&mut ()) -> V> {
2257 root: RenderRoot<(), V>,
2258 app: F,
2259 }
2260
2261 impl<V: View<()>, F: FnMut(&mut ()) -> V> Driver<V, F> {
2262 fn new(app: F) -> Self {
2263 let mut driver = Self {
2264 root: RenderRoot::new(),
2265 app,
2266 };
2267 driver.rebuild();
2268 driver
2269 }
2270
2271 fn rebuild(&mut self) {
2272 self.root.rebuild(&mut self.app, &mut ());
2273 }
2274 }
2275
2276 /// An `on_back` that counts its calls, and the count.
2277 fn counting() -> (Rc<Cell<usize>>, impl Fn() + 'static) {
2278 let hits = Rc::new(Cell::new(0));
2279 let counter = Rc::clone(&hits);
2280 (hits, move || counter.set(counter.get() + 1))
2281 }
2282
2283 /// `fallback`'s arbitration identity.
2284 fn fallback_id(fallback: &BackFallback) -> RegistrantId {
2285 RegistrantId::Fallback(fallback.inner.id)
2286 }
2287
2288 /// Whether `fallback` is in the arbitration list at all.
2289 fn is_registered(fallback: &BackFallback) -> bool {
2290 registrants().contains(&fallback_id(fallback))
2291 }
2292
2293 /// The root view [`wrapped_navigator`] drives, and its driver.
2294 type WrappedNavigator = BackFallbackView<NavigatorView<()>>;
2295 type WrappedDriver = Driver<WrappedNavigator, Box<dyn FnMut(&mut ()) -> WrappedNavigator>>;
2296
2297 /// A navigator (at its root) wrapped by `back_fallback`, plus the fallback
2298 /// and its call count, with the fallback's interest fixed to `interest`.
2299 fn wrapped_navigator(
2300 interest: bool,
2301 ) -> (
2302 NavigatorController<()>,
2303 BackFallback,
2304 Rc<Cell<usize>>,
2305 WrappedDriver,
2306 ) {
2307 let nav: NavigatorController<()> = NavigatorController::new();
2308 let (hits, on_back) = counting();
2309 let fallback = BackFallback::new(move || interest, on_back);
2310 let driver = {
2311 let (nav, fallback) = (nav.clone(), fallback.clone());
2312 Driver::new(Box::new(move |_: &mut ()| {
2313 back_fallback(&fallback, crate::navigator(&nav, page))
2314 })
2315 as Box<dyn FnMut(&mut ()) -> WrappedNavigator>)
2316 };
2317 (nav, fallback, hits, driver)
2318 }
2319
2320 /// (a) A navigator that can pop outranks a mounted, interested fallback.
2321 #[test]
2322 fn a_poppable_navigator_outranks_a_fallback() {
2323 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2324 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2325 reset();
2326
2327 let (nav, _fallback, hits, mut d) = wrapped_navigator(true);
2328 nav.push(page);
2329 d.rebuild();
2330 assert_eq!(nav.depth(), 2);
2331
2332 push_back_press();
2333 d.rebuild();
2334 assert_eq!(nav.depth(), 1, "the navigator took the press");
2335 assert_eq!(hits.get(), 0, "and the fallback did not");
2336 }
2337
2338 /// (b) With the navigator at its root, an interested fallback claims the
2339 /// press: `handles_back()` is true, `on_back` runs once, and the navigator
2340 /// is untouched.
2341 #[test]
2342 fn a_fallback_claims_the_press_when_no_navigator_does() {
2343 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2344 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2345 reset();
2346
2347 let (nav, _fallback, hits, mut d) = wrapped_navigator(true);
2348 assert_eq!(nav.depth(), 1);
2349 assert!(
2350 handles_back(),
2351 "the fallback claims the press ahead of time"
2352 );
2353
2354 push_back_press();
2355 d.rebuild();
2356 assert_eq!(hits.get(), 1, "on_back ran once");
2357 assert_eq!(nav.depth(), 1, "the navigator is unchanged");
2358
2359 d.rebuild();
2360 assert_eq!(hits.get(), 1, "a rebuild with no new press does not refire");
2361 }
2362
2363 /// (c) A fallback with no interest lets a root-level press exit.
2364 #[test]
2365 fn an_uninterested_fallback_lets_the_press_exit() {
2366 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2367 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2368 reset();
2369
2370 let (nav, _fallback, hits, mut d) = wrapped_navigator(false);
2371 assert!(!handles_back(), "nobody claims: the app would exit");
2372
2373 push_back_press();
2374 d.rebuild();
2375 assert_eq!((hits.get(), nav.depth()), (0, 1));
2376 }
2377
2378 /// (d) Rank comes from `Role`, never from wire order: a fallback wired
2379 /// BEFORE the navigator and one wired AFTER it both rank after it, and
2380 /// among themselves keep first-wire order.
2381 #[test]
2382 fn fallbacks_rank_after_the_navigator_whatever_the_wire_order() {
2383 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2384 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2385 reset();
2386
2387 let nav: NavigatorController<()> = NavigatorController::new();
2388 let (before_hits, before_on_back) = counting();
2389 let (after_hits, after_on_back) = counting();
2390 let before = BackFallback::new(|| true, before_on_back);
2391 let after = BackFallback::new(|| true, after_on_back);
2392 let mut d = {
2393 let (nav, before, after) = (nav.clone(), before.clone(), after.clone());
2394 Driver::new(move |_: &mut ()| {
2395 // Argument order is wire order: `before`, the navigator, `after`.
2396 frust_widgets::stack()
2397 .child(back_fallback(&before, frust_widgets::text("x")))
2398 .child(back_fallback(&after, crate::navigator(&nav, page)))
2399 })
2400 };
2401 assert_eq!(
2402 registrants(),
2403 vec![
2404 RegistrantId::Navigator(nav.id()),
2405 fallback_id(&before),
2406 fallback_id(&after),
2407 ],
2408 "the navigator first, then the fallbacks in first-wire order"
2409 );
2410
2411 nav.push(page);
2412 d.rebuild();
2413 push_back_press();
2414 d.rebuild();
2415 assert_eq!(nav.depth(), 1, "the navigator took the press");
2416 assert_eq!((before_hits.get(), after_hits.get()), (0, 0));
2417
2418 push_back_press();
2419 d.rebuild();
2420 assert_eq!(
2421 (before_hits.get(), after_hits.get()),
2422 (1, 0),
2423 "with the navigator at its root, the first-wired fallback took it"
2424 );
2425 assert_eq!(
2426 registrants(),
2427 vec![
2428 RegistrantId::Navigator(nav.id()),
2429 fallback_id(&before),
2430 fallback_id(&after),
2431 ],
2432 "re-wiring never re-orders"
2433 );
2434 }
2435
2436 /// (e) Leaving the screen: the wrapper unmounts, the fallback stops
2437 /// claiming at once, rule 2 prunes it, and a press does not reach it.
2438 /// Re-adding the wrapper re-registers it.
2439 #[test]
2440 fn an_unmounted_fallback_stops_claiming_and_is_pruned() {
2441 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2442 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2443 reset();
2444
2445 let nav: NavigatorController<()> = NavigatorController::new();
2446 let (hits, on_back) = counting();
2447 let fallback = BackFallback::new(|| true, on_back);
2448 let show = Rc::new(Cell::new(true));
2449 let mut d = {
2450 let (nav, fallback, show) = (nav.clone(), fallback.clone(), Rc::clone(&show));
2451 Driver::new(move |_: &mut ()| {
2452 frust_widgets::stack()
2453 .child(crate::navigator(&nav, page))
2454 .when(show.get(), |s| {
2455 s.child(back_fallback(&fallback, frust_widgets::text("x")))
2456 })
2457 })
2458 };
2459 assert!(handles_back(), "mounted and interested");
2460
2461 show.set(false);
2462 d.rebuild();
2463 assert!(!fallback.is_live(), "the wrapper was torn down");
2464 assert!(!handles_back(), "an unmounted fallback claims nothing");
2465 d.rebuild();
2466 d.rebuild();
2467 assert!(!is_registered(&fallback), "rule 2 pruned it");
2468 assert_eq!(registrant_ids(), vec![nav.id()]);
2469
2470 push_back_press();
2471 d.rebuild();
2472 assert_eq!(hits.get(), 0, "a press after the prune does not reach it");
2473
2474 show.set(true);
2475 d.rebuild();
2476 assert!(
2477 is_registered(&fallback),
2478 "re-adding the wrapper re-registers it"
2479 );
2480 assert!(handles_back());
2481 push_back_press();
2482 d.rebuild();
2483 assert_eq!(hits.get(), 1, "and it claims presses again");
2484 }
2485
2486 /// (f) A burst of presses before a rebuild collapses to one `on_back`.
2487 #[test]
2488 fn a_press_burst_calls_on_back_once() {
2489 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2490 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2491 reset();
2492
2493 let (_nav, _fallback, hits, mut d) = wrapped_navigator(true);
2494 for _ in 0..3 {
2495 push_back_press();
2496 }
2497 d.rebuild();
2498 assert_eq!(hits.get(), 1, "three presses, one rebuild: one on_back");
2499 d.rebuild();
2500 assert_eq!(hits.get(), 1);
2501 }
2502
2503 /// (g) Per-tab stacks under an `IndexedStack`, wrapped by a back-to-home
2504 /// fallback: back at a non-home tab root switches to the home tab, and
2505 /// back at the home tab root is not consumed (the app exits).
2506 #[test]
2507 fn back_at_a_tab_root_returns_home_then_exits() {
2508 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2509 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2510 reset();
2511
2512 const HOME: usize = 0;
2513 let mut h = TabsHarness::with_fallback(1, |tab0, tab1, sel| {
2514 let (tab0, tab1) = (tab0.clone(), tab1.clone());
2515 let (probe, set) = (Rc::clone(sel), Rc::clone(sel));
2516 BackFallback::new(
2517 move || match probe.get() {
2518 Some(HOME) | None => false,
2519 Some(i) => [&tab0, &tab1][i].depth() <= 1,
2520 },
2521 move || set.set(Some(HOME)),
2522 )
2523 });
2524
2525 // A poppable selected tab still takes the press first.
2526 h.deepen(1, 2);
2527 push_back_press();
2528 h.rebuild();
2529 assert_eq!(h.depths(), (1, 1), "tab B popped");
2530 assert_eq!(h.selected.get(), Some(1), "and the selection stayed");
2531
2532 // Tab B at its root: the fallback switches to the home tab.
2533 assert!(
2534 handles_back(),
2535 "off home at a tab root: the fallback claims"
2536 );
2537 push_back_press();
2538 h.rebuild();
2539 assert_eq!(
2540 h.selected.get(),
2541 Some(HOME),
2542 "the press selected the home tab"
2543 );
2544 h.rebuild();
2545 assert_eq!(h.depths(), (1, 1), "no stack was popped");
2546
2547 // Home tab at its root: nobody claims, so the press would exit.
2548 assert!(!handles_back(), "home at its root: back falls through");
2549 push_back_press();
2550 h.rebuild();
2551 assert_eq!(h.selected.get(), Some(HOME));
2552 assert_eq!(h.depths(), (1, 1));
2553 }
2554
2555 /// (h) A navigator that wires twice per pass (`BackHandler::track` +
2556 /// `navigator()` on one controller) never prunes a mounted fallback: rule 1
2557 /// holds for a fallback exactly as for a navigator.
2558 #[test]
2559 fn a_mounted_fallback_survives_a_double_wiring_navigator() {
2560 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2561 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2562 reset();
2563
2564 let nav: NavigatorController<()> = NavigatorController::new();
2565 let back = BackHandler::new(nav.clone());
2566 let (hits, on_back) = counting();
2567 let fallback = BackFallback::new(|| true, on_back);
2568 let mut d = {
2569 let (nav, fallback) = (nav.clone(), fallback.clone());
2570 Driver::new(move |_: &mut ()| {
2571 back.track();
2572 back_fallback(&fallback, crate::navigator(&nav, page))
2573 })
2574 };
2575 for pass in 0..4 {
2576 d.rebuild();
2577 assert!(is_registered(&fallback), "never pruned (pass {pass})");
2578 assert!(handles_back(), "handles_back stays true (pass {pass})");
2579 }
2580
2581 push_back_press();
2582 d.rebuild();
2583 assert_eq!(
2584 (hits.get(), nav.depth()),
2585 (1, 1),
2586 "the fallback took it once"
2587 );
2588 }
2589
2590 /// (i) First-wire grace: `back_fallback()` wires before its wrapper is
2591 /// built, and a double-wiring navigator constructed AFTER it in the same
2592 /// first pass must not prune it while its mount count is still 0.
2593 #[test]
2594 fn first_wire_grace_keeps_an_unbuilt_fallback_through_a_double_wire() {
2595 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2596 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2597 reset();
2598
2599 let nav: NavigatorController<()> = NavigatorController::new();
2600 let back = BackHandler::new(nav.clone());
2601 let fallback = BackFallback::new(|| true, || {});
2602 // What the double wire left behind, seen mid-pass: (registered, mount
2603 // count) right after the navigator's second wire, before any build.
2604 let mid_pass = Rc::new(Cell::new((false, usize::MAX)));
2605 let d = {
2606 let (nav, fallback, mid_pass) = (nav.clone(), fallback.clone(), Rc::clone(&mid_pass));
2607 Driver::new(move |_: &mut ()| {
2608 let wrapped = back_fallback(&fallback, frust_widgets::text("x"));
2609 back.track();
2610 let navigator = crate::navigator(&nav, page);
2611 mid_pass.set((is_registered(&fallback), fallback.inner.mounted.get()));
2612 frust_widgets::stack().child(wrapped).child(navigator)
2613 })
2614 };
2615 assert_eq!(
2616 mid_pass.get(),
2617 (true, 0),
2618 "unbuilt (mount count 0) yet not pruned by the navigator's double wire"
2619 );
2620 assert!(is_registered(&fallback), "still registered after the pass");
2621 assert_eq!(fallback.inner.mounted.get(), 1, "and now mounted");
2622 assert!(
2623 handles_back(),
2624 "handles_back reflects it on the first frame"
2625 );
2626 drop(d);
2627 }
2628
2629 /// (j) Two wrappers sharing one handle: the fallback stays live while
2630 /// either is mounted, and is pruned only once both are gone.
2631 #[test]
2632 fn a_fallback_shared_by_two_wrappers_lives_until_both_unmount() {
2633 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2634 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2635 reset();
2636
2637 let nav: NavigatorController<()> = NavigatorController::new();
2638 let fallback = BackFallback::new(|| true, || {});
2639 let show = Rc::new(Cell::new((true, true)));
2640 let mut d = {
2641 let (nav, fallback, show) = (nav.clone(), fallback.clone(), Rc::clone(&show));
2642 Driver::new(move |_: &mut ()| {
2643 let (a, b) = show.get();
2644 frust_widgets::stack()
2645 .child(crate::navigator(&nav, page))
2646 .when(a, |s| {
2647 s.child(back_fallback(&fallback, frust_widgets::text("a")))
2648 })
2649 .when(b, |s| {
2650 s.child(back_fallback(&fallback, frust_widgets::text("b")))
2651 })
2652 })
2653 };
2654 assert_eq!(fallback.inner.mounted.get(), 2);
2655
2656 show.set((true, false));
2657 for _ in 0..3 {
2658 d.rebuild();
2659 }
2660 assert_eq!(fallback.inner.mounted.get(), 1);
2661 assert!(is_registered(&fallback), "one wrapper still mounted: live");
2662 assert!(handles_back());
2663
2664 show.set((false, false));
2665 d.rebuild();
2666 assert!(!handles_back(), "both unmounted: claims nothing");
2667 d.rebuild();
2668 d.rebuild();
2669 assert!(!is_registered(&fallback), "and rule 2 pruned it");
2670 }
2671
2672 /// A slot that rebuilds against a DIFFERENT handle moves its mount: the
2673 /// old fallback unmounts (and is pruned), the new one is live.
2674 #[test]
2675 fn a_wrapper_rebuilt_against_another_handle_moves_its_mount() {
2676 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2677 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2678 reset();
2679
2680 let nav: NavigatorController<()> = NavigatorController::new();
2681 let first = BackFallback::new(|| true, || {});
2682 let second = BackFallback::new(|| true, || {});
2683 let use_first = Rc::new(Cell::new(true));
2684 let mut d = {
2685 let (nav, first, second, use_first) = (
2686 nav.clone(),
2687 first.clone(),
2688 second.clone(),
2689 Rc::clone(&use_first),
2690 );
2691 Driver::new(move |_: &mut ()| {
2692 let fallback = if use_first.get() { &first } else { &second };
2693 back_fallback(fallback, crate::navigator(&nav, page))
2694 })
2695 };
2696 assert_eq!(
2697 (first.inner.mounted.get(), second.inner.mounted.get()),
2698 (1, 0)
2699 );
2700
2701 use_first.set(false);
2702 for _ in 0..3 {
2703 d.rebuild();
2704 }
2705 assert_eq!(
2706 (first.inner.mounted.get(), second.inner.mounted.get()),
2707 (0, 1)
2708 );
2709 assert!(!is_registered(&first), "the old handle was released");
2710 assert!(is_registered(&second), "the new one is live");
2711 }
2712
2713 // -----------------------------------------------------------------------
2714 // Unclaimed presses are dropped; tab navigators + fallback under a real
2715 // `pattern_switcher`.
2716 // -----------------------------------------------------------------------
2717
2718 /// (b) The opposite wire order of the first tabs test: the hidden deep tab
2719 /// wires FIRST, the selected tab (wired last) is at its root, no fallback.
2720 /// Nothing claims, and nothing pops.
2721 #[test]
2722 fn an_unclaimed_press_pops_nothing_when_the_hidden_deep_tab_wires_first() {
2723 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2724 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2725 reset();
2726
2727 let mut h = TabsHarness::new(0, false, false);
2728 h.deepen(1, 3);
2729 assert!(!handles_back(), "selected tab at its root: nothing claims");
2730
2731 push_back_press();
2732 h.rebuild();
2733 assert_eq!(h.depths(), (1, 3), "the hidden deep tab was not popped");
2734 h.rebuild();
2735 assert_eq!(h.depths(), (1, 3));
2736 }
2737
2738 /// (c) Lost-claim race: the fallback claims at poll time, the selection
2739 /// flips to Home before the rebuild that consumes the press, and the
2740 /// hidden Home tab is deep. The press pops nothing.
2741 #[test]
2742 fn a_claim_lost_before_the_press_is_consumed_pops_no_hidden_tab() {
2743 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2744 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2745 reset();
2746
2747 const HOME: usize = 0;
2748 let mut h = TabsHarness::with_fallback(1, |tab0, tab1, sel| {
2749 let (tab0, tab1) = (tab0.clone(), tab1.clone());
2750 let probe = Rc::clone(sel);
2751 BackFallback::new(
2752 move || match probe.get() {
2753 Some(HOME) | None => false,
2754 Some(i) => [&tab0, &tab1][i].depth() <= 1,
2755 },
2756 || {},
2757 )
2758 });
2759 h.deepen(3, 1);
2760 assert!(handles_back(), "tab B at its root: the fallback claims");
2761
2762 // The selection flips before the rebuild that consumes the press.
2763 h.selected.set(Some(HOME));
2764 push_back_press();
2765 h.rebuild();
2766 assert_eq!(h.depths(), (3, 1), "the hidden-then-selected Home tab held");
2767 h.rebuild();
2768 assert_eq!(h.depths(), (3, 1));
2769 }
2770
2771 type SwitchedApp = Box<dyn FnMut(&mut ()) -> PatternSwitcherView<(), SharedAxis>>;
2772
2773 /// Two tab navigators (`IndexedStack`, optionally under a `back_fallback`)
2774 /// inside the page of a real `frust::motion` `pattern_switcher` keyed 0;
2775 /// key 1 is a page with no navigator.
2776 struct SwitchHarness {
2777 tab0: NavigatorController<()>,
2778 tab1: NavigatorController<()>,
2779 key: Rc<Cell<usize>>,
2780 hits: Rc<Cell<usize>>,
2781 driver: Driver<PatternSwitcherView<(), SharedAxis>, SwitchedApp>,
2782 }
2783
2784 impl SwitchHarness {
2785 fn new(with_fallback: bool) -> Self {
2786 let tab0: NavigatorController<()> = NavigatorController::new();
2787 let tab1: NavigatorController<()> = NavigatorController::new();
2788 let key = Rc::new(Cell::new(0usize));
2789 let (hits, on_back) = counting();
2790 let fallback = {
2791 let tab1 = tab1.clone();
2792 BackFallback::new(move || tab1.depth() <= 1, on_back)
2793 };
2794 let app: SwitchedApp = {
2795 let (tab0, tab1, key) = (tab0.clone(), tab1.clone(), key.clone());
2796 Box::new(move |_: &mut ()| {
2797 let child: AnyView<()> = if key.get() == 0 {
2798 let n0 = crate::navigator(&tab0, page);
2799 let n1 = crate::navigator(&tab1, page);
2800 let tabs = frust_widgets::IndexedStack(Some(1), (n0, n1));
2801 if with_fallback {
2802 any(back_fallback(&fallback, tabs))
2803 } else {
2804 any(tabs)
2805 }
2806 } else {
2807 any(frust_widgets::text("no navigator here"))
2808 };
2809 crate::motion::switcher::pattern_switcher(key.get(), SharedAxis::X, child)
2810 })
2811 };
2812 Self {
2813 tab0,
2814 tab1,
2815 key,
2816 hits,
2817 driver: Driver::new(app),
2818 }
2819 }
2820
2821 /// Tab B (the selected one) to `depth`, by a counted number of pushes.
2822 fn deepen_b(&mut self, depth: usize) {
2823 for _ in self.tab1.depth()..depth {
2824 self.tab1.push(page);
2825 }
2826 self.driver.rebuild();
2827 assert_eq!(self.tab1.depth(), depth);
2828 }
2829
2830 fn switch_away(&mut self) {
2831 self.key.set(1);
2832 self.driver.rebuild();
2833 }
2834 }
2835
2836 /// (a) The real switcher path, deep tab: switching to a page with no
2837 /// navigator tears the tabs down on the switch rebuild itself, so nothing
2838 /// claims and the press is not swallowed by the dead tab B.
2839 #[test]
2840 fn a_switcher_teardown_leaves_no_claim_on_a_deep_dead_tab() {
2841 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2842 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2843 reset();
2844
2845 let mut h = SwitchHarness::new(true);
2846 h.deepen_b(3);
2847 assert!(
2848 handles_back(),
2849 "deep selected tab B claims before the switch"
2850 );
2851
2852 h.switch_away();
2853 assert!(!handles_back(), "no claim on the switch rebuild itself");
2854
2855 push_back_press();
2856 h.driver.rebuild();
2857 assert_eq!(h.tab1.depth(), 3, "the dead tab B was not popped");
2858 assert_eq!(h.tab0.depth(), 1);
2859 assert_eq!(h.hits.get(), 0, "the fallback did not fire either");
2860 assert!(!handles_back());
2861 }
2862
2863 /// (a, cont.) The real switcher path, tab B at its root with the fallback
2864 /// interested: after the switch the fallback is unmounted and does not fire.
2865 #[test]
2866 fn a_switcher_teardown_silences_an_interested_fallback() {
2867 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2868 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2869 reset();
2870
2871 let mut h = SwitchHarness::new(true);
2872 assert!(handles_back(), "tab B at its root: the fallback claims");
2873
2874 h.switch_away();
2875 assert!(!handles_back(), "the torn-down fallback stops claiming");
2876
2877 push_back_press();
2878 h.driver.rebuild();
2879 assert_eq!(h.hits.get(), 0, "the fallback did not fire");
2880 }
2881
2882 /// (d) A torn-down navigator stops claiming immediately: no extra rebuild
2883 /// cycle is needed before `handles_back` turns false.
2884 #[test]
2885 fn a_torn_down_navigator_stops_claiming_without_an_extra_rebuild() {
2886 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2887 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
2888 reset();
2889
2890 let mut h = SwitchHarness::new(false);
2891 h.deepen_b(3);
2892 assert!(h.tab1.is_mounted());
2893 assert!(handles_back());
2894
2895 h.switch_away();
2896 assert!(!h.tab1.is_mounted(), "torn down at the switch");
2897 assert!(
2898 !handles_back(),
2899 "and no longer claiming, with no extra pass"
2900 );
2901 }
2902}