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: at most one live provider exists
127/// at a time, and a later [`set_can_pop_provider`] call replaces the earlier
128/// registration without warning** (the facade's `BackHandler` is expected to be
129/// constructed once, at the app root). This token is what makes that
130/// replacement safe against out-of-order teardown: [`CanPopRegistration::unregister`]
131/// clears the slot **only if this registration is still the live one**, so a
132/// replaced (stale) handler's `on_cleanup` can never clear a newer handler's
133/// provider out from under it. A future multi-handler/intercept design must
134/// replace this slot with a stack — see the module docs.
135#[must_use = "dropping the registration token without storing it makes the provider impossible to unregister scoped-safely"]
136#[derive(Debug)]
137pub struct CanPopRegistration(u64);
138
139impl CanPopRegistration {
140 /// Unregister this provider **iff it is still the live registration**;
141 /// a stale token (already replaced by a newer [`set_can_pop_provider`]
142 /// call) is a harmless no-op, leaving the newer provider intact. Safe on
143 /// any thread (a non-UI thread's slot is empty, so it no-ops).
144 pub fn unregister(self) {
145 CAN_POP_PROVIDER.with(|slot| {
146 let mut slot = slot.borrow_mut();
147 if slot.as_ref().is_some_and(|(id, _)| *id == self.0) {
148 *slot = None;
149 }
150 });
151 }
152}
153
154/// Returns the process-wide back-press counter signal, creating it (under the
155/// reactive root [`Owner`](reactive_graph::owner::Owner)) on first access.
156///
157/// # Panics
158///
159/// Panics if [`ReactiveRuntime::init`] has not run yet — reading or tracking
160/// the counter before the reactive runtime exists is a wiring bug
161/// ([`push_back_press`]'s pre-init case is handled separately, before this is
162/// ever reached): the caller must initialize the runtime first.
163fn counter() -> RwSignal<u64> {
164 *COUNTER.get_or_init(|| {
165 let rt = ReactiveRuntime::get().expect(
166 "frust-reactive: back_presses() was called before ReactiveRuntime::init — an app \
167 must run under the Frust facade's entry point (which initializes the reactive \
168 runtime) before reading back presses",
169 );
170 rt.with_owner(|| RwSignal::new(0))
171 })
172}
173
174/// Deliver a platform back press into the process-wide source. Called by a
175/// shell (the Android back callback) on the UI thread; app code never calls
176/// this directly.
177///
178/// Bumps the [`BackPresses::count`] counter by one, so a tracked reader (the
179/// facade's `BackHandler`, run under `Component::build`) observes a new event
180/// and dedupes it against the last count it consumed.
181///
182/// # Panics
183///
184/// Panics if called off the UI thread (see the module docs' thread contract).
185/// A call before [`ReactiveRuntime::init`] does **not** panic — it is dropped
186/// with a logged warning (see the module docs).
187pub fn push_back_press() {
188 if !is_ui_thread() {
189 panic!(
190 "frust-reactive: push_back_press was called off the UI thread. Back presses can \
191 only be pushed from the UI thread (the one `ReactiveRuntime::init` ran on) — this \
192 is a wiring bug: route the platform delivery through the UI thread before pushing, \
193 the same contract `push_deep_link`/`Executor::spawn_local` enforce."
194 );
195 }
196
197 if ReactiveRuntime::get().is_none() {
198 eprintln!(
199 "frust-reactive: push_back_press() dropped — ReactiveRuntime::init has not run \
200 yet. A back press before the runtime exists indicates an odd init-ordering race, \
201 not normal operation (the shell wires the back callback only after init)."
202 );
203 return;
204 }
205
206 counter().update(|c| *c += 1);
207}
208
209/// The current back-press read surface: the live [`BackPresses::count`]
210/// signal. Call from a tracked context (e.g. inside `Component::build`, via the
211/// facade's `BackHandler`) to observe subsequent presses as they arrive.
212pub fn back_presses() -> BackPresses {
213 BackPresses { count: counter() }
214}
215
216/// Publish whether the framework will consume the **next** back press (see the
217/// module docs' `handles_back` section). The facade's `BackHandler` calls this
218/// each rebuild with `controller.can_pop()`; a shell reads the answer via
219/// [`handles_back`].
220///
221/// Unlike [`push_back_press`], this carries no thread constraint (it is a plain
222/// atomic store — the `theme_override` precedent).
223pub fn set_handles_back(handles: bool) {
224 HANDLES_BACK.store(handles, Ordering::Relaxed);
225}
226
227/// Register a live provider [`handles_back`] consults to answer "would a back
228/// press pop?" against the navigator's CURRENT stack depth, not a rebuild-time
229/// snapshot. The facade's `BackHandler` registers `move || controller.can_pop()`,
230/// so a press arriving between frames — after `apply_ops` published the new
231/// depth but before the next rebuild refreshed the polled flag — is decided
232/// correctly instead of falling through to activity finish (see the module
233/// docs' timing section). A registered provider wins over
234/// [`set_handles_back`]'s flag; the returned [`CanPopRegistration`] token
235/// unregisters it scoped-safely.
236///
237/// **Single-registrant invariant:** the slot holds at most ONE provider; a
238/// second call replaces the first silently (last-writer-wins). Constructing
239/// more than one live `BackHandler` is therefore unsupported today — the
240/// replaced handler stops influencing [`handles_back`] immediately, and its
241/// later cleanup no-ops (token-guarded) rather than clearing the newer
242/// registration. A future back-intercept/stacked design
243/// must widen this slot to a stack instead of registering a second provider.
244///
245/// # Panics
246///
247/// Panics if called off the UI thread. The provider captures an `Rc`-backed
248/// `NavigatorController` (`!Send`) and lives in UI-thread `thread_local`
249/// storage, so registering it from another thread is a wiring bug — the same
250/// convention [`push_back_press`]/`Executor::spawn_local` enforce.
251pub fn set_can_pop_provider(provider: Box<dyn Fn() -> bool>) -> CanPopRegistration {
252 if !is_ui_thread() {
253 panic!(
254 "frust-reactive: set_can_pop_provider was called off the UI thread. The can-pop \
255 provider captures an Rc-backed NavigatorController (!Send) and lives in UI-thread \
256 storage — this is a wiring bug: register it from the UI thread (the one \
257 `ReactiveRuntime::init` ran on), the same contract `push_back_press`/\
258 `Executor::spawn_local` enforce."
259 );
260 }
261 let id = CAN_POP_NEXT_ID.with(|next| {
262 let id = next.get();
263 next.set(id + 1);
264 id
265 });
266 CAN_POP_PROVIDER.with(|slot| *slot.borrow_mut() = Some((id, provider)));
267 CanPopRegistration(id)
268}
269
270/// **Force-clear** the live can-pop provider unconditionally, restoring the
271/// polled [`handles_back`] fallback — a teardown/test hammer, NOT the handler
272/// cleanup path. A handler's cleanup must go through its own
273/// [`CanPopRegistration::unregister`] so a stale registration can never clear
274/// a newer one (see the single-registrant invariant on
275/// [`set_can_pop_provider`]). Idempotent — safe with no provider registered,
276/// and it carries no thread constraint (clearing another thread's empty slot
277/// is a harmless no-op).
278pub fn clear_can_pop_provider() {
279 CAN_POP_PROVIDER.with(|slot| *slot.borrow_mut() = None);
280}
281
282/// Whether the framework wants to consume the next back press. A shell polls
283/// this to decide whether a back press should be routed into the app (`true`)
284/// or fall through to the platform / activity finish (`false`, the default
285/// until glue publishes otherwise — see the module docs).
286///
287/// A live provider (see [`set_can_pop_provider`]), when registered, wins: it is
288/// queried against the navigator's CURRENT depth so a press is never mis-decided
289/// against a stale rebuild-time snapshot. Otherwise this returns the polled
290/// [`set_handles_back`] flag.
291pub fn handles_back() -> bool {
292 // A live provider reads the navigator's current stack depth at call time —
293 // the whole point of the slot is to bypass the polled flag's one-frame lag.
294 // The closure only reads the controller's depth cell (never re-enters this
295 // module), so calling it under the borrow is safe.
296 if let Some(answer) = CAN_POP_PROVIDER.with(|slot| slot.borrow().as_ref().map(|(_, f)| f())) {
297 return answer;
298 }
299 HANDLES_BACK.load(Ordering::Relaxed)
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::{FrameWaker, TrackedScope};
306 use reactive_graph::traits::{Get, GetUntracked};
307 use std::sync::Arc;
308 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
309
310 /// A recording waker: an `Arc<AtomicUsize>` bumped once per `wake()`.
311 fn recording_waker() -> (FrameWaker, Arc<AtomicUsize>) {
312 let counter = Arc::new(AtomicUsize::new(0));
313 let seen = counter.clone();
314 let waker: FrameWaker = Arc::new(move || {
315 counter.fetch_add(1, AtomicOrdering::SeqCst);
316 });
317 (waker, seen)
318 }
319
320 /// Every non-panic acceptance criterion in one `#[test]`, serialized on the
321 /// shared waker lock (`ReactiveRuntime::init` swaps the process-wide waker,
322 /// which would otherwise race the other waker-asserting tests in this
323 /// crate — see `WAKER_TEST_LOCK`'s doc comment in `lib.rs`).
324 #[test]
325 fn back_press_push_dedupe_and_handles_flag() {
326 let _guard = crate::WAKER_TEST_LOCK
327 .lock()
328 .unwrap_or_else(|e| e.into_inner());
329
330 let (waker, wakes) = recording_waker();
331 let _rt = ReactiveRuntime::init(waker);
332
333 // handles_back defaults to false (an app with no navigator lets back
334 // exit), and set/get round-trips.
335 assert!(!handles_back(), "handles_back defaults to false");
336 set_handles_back(true);
337 assert!(handles_back(), "set_handles_back(true) is observable");
338 set_handles_back(false);
339 assert!(!handles_back(), "set_handles_back(false) is observable");
340
341 // A tracked scope reading `count` starts clean; a push bumps the
342 // counter and fires the waker exactly once (coalesced signal-write).
343 let start = back_presses().count.get_untracked();
344 let scope = TrackedScope::new();
345 let seen = scope.track(|| back_presses().count.get());
346 assert_eq!(seen, start, "a fresh track observes the current count");
347 assert!(!scope.is_dirty(), "a fresh track starts clean");
348
349 let before = wakes.load(AtomicOrdering::SeqCst);
350 push_back_press();
351 assert!(
352 scope.is_dirty(),
353 "push_back_press must dirty a scope tracking `count`"
354 );
355 assert_eq!(
356 wakes.load(AtomicOrdering::SeqCst) - before,
357 1,
358 "a push must fire the waker exactly once"
359 );
360 assert_eq!(
361 back_presses().count.get_untracked(),
362 start + 1,
363 "each push increments the counter by one"
364 );
365
366 // Dedupe contract (mirrors RouterDeepLinks): a consumer that records
367 // the last-seen count no-ops on a re-read with no new push, and sees
368 // exactly one new event after another push.
369 let mut consumed = back_presses().count.get_untracked();
370 assert_eq!(
371 back_presses().count.get_untracked(),
372 consumed,
373 "no push -> the count is unchanged, so a dedup'd consumer no-ops"
374 );
375 push_back_press();
376 push_back_press();
377 let now = back_presses().count.get_untracked();
378 assert_eq!(now, consumed + 2, "two pushes advance the counter by two");
379 // A consumer catches up to the latest count in one step (it dedupes by
380 // value, not by replaying each intermediate press).
381 assert_ne!(now, consumed, "there is unconsumed back-press progress");
382 consumed = now;
383 assert_eq!(back_presses().count.get_untracked(), consumed);
384 }
385
386 /// The live can-pop provider wins over the polled `handles_back` flag and is
387 /// queried afresh each call (so it reflects the CURRENT navigator depth, not
388 /// a rebuild-time snapshot), and unregistering it restores the flag fallback.
389 /// Serializes on the waker lock since `ReactiveRuntime::init` swaps the
390 /// process-wide waker (and marks this thread as the UI thread, which
391 /// `set_can_pop_provider` requires).
392 #[test]
393 fn can_pop_provider_wins_and_unregisters() {
394 use std::cell::Cell;
395 use std::rc::Rc;
396
397 let _guard = crate::WAKER_TEST_LOCK
398 .lock()
399 .unwrap_or_else(|e| e.into_inner());
400 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
401
402 // Start from a known state: no provider, flag false.
403 clear_can_pop_provider();
404 set_handles_back(false);
405 assert!(!handles_back(), "no provider + flag false -> false");
406 set_handles_back(true);
407 assert!(
408 handles_back(),
409 "no provider -> the polled flag is the answer"
410 );
411
412 // Register a live provider driven by a local cell; point the polled flag
413 // the OPPOSITE way so the assertions can only pass if the provider wins.
414 let live = Rc::new(Cell::new(true));
415 let probe = live.clone();
416 let reg = set_can_pop_provider(Box::new(move || probe.get()));
417
418 set_handles_back(false);
419 assert!(
420 handles_back(),
421 "a registered provider wins over the (opposite) polled flag"
422 );
423
424 // The provider is queried live each call — flipping the cell (as a
425 // post-apply_ops depth change would) is observed immediately, with no
426 // rebuild in between: the whole point of the slot.
427 live.set(false);
428 set_handles_back(true);
429 assert!(
430 !handles_back(),
431 "the provider is re-queried live, not cached, and still wins"
432 );
433
434 // Unregistering (token-scoped) restores the polled-flag fallback.
435 reg.unregister();
436 assert!(handles_back(), "after clear, the flag (true) answers again");
437 set_handles_back(false);
438 assert!(!handles_back(), "fallback tracks the flag once more");
439
440 // Idempotent: a force-clear with nothing registered is a safe no-op.
441 clear_can_pop_provider();
442 assert!(!handles_back());
443 }
444
445 /// The single-registrant invariant's token guard: a REPLACED registration's
446 /// cleanup must never clear the newer provider out from under it — the
447 /// exact out-of-order-teardown hazard this guard exists to prevent (a stale
448 /// `on_cleanup` firing after a second `BackHandler` registered would
449 /// silently revert `handles_back` to the polled fallback).
450 #[test]
451 fn stale_unregister_never_clears_a_newer_registration() {
452 let _guard = crate::WAKER_TEST_LOCK
453 .lock()
454 .unwrap_or_else(|e| e.into_inner());
455 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
456
457 clear_can_pop_provider();
458 set_handles_back(false);
459
460 let reg1 = set_can_pop_provider(Box::new(|| false));
461 assert!(!handles_back(), "first provider answers false");
462
463 // A second registration replaces the first (last-writer-wins).
464 let reg2 = set_can_pop_provider(Box::new(|| true));
465 assert!(handles_back(), "second provider replaced the first");
466
467 // The STALE token's cleanup is a no-op — the live provider survives.
468 reg1.unregister();
469 assert!(
470 handles_back(),
471 "stale unregister must not clear the newer registration"
472 );
473
474 // The live token's cleanup clears for real, restoring the fallback.
475 reg2.unregister();
476 assert!(!handles_back(), "live unregister restores the polled flag");
477 }
478
479 /// The UI-thread contract is enforced (mirrors `push_deep_link`'s
480 /// off-thread panic). Serializes on the waker lock since
481 /// `ReactiveRuntime::init` swaps the process-wide waker.
482 #[test]
483 #[should_panic(expected = "wiring bug")]
484 fn push_off_ui_thread_panics() {
485 let _guard = crate::WAKER_TEST_LOCK
486 .lock()
487 .unwrap_or_else(|e| e.into_inner());
488 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
489
490 std::thread::spawn(push_back_press)
491 .join()
492 .unwrap_or_else(|e| std::panic::resume_unwind(e));
493 }
494
495 /// Registering the live provider off the UI thread is a wiring bug and
496 /// panics — the closure captures an `Rc`-backed controller (`!Send`), so it
497 /// can only live in the UI thread's storage. Serializes on the waker lock
498 /// since `ReactiveRuntime::init` swaps the process-wide waker.
499 #[test]
500 #[should_panic(expected = "wiring bug")]
501 fn set_provider_off_ui_thread_panics() {
502 let _guard = crate::WAKER_TEST_LOCK
503 .lock()
504 .unwrap_or_else(|e| e.into_inner());
505 let _rt = ReactiveRuntime::init(Arc::new(|| {}));
506
507 std::thread::spawn(|| {
508 let _ = set_can_pop_provider(Box::new(|| true));
509 })
510 .join()
511 .unwrap_or_else(|e| std::panic::resume_unwind(e));
512 }
513}