Skip to main content

frust_reactive/
back.rs

1//! Process-wide Android **back-press** source + a "framework handles back"
2//! flag: a shell delivers
3//! a hardware/gesture back press via [`push_back_press`]; app/facade glue
4//! reads the resulting event through [`back_presses`]/[`BackPresses`] and
5//! publishes, via [`set_handles_back`], whether the framework wants to consume
6//! the *next* press so a shell polling [`handles_back`] knows whether a
7//! root-level back should fall through to the platform (activity finish).
8//!
9//! This mirrors the deep-link source next door (`frust-reactive::deep_link`)
10//! in both shape and layering: `frust-reactive` stays router/navigator-free,
11//! and the `frust` facade is the only crate that wires this source to a
12//! `NavigatorController` (see `frust::back_glue`).
13//!
14//! # A counter, not a boolean
15//!
16//! Each back press is an *event*, so the source is a monotonically increasing
17//! [`RwSignal<u64>`] counter (via [`BackPresses::count`]), not a `bool` "back
18//! pressed" flag. App glue dedupes by the last value it consumed — exactly the
19//! `RouterDeepLinks` consumed-marker pattern — so a rebuild that re-reads the
20//! same count does not re-pop. A counter (rather than a value payload) is
21//! enough because a back press carries no data: only "another one happened".
22//!
23//! # `handles_back`: default false
24//!
25//! [`handles_back`] backs a shell's `setFrameworkHandlesBack`-style decision:
26//! the framework pre-registers whether it will consume the next
27//! back. It is a process-global [`AtomicBool`] (the `theme_override` polling
28//! precedent — a plain flag a shell reads, no reactive tracking), and it
29//! **defaults to `false`**: an app with no navigator (nothing to pop) must let
30//! a back press exit, matching Flutter's "no routes to pop → bubble →
31//! `SystemNavigator.pop`". The facade's `BackHandler` refreshes it to
32//! `controller.can_pop()` each rebuild.
33//!
34//! # Timing: a live provider closes the stale-window
35//!
36//! [`handles_back`] has two sources. The polled [`set_handles_back`] flag is
37//! refreshed at **rebuild time**, so it is stale by up to one frame — a press
38//! racing a same-frame stack change reads the *previous* answer, and if the
39//! frame gate skips the settle frame that stale answer can persist. On its own
40//! that window can let a root-level back fall through to activity-finish while
41//! the stack is still poppable.
42//!
43//! To close it, the facade's `BackHandler` also registers a **live provider**
44//! (see [`set_can_pop_provider`]) that [`handles_back`] consults *first*: the
45//! provider reads the navigator's CURRENT stack depth at press time — after the
46//! navigator's `apply_ops` has published it — so a back press is always decided
47//! against the real depth rather than a rebuild-time snapshot, with no
48//! one-frame lag. The polled flag stays the fallback when no provider is
49//! registered (an app with no `BackHandler`); there the navigator's own
50//! `len > 1` guard still makes a mis-predicted root-level back a safe no-op pop
51//! rather than an incorrect navigation.
52//!
53//! # Thread contract
54//!
55//! [`push_back_press`] must be called on the UI thread — the one
56//! [`ReactiveRuntime::init`] ran on — mirroring [`push_deep_link`](crate::push_deep_link)'s
57//! contract: a mobile shell's native back callback always runs on the UI
58//! thread, so an off-thread call is a wiring bug and panics. A press that
59//! races ahead of [`ReactiveRuntime::init`] is dropped with a logged warning
60//! rather than panicking or buffering (the same rationale as the deep-link
61//! source). [`set_handles_back`]/[`handles_back`], like `theme_override`, carry
62//! no thread constraint — a shell polls `handles_back` from its own frame loop.
63//! The **live provider** slot ([`set_can_pop_provider`]/[`clear_can_pop_provider`])
64//! is the exception: its closure captures an `Rc`-backed `NavigatorController`
65//! (`!Send`), so it lives in UI-thread-affine `thread_local` storage and
66//! `set_can_pop_provider` panics if called off the UI thread — the same
67//! wiring-bug convention `push_back_press`/`Executor::spawn_local` enforce.
68
69use std::cell::RefCell;
70use std::sync::OnceLock;
71use std::sync::atomic::{AtomicBool, Ordering};
72
73use reactive_graph::signal::RwSignal;
74use reactive_graph::traits::Update;
75
76use crate::ReactiveRuntime;
77use crate::executor::is_ui_thread;
78
79/// The app-facing back-press read surface (see the module docs). Obtained via
80/// [`back_presses`] (`frust::back_presses()` at the facade).
81#[derive(Clone)]
82pub struct BackPresses {
83    /// A monotonically increasing count of back presses delivered this
84    /// process. Read/track it with the `Get`/`Track` traits the same way any
85    /// other `RwSignal` is read; glue dedupes by comparing it against the last
86    /// value it consumed (see the module docs).
87    pub count: RwSignal<u64>,
88}
89
90/// The process-wide back-press counter signal. Lazily created (mirroring
91/// [`deep_link`](crate::deep_link)'s slot) under the reactive root
92/// [`Owner`](reactive_graph::owner::Owner) on first access once
93/// [`ReactiveRuntime`] exists.
94static COUNTER: OnceLock<RwSignal<u64>> = OnceLock::new();
95
96/// The process-global "framework handles the next back press" flag. A plain
97/// [`AtomicBool`] (no reactive tracking — a shell polls it), defaulting to
98/// `false` (see the module docs' `handles_back` section). The **fallback**
99/// answer [`handles_back`] returns when no live provider is registered.
100static HANDLES_BACK: AtomicBool = AtomicBool::new(false);
101
102thread_local! {
103    /// The live "can the framework pop?" provider (see the module docs' timing
104    /// section). UI-thread-affine because the closure the facade's `BackHandler`
105    /// registers captures an `Rc`-backed `NavigatorController` (`!Send`); it
106    /// therefore lives here in `thread_local` storage rather than a global, and
107    /// [`set_can_pop_provider`] panics off the UI thread. When set,
108    /// [`handles_back`] consults it in preference to the polled [`HANDLES_BACK`]
109    /// flag, so a back press reads the navigator's CURRENT depth (post-
110    /// `apply_ops`) rather than a stale rebuild-time snapshot.
111    static CAN_POP_PROVIDER: RefCell<Option<RegisteredProvider>> =
112        const { RefCell::new(None) };
113
114    /// Monotonic id source for [`CanPopRegistration`] tokens (UI-thread-only,
115    /// like the slot itself), so a stale registration's cleanup can be told
116    /// apart from the live one's.
117    static CAN_POP_NEXT_ID: std::cell::Cell<u64> = const { std::cell::Cell::new(1) };
118}
119
120/// A registered live provider: its registration id + the closure itself
121/// (see [`CanPopRegistration`] for the id's role).
122type RegisteredProvider = (u64, Box<dyn Fn() -> bool>);
123
124/// Proof-of-registration token returned by [`set_can_pop_provider`].
125///
126/// **The provider slot is single-registrant by design: at most one live provider
127/// exists at a time, and a later [`set_can_pop_provider`] call replaces the
128/// earlier registration without warning.** The single slot does not limit how
129/// many navigators or `BackHandler`s an app may have: the `frust` facade
130/// registers exactly one provider, a closure that multiplexes over its own
131/// ordered registrant list (arbitration is documented in the `frust::back_glue`
132/// module docs). This token is what makes replacement safe against
133/// out-of-order teardown: [`CanPopRegistration::unregister`] clears the slot
134/// **only if this registration is still the live one**, so a replaced (stale)
135/// registrant's `on_cleanup` can never clear a newer provider out from under it.
136#[must_use = "dropping the registration token without storing it makes the provider impossible to unregister scoped-safely"]
137#[derive(Debug)]
138pub struct CanPopRegistration(u64);
139
140impl CanPopRegistration {
141    /// Unregister this provider **iff it is still the live registration**;
142    /// a stale token (already replaced by a newer [`set_can_pop_provider`]
143    /// call) is a harmless no-op, leaving the newer provider intact. Safe on
144    /// any thread (a non-UI thread's slot is empty, so it no-ops).
145    pub fn unregister(self) {
146        CAN_POP_PROVIDER.with(|slot| {
147            let mut slot = slot.borrow_mut();
148            if slot.as_ref().is_some_and(|(id, _)| *id == self.0) {
149                *slot = None;
150            }
151        });
152    }
153}
154
155/// Returns the process-wide back-press counter signal, creating it (under the
156/// reactive root [`Owner`](reactive_graph::owner::Owner)) on first access.
157///
158/// # Panics
159///
160/// Panics if [`ReactiveRuntime::init`] has not run yet — reading or tracking
161/// the counter before the reactive runtime exists is a wiring bug
162/// ([`push_back_press`]'s pre-init case is handled separately, before this is
163/// ever reached): the caller must initialize the runtime first.
164fn counter() -> RwSignal<u64> {
165    *COUNTER.get_or_init(|| {
166        let rt = ReactiveRuntime::get().expect(
167            "frust-reactive: back_presses() was called before ReactiveRuntime::init — an app \
168             must run under the Frust facade's entry point (which initializes the reactive \
169             runtime) before reading back presses",
170        );
171        rt.with_owner(|| RwSignal::new(0))
172    })
173}
174
175/// Deliver a platform back press into the process-wide source. Called by a
176/// shell (the Android back callback) on the UI thread; app code never calls
177/// this directly.
178///
179/// Bumps the [`BackPresses::count`] counter by one, so a tracked reader (the
180/// facade's `BackHandler`, run under `Component::build`) observes a new event
181/// and dedupes it against the last count it consumed.
182///
183/// # Panics
184///
185/// Panics if called off the UI thread (see the module docs' thread contract).
186/// A call before [`ReactiveRuntime::init`] does **not** panic — it is dropped
187/// with a logged warning (see the module docs).
188pub fn push_back_press() {
189    if !is_ui_thread() {
190        panic!(
191            "frust-reactive: push_back_press was called off the UI thread. Back presses can \
192             only be pushed from the UI thread (the one `ReactiveRuntime::init` ran on) — this \
193             is a wiring bug: route the platform delivery through the UI thread before pushing, \
194             the same contract `push_deep_link`/`Executor::spawn_local` enforce."
195        );
196    }
197
198    if ReactiveRuntime::get().is_none() {
199        eprintln!(
200            "frust-reactive: push_back_press() dropped — ReactiveRuntime::init has not run \
201             yet. A back press before the runtime exists indicates an odd init-ordering race, \
202             not normal operation (the shell wires the back callback only after init)."
203        );
204        return;
205    }
206
207    counter().update(|c| *c += 1);
208}
209
210/// The current back-press read surface: the live [`BackPresses::count`]
211/// signal. Call from a tracked context (e.g. inside `Component::build`, via the
212/// facade's `BackHandler`) to observe subsequent presses as they arrive.
213pub fn back_presses() -> BackPresses {
214    BackPresses { count: counter() }
215}
216
217/// Publish whether the framework will consume the **next** back press (see the
218/// module docs' `handles_back` section). The facade's `BackHandler` calls this
219/// each rebuild with `controller.can_pop()`; a shell reads the answer via
220/// [`handles_back`].
221///
222/// Unlike [`push_back_press`], this carries no thread constraint (it is a plain
223/// atomic store — the `theme_override` precedent).
224pub fn set_handles_back(handles: bool) {
225    HANDLES_BACK.store(handles, Ordering::Relaxed);
226}
227
228/// Register a live provider [`handles_back`] consults to answer "would a back
229/// press pop?" against the navigator's CURRENT stack depth, not a rebuild-time
230/// snapshot. The facade's `BackHandler` registers `move || controller.can_pop()`,
231/// so a press arriving between frames — after `apply_ops` published the new
232/// depth but before the next rebuild refreshed the polled flag — is decided
233/// correctly instead of falling through to activity finish (see the module
234/// docs' timing section). A registered provider wins over
235/// [`set_handles_back`]'s flag; the returned [`CanPopRegistration`] token
236/// unregisters it scoped-safely.
237///
238/// **Single-registrant invariant:** the slot holds at most ONE provider; a
239/// second call replaces the first silently (last-writer-wins). This binds only
240/// DIRECT callers of this function. The `frust` facade registers its
241/// multiplexer here exactly once, and that multiplexer consults every
242/// navigator and `BackHandler` it has wired, so many of each are supported
243/// (arbitration is documented in the `frust::back_glue` module docs). Any
244/// other registration here displaces the facade's multiplexer: it stops
245/// influencing [`handles_back`] immediately, and the displaced registration's
246/// later cleanup no-ops (token-guarded) rather than clearing the newer one.
247///
248/// # Panics
249///
250/// Panics if called off the UI thread. The provider captures an `Rc`-backed
251/// `NavigatorController` (`!Send`) and lives in UI-thread `thread_local`
252/// storage, so registering it from another thread is a wiring bug — the same
253/// convention [`push_back_press`]/`Executor::spawn_local` enforce.
254pub fn set_can_pop_provider(provider: Box<dyn Fn() -> bool>) -> CanPopRegistration {
255    if !is_ui_thread() {
256        panic!(
257            "frust-reactive: set_can_pop_provider was called off the UI thread. The can-pop \
258             provider captures an Rc-backed NavigatorController (!Send) and lives in UI-thread \
259             storage — this is a wiring bug: register it from the UI thread (the one \
260             `ReactiveRuntime::init` ran on), the same contract `push_back_press`/\
261             `Executor::spawn_local` enforce."
262        );
263    }
264    let id = CAN_POP_NEXT_ID.with(|next| {
265        let id = next.get();
266        next.set(id + 1);
267        id
268    });
269    CAN_POP_PROVIDER.with(|slot| *slot.borrow_mut() = Some((id, provider)));
270    CanPopRegistration(id)
271}
272
273/// **Force-clear** the live can-pop provider unconditionally, restoring the
274/// polled [`handles_back`] fallback — a teardown/test hammer, NOT the handler
275/// cleanup path. A handler's cleanup must go through its own
276/// [`CanPopRegistration::unregister`] so a stale registration can never clear
277/// a newer one (see the single-registrant invariant on
278/// [`set_can_pop_provider`]). Idempotent — safe with no provider registered,
279/// and it carries no thread constraint (clearing another thread's empty slot
280/// is a harmless no-op).
281pub fn clear_can_pop_provider() {
282    CAN_POP_PROVIDER.with(|slot| *slot.borrow_mut() = None);
283}
284
285/// Whether the framework wants to consume the next back press. A shell polls
286/// this to decide whether a back press should be routed into the app (`true`)
287/// or fall through to the platform / activity finish (`false`, the default
288/// until glue publishes otherwise — see the module docs).
289///
290/// A live provider (see [`set_can_pop_provider`]), when registered, wins: it is
291/// queried against the navigator's CURRENT depth so a press is never mis-decided
292/// against a stale rebuild-time snapshot. Otherwise this returns the polled
293/// [`set_handles_back`] flag.
294pub fn handles_back() -> bool {
295    // A live provider reads the navigator's current stack depth at call time —
296    // the whole point of the slot is to bypass the polled flag's one-frame lag.
297    // The closure only reads the controller's depth cell (never re-enters this
298    // module), so calling it under the borrow is safe.
299    if let Some(answer) = CAN_POP_PROVIDER.with(|slot| slot.borrow().as_ref().map(|(_, f)| f())) {
300        return answer;
301    }
302    HANDLES_BACK.load(Ordering::Relaxed)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::{FrameWaker, TrackedScope};
309    use reactive_graph::traits::{Get, GetUntracked};
310    use std::sync::Arc;
311    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
312
313    /// A recording waker: an `Arc<AtomicUsize>` bumped once per `wake()`.
314    fn recording_waker() -> (FrameWaker, Arc<AtomicUsize>) {
315        let counter = Arc::new(AtomicUsize::new(0));
316        let seen = counter.clone();
317        let waker: FrameWaker = Arc::new(move || {
318            counter.fetch_add(1, AtomicOrdering::SeqCst);
319        });
320        (waker, seen)
321    }
322
323    /// Every non-panic acceptance criterion in one `#[test]`, serialized on the
324    /// shared waker lock (`ReactiveRuntime::init` swaps the process-wide waker,
325    /// which would otherwise race the other waker-asserting tests in this
326    /// crate — see `WAKER_TEST_LOCK`'s doc comment in `lib.rs`).
327    #[test]
328    fn back_press_push_dedupe_and_handles_flag() {
329        let _guard = crate::WAKER_TEST_LOCK
330            .lock()
331            .unwrap_or_else(|e| e.into_inner());
332
333        let (waker, wakes) = recording_waker();
334        let _rt = ReactiveRuntime::init(waker);
335
336        // handles_back defaults to false (an app with no navigator lets back
337        // exit), and set/get round-trips.
338        assert!(!handles_back(), "handles_back defaults to false");
339        set_handles_back(true);
340        assert!(handles_back(), "set_handles_back(true) is observable");
341        set_handles_back(false);
342        assert!(!handles_back(), "set_handles_back(false) is observable");
343
344        // A tracked scope reading `count` starts clean; a push bumps the
345        // counter and fires the waker exactly once (coalesced signal-write).
346        let start = back_presses().count.get_untracked();
347        let scope = TrackedScope::new();
348        let seen = scope.track(|| back_presses().count.get());
349        assert_eq!(seen, start, "a fresh track observes the current count");
350        assert!(!scope.is_dirty(), "a fresh track starts clean");
351
352        let before = wakes.load(AtomicOrdering::SeqCst);
353        push_back_press();
354        assert!(
355            scope.is_dirty(),
356            "push_back_press must dirty a scope tracking `count`"
357        );
358        assert_eq!(
359            wakes.load(AtomicOrdering::SeqCst) - before,
360            1,
361            "a push must fire the waker exactly once"
362        );
363        assert_eq!(
364            back_presses().count.get_untracked(),
365            start + 1,
366            "each push increments the counter by one"
367        );
368
369        // Dedupe contract (mirrors RouterDeepLinks): a consumer that records
370        // the last-seen count no-ops on a re-read with no new push, and sees
371        // exactly one new event after another push.
372        let mut consumed = back_presses().count.get_untracked();
373        assert_eq!(
374            back_presses().count.get_untracked(),
375            consumed,
376            "no push -> the count is unchanged, so a dedup'd consumer no-ops"
377        );
378        push_back_press();
379        push_back_press();
380        let now = back_presses().count.get_untracked();
381        assert_eq!(now, consumed + 2, "two pushes advance the counter by two");
382        // A consumer catches up to the latest count in one step (it dedupes by
383        // value, not by replaying each intermediate press).
384        assert_ne!(now, consumed, "there is unconsumed back-press progress");
385        consumed = now;
386        assert_eq!(back_presses().count.get_untracked(), consumed);
387    }
388
389    /// The live can-pop provider wins over the polled `handles_back` flag and is
390    /// queried afresh each call (so it reflects the CURRENT navigator depth, not
391    /// a rebuild-time snapshot), and unregistering it restores the flag fallback.
392    /// Serializes on the waker lock since `ReactiveRuntime::init` swaps the
393    /// process-wide waker (and marks this thread as the UI thread, which
394    /// `set_can_pop_provider` requires).
395    #[test]
396    fn can_pop_provider_wins_and_unregisters() {
397        use std::cell::Cell;
398        use std::rc::Rc;
399
400        let _guard = crate::WAKER_TEST_LOCK
401            .lock()
402            .unwrap_or_else(|e| e.into_inner());
403        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
404
405        // Start from a known state: no provider, flag false.
406        clear_can_pop_provider();
407        set_handles_back(false);
408        assert!(!handles_back(), "no provider + flag false -> false");
409        set_handles_back(true);
410        assert!(
411            handles_back(),
412            "no provider -> the polled flag is the answer"
413        );
414
415        // Register a live provider driven by a local cell; point the polled flag
416        // the OPPOSITE way so the assertions can only pass if the provider wins.
417        let live = Rc::new(Cell::new(true));
418        let probe = live.clone();
419        let reg = set_can_pop_provider(Box::new(move || probe.get()));
420
421        set_handles_back(false);
422        assert!(
423            handles_back(),
424            "a registered provider wins over the (opposite) polled flag"
425        );
426
427        // The provider is queried live each call — flipping the cell (as a
428        // post-apply_ops depth change would) is observed immediately, with no
429        // rebuild in between: the whole point of the slot.
430        live.set(false);
431        set_handles_back(true);
432        assert!(
433            !handles_back(),
434            "the provider is re-queried live, not cached, and still wins"
435        );
436
437        // Unregistering (token-scoped) restores the polled-flag fallback.
438        reg.unregister();
439        assert!(handles_back(), "after clear, the flag (true) answers again");
440        set_handles_back(false);
441        assert!(!handles_back(), "fallback tracks the flag once more");
442
443        // Idempotent: a force-clear with nothing registered is a safe no-op.
444        clear_can_pop_provider();
445        assert!(!handles_back());
446    }
447
448    /// The single-registrant invariant's token guard: a REPLACED registration's
449    /// cleanup must never clear the newer provider out from under it — the
450    /// exact out-of-order-teardown hazard this guard exists to prevent (a stale
451    /// `on_cleanup` firing after a second `BackHandler` registered would
452    /// silently revert `handles_back` to the polled fallback).
453    #[test]
454    fn stale_unregister_never_clears_a_newer_registration() {
455        let _guard = crate::WAKER_TEST_LOCK
456            .lock()
457            .unwrap_or_else(|e| e.into_inner());
458        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
459
460        clear_can_pop_provider();
461        set_handles_back(false);
462
463        let reg1 = set_can_pop_provider(Box::new(|| false));
464        assert!(!handles_back(), "first provider answers false");
465
466        // A second registration replaces the first (last-writer-wins).
467        let reg2 = set_can_pop_provider(Box::new(|| true));
468        assert!(handles_back(), "second provider replaced the first");
469
470        // The STALE token's cleanup is a no-op — the live provider survives.
471        reg1.unregister();
472        assert!(
473            handles_back(),
474            "stale unregister must not clear the newer registration"
475        );
476
477        // The live token's cleanup clears for real, restoring the fallback.
478        reg2.unregister();
479        assert!(!handles_back(), "live unregister restores the polled flag");
480    }
481
482    /// The UI-thread contract is enforced (mirrors `push_deep_link`'s
483    /// off-thread panic). Serializes on the waker lock since
484    /// `ReactiveRuntime::init` swaps the process-wide waker.
485    #[test]
486    #[should_panic(expected = "wiring bug")]
487    fn push_off_ui_thread_panics() {
488        let _guard = crate::WAKER_TEST_LOCK
489            .lock()
490            .unwrap_or_else(|e| e.into_inner());
491        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
492
493        std::thread::spawn(push_back_press)
494            .join()
495            .unwrap_or_else(|e| std::panic::resume_unwind(e));
496    }
497
498    /// Registering the live provider off the UI thread is a wiring bug and
499    /// panics — the closure captures an `Rc`-backed controller (`!Send`), so it
500    /// can only live in the UI thread's storage. Serializes on the waker lock
501    /// since `ReactiveRuntime::init` swaps the process-wide waker.
502    #[test]
503    #[should_panic(expected = "wiring bug")]
504    fn set_provider_off_ui_thread_panics() {
505        let _guard = crate::WAKER_TEST_LOCK
506            .lock()
507            .unwrap_or_else(|e| e.into_inner());
508        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
509
510        std::thread::spawn(|| {
511            let _ = set_can_pop_provider(Box::new(|| true));
512        })
513        .join()
514        .unwrap_or_else(|e| std::panic::resume_unwind(e));
515    }
516}