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}