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}