Skip to main content

frust_widgets/nav/
route.rs

1//! [`RouteNavigator`]: the thread-safe, context-reachable navigation seam — a
2//! queue of *data* requests any code can append to, drained and applied by the
3//! [`Router`](super::router::Router) on the UI thread.
4//!
5//! # Why data, not behaviour
6//!
7//! `provide_context`/`use_context` require `T: Send + Sync + 'static`, and
8//! neither of the router's own types can ever satisfy that: a
9//! [`Router`](super::router::Router) holds `Rc<dyn Fn(&RouteParams) -> AnyView>`
10//! page builders and a
11//! [`NavigatorController`](super::navigator::NavigatorController) holds an
12//! `Rc<RefCell<…>>` op queue. A screen therefore cannot reach the router from a
13//! callback — only from a parameter its caller threaded down by hand.
14//!
15//! `RouteNavigator` breaks that by carrying **no behaviour at all**: it is an
16//! `Arc<Mutex<Vec<NavRequest>>>` of plain strings plus a `Send + Sync` waker.
17//! Anyone, on any thread, appends a [`NavRequest`]; the router drains it in
18//! [`Router::pump`](super::router::Router::pump) — which the facade calls at the
19//! top of each rebuild — and applies each request against its own route table.
20//! Because the queue is plain data, the whole type rides `provide_context`, and
21//! the module pins that with a compile-time assertion (below): if a future edit
22//! ever puts an `Rc`, a `Router`, or a `State`-generic closure in here, the
23//! crate stops compiling rather than silently regressing to unreachable-again.
24//!
25//! # Threading
26//!
27//! **Off-thread use is total, not a panic.** Every method here may be called
28//! from any thread: appending takes the queue mutex briefly and then fires the
29//! waker (outside the lock) so the shell schedules a frame. This is deliberately
30//! unlike `frust_reactive::set_can_pop_provider`, which *panics* off the UI
31//! thread because it stores an `Rc` in UI-thread-local storage — the whole point
32//! of this shape is that a background task can request navigation without
33//! marshalling to the UI thread first. A poisoned mutex is recovered
34//! (`into_inner`) rather than propagated: a panic elsewhere must not make
35//! navigation permanently unusable.
36//!
37//! # Latency
38//!
39//! Zero frames. A request appended during an event handler is applied on the
40//! very next rebuild: the app's `Component::build` (which calls the facade's
41//! `RouterDeepLinks::track`, hence `pump`) runs *before* the navigator's
42//! `rebuild`, so the enqueued controller ops are drained in that same reconcile
43//! pass.
44
45use std::fmt;
46use std::sync::{Arc, Mutex, MutexGuard};
47
48use super::path::{Location, RouteParams};
49
50/// A "wake the shell" callback: cheaply cloneable and callable from any thread,
51/// mirroring `frust_reactive`'s `FrameWaker`. `frust-widgets` is reactive-free,
52/// so the waker stays opaque here — the facade installs
53/// `ReactiveRuntime::wake` when it wires the router up.
54pub type NavWaker = Arc<dyn Fn() + Send + Sync>;
55
56/// One queued navigation request: pure data (paths, names, params), never a
57/// closure or a page. Applied by [`Router::pump`](super::router::Router::pump)
58/// against the router's own route table, so the request and the resolution stay
59/// on opposite sides of the `Send + Sync` boundary.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub enum NavRequest {
62    /// Reset the stack to the matched chain — [`Router::go`](super::router::Router::go).
63    Go(String),
64    /// Stack the matched leaf — [`Router::push`](super::router::Router::push).
65    Push(String),
66    /// Replace the top page with the matched leaf —
67    /// [`Router::replace`](super::router::Router::replace).
68    Replace(String),
69    /// Pop the top page — [`Router::pop`](super::router::Router::pop).
70    Pop,
71    /// [`Router::go_named`](super::router::Router::go_named).
72    GoNamed {
73        /// The route's `name`.
74        name: String,
75        /// Params substituted into the named route's pattern; leftovers become
76        /// query parameters.
77        params: RouteParams,
78    },
79    /// [`Router::push_named`](super::router::Router::push_named).
80    PushNamed {
81        /// The route's `name`.
82        name: String,
83        /// Params substituted into the named route's pattern; leftovers become
84        /// query parameters.
85        params: RouteParams,
86    },
87}
88
89/// The shared inner state. Two independent mutexes so publishing a location
90/// (router side) never contends with an append (caller side), plus the waker in
91/// its own slot because it is replaced at most once, at wiring time.
92struct RouteNavInner {
93    queue: Mutex<Vec<NavRequest>>,
94    waker: Mutex<Option<NavWaker>>,
95    location: Mutex<Option<Location>>,
96}
97
98/// The context-reachable navigation handle: `Send + Sync + 'static`, cheap to
99/// clone (one `Arc`), and safe to hold in app state, in a callback, or under
100/// `provide_context`. See the [module docs](self) for why it carries data
101/// instead of a router reference.
102///
103/// Obtain one from
104/// [`Router::route_navigator`](super::router::Router::route_navigator); the
105/// router it came from applies whatever is queued on its next
106/// [`pump`](super::router::Router::pump).
107pub struct RouteNavigator {
108    inner: Arc<RouteNavInner>,
109}
110
111/// The seam's whole point, pinned at compile time: if `RouteNavigator` ever
112/// stops being `Send + Sync + 'static` it can no longer ride `provide_context`,
113/// and this becomes a compile error rather than a review finding.
114const _: fn() = || {
115    fn assert<T: Send + Sync + 'static>() {}
116    assert::<RouteNavigator>();
117};
118
119impl RouteNavigator {
120    /// A fresh, empty navigator with no waker installed. Apps get one from
121    /// [`Router::route_navigator`](super::router::Router::route_navigator)
122    /// rather than constructing it directly.
123    pub fn new() -> Self {
124        RouteNavigator {
125            inner: Arc::new(RouteNavInner {
126                queue: Mutex::new(Vec::new()),
127                waker: Mutex::new(None),
128                location: Mutex::new(None),
129            }),
130        }
131    }
132
133    /// Queue `request` and fire the waker. The single append path every
134    /// convenience method below routes through.
135    pub fn request(&self, request: NavRequest) {
136        lock(&self.inner.queue).push(request);
137        self.wake();
138    }
139
140    /// Queue a [`NavRequest::Go`] — reset the stack to `location`'s chain.
141    pub fn go(&self, location: impl Into<String>) {
142        self.request(NavRequest::Go(location.into()));
143    }
144
145    /// Queue a [`NavRequest::Push`] — stack `location`'s leaf page.
146    pub fn push(&self, location: impl Into<String>) {
147        self.request(NavRequest::Push(location.into()));
148    }
149
150    /// Queue a [`NavRequest::Replace`] — swap the top page for `location`'s
151    /// leaf.
152    pub fn replace(&self, location: impl Into<String>) {
153        self.request(NavRequest::Replace(location.into()));
154    }
155
156    /// Queue a [`NavRequest::Pop`].
157    pub fn pop(&self) {
158        self.request(NavRequest::Pop);
159    }
160
161    /// Queue a [`NavRequest::GoNamed`].
162    pub fn go_named(&self, name: impl Into<String>, params: RouteParams) {
163        self.request(NavRequest::GoNamed {
164            name: name.into(),
165            params,
166        });
167    }
168
169    /// Queue a [`NavRequest::PushNamed`].
170    pub fn push_named(&self, name: impl Into<String>, params: RouteParams) {
171        self.request(NavRequest::PushNamed {
172            name: name.into(),
173            params,
174        });
175    }
176
177    /// The last [`Location`] the router *resolved* (post-redirect), including
178    /// the one an unmatched location fell back to the error page with.
179    /// `None` before the first navigation.
180    ///
181    /// Not updated by [`pop`](Self::pop): the router keeps no history stack, so
182    /// after a pop this still reports the location that pushed the popped page.
183    pub fn location(&self) -> Option<Location> {
184        lock(&self.inner.location).clone()
185    }
186
187    /// Install the "wake the shell" callback fired after every append. The
188    /// facade installs `ReactiveRuntime::wake` when it wires the router; a
189    /// navigator with no waker still queues correctly, it just relies on
190    /// something else to schedule the next frame.
191    pub fn set_waker(&self, waker: NavWaker) {
192        *lock(&self.inner.waker) = Some(waker);
193    }
194
195    /// Take every queued request in append order, leaving the queue empty.
196    /// [`Router::pump`](super::router::Router::pump) is the only caller in
197    /// normal use.
198    pub fn drain(&self) -> Vec<NavRequest> {
199        std::mem::take(&mut *lock(&self.inner.queue))
200    }
201
202    /// Publish the location the router just resolved (see
203    /// [`location`](Self::location)).
204    pub(super) fn set_location(&self, location: Location) {
205        *lock(&self.inner.location) = Some(location);
206    }
207
208    /// Fire the installed waker, if any — cloned out first so the mutex is not
209    /// held across a callback that may re-enter.
210    fn wake(&self) {
211        let waker = lock(&self.inner.waker).clone();
212        if let Some(waker) = waker {
213            waker();
214        }
215    }
216}
217
218impl Clone for RouteNavigator {
219    fn clone(&self) -> Self {
220        RouteNavigator {
221            inner: Arc::clone(&self.inner),
222        }
223    }
224}
225
226impl Default for RouteNavigator {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232impl fmt::Debug for RouteNavigator {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        f.debug_struct("RouteNavigator")
235            .field("pending", &lock(&self.inner.queue).len())
236            .field("location", &*lock(&self.inner.location))
237            .finish_non_exhaustive()
238    }
239}
240
241/// Lock a slot, recovering from poisoning rather than propagating it (see the
242/// [module docs](self)' threading note: a panic elsewhere must not make
243/// navigation permanently unusable).
244fn lock<T>(slot: &Mutex<T>) -> MutexGuard<'_, T> {
245    slot.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use std::sync::atomic::{AtomicUsize, Ordering};
252
253    fn params(pairs: &[(&str, &str)]) -> RouteParams {
254        pairs
255            .iter()
256            .map(|(k, v)| (k.to_string(), v.to_string()))
257            .collect()
258    }
259
260    #[test]
261    fn queues_in_append_order_and_drains_once() {
262        let nav = RouteNavigator::new();
263        nav.go("/home");
264        nav.push("/detail");
265        nav.replace("/other");
266        nav.pop();
267        nav.go_named("user", params(&[("id", "7")]));
268        nav.push_named("user", params(&[("id", "8")]));
269
270        assert_eq!(
271            nav.drain(),
272            vec![
273                NavRequest::Go("/home".to_string()),
274                NavRequest::Push("/detail".to_string()),
275                NavRequest::Replace("/other".to_string()),
276                NavRequest::Pop,
277                NavRequest::GoNamed {
278                    name: "user".to_string(),
279                    params: params(&[("id", "7")]),
280                },
281                NavRequest::PushNamed {
282                    name: "user".to_string(),
283                    params: params(&[("id", "8")]),
284                },
285            ]
286        );
287        // Draining is destructive: a second pump sees nothing.
288        assert!(nav.drain().is_empty());
289    }
290
291    #[test]
292    fn waker_fires_once_per_append() {
293        let nav = RouteNavigator::new();
294        let woke = Arc::new(AtomicUsize::new(0));
295        let counter = woke.clone();
296        nav.set_waker(Arc::new(move || {
297            counter.fetch_add(1, Ordering::SeqCst);
298        }));
299
300        nav.go("/a");
301        nav.pop();
302        assert_eq!(woke.load(Ordering::SeqCst), 2);
303        // Draining is not an append — it must not wake.
304        let _ = nav.drain();
305        assert_eq!(woke.load(Ordering::SeqCst), 2);
306    }
307
308    #[test]
309    fn appends_from_another_thread_are_total() {
310        // The shape's whole point: a background thread requests navigation
311        // without marshalling to the UI thread, and does not panic doing so.
312        let nav = RouteNavigator::new();
313        let woke = Arc::new(AtomicUsize::new(0));
314        let counter = woke.clone();
315        nav.set_waker(Arc::new(move || {
316            counter.fetch_add(1, Ordering::SeqCst);
317        }));
318
319        let off_thread = nav.clone();
320        std::thread::spawn(move || off_thread.push("/from-a-thread"))
321            .join()
322            .expect("the off-thread append must not panic");
323
324        assert_eq!(
325            nav.drain(),
326            vec![NavRequest::Push("/from-a-thread".to_string())]
327        );
328        assert_eq!(woke.load(Ordering::SeqCst), 1);
329    }
330
331    #[test]
332    fn clones_share_one_queue() {
333        let nav = RouteNavigator::new();
334        let other = nav.clone();
335        other.go("/shared");
336        assert_eq!(nav.drain(), vec![NavRequest::Go("/shared".to_string())]);
337    }
338
339    #[test]
340    fn location_starts_empty_and_reports_the_last_published() {
341        let nav = RouteNavigator::new();
342        assert!(nav.location().is_none());
343        nav.set_location(Location::parse("/users/42?tab=posts"));
344        let loc = nav.location().expect("published");
345        assert_eq!(loc.path, "/users/42");
346        assert_eq!(loc.query.get("tab").map(String::as_str), Some("posts"));
347    }
348
349    #[test]
350    fn a_poisoned_queue_still_works() {
351        // Recovery, not propagation: a panic elsewhere in the process must not
352        // make navigation permanently unusable.
353        let nav = RouteNavigator::new();
354        let poisoner = nav.clone();
355        let _ = std::thread::spawn(move || {
356            let _guard = poisoner.inner.queue.lock().expect("fresh mutex");
357            panic!("poison the queue");
358        })
359        .join();
360
361        nav.go("/after-poison");
362        assert_eq!(
363            nav.drain(),
364            vec![NavRequest::Go("/after-poison".to_string())]
365        );
366    }
367}