frust/route_state.rs
1//! The facade's reactive route-state observable: the signal face over
2//! `frust_widgets`' signal-free [`RouteStack`]/[`NavChange`], riding
3//! `provide_context` exactly like [`RouteNavigator`] does — the difference is
4//! that `RouteNavigator` carries *intent* (queued, undrained requests) while
5//! [`RouteObserver`] carries *fact* (the last-published stack), the same split
6//! `route_state`'s (`frust_widgets::nav::route_state`) own module docs draw.
7//!
8//! This is the one place the facade sees both `frust_widgets`'
9//! [`NavigatorView::on_route_change`] and `frust-reactive`'s `RwSignal`
10//! together — `frust-widgets` stays reactive-free (see
11//! `docs/WIDGETS_ARCHITECTURE.md`'s Reactive-Free Design section), so the
12//! bridge lives here, wired from [`RouterDeepLinks`](crate::RouterDeepLinks)
13//! alongside its existing deep-link waker.
14
15use frust_reactive::RwSignal;
16use frust_widgets::{Location, NavChange, NavigatorView, RouteStack};
17use reactive_graph::traits::{Get, Set};
18
19/// Reactive face of one navigator's route stack: `Copy + Send + Sync`
20/// (five `RwSignal`s and nothing else), so it rides `provide_context` — the
21/// navigator's own [`NavigatorController`](frust_widgets::NavigatorController)/
22/// [`Router`](frust_widgets::Router) never can (see
23/// `frust_widgets::nav::route`'s module docs for why).
24#[derive(Clone, Copy)]
25pub struct RouteObserver {
26 current: RwSignal<Option<Location>>,
27 stack: RwSignal<Vec<Option<Location>>>,
28 depth: RwSignal<usize>,
29 change: RwSignal<NavChange>,
30 generation: RwSignal<u64>,
31}
32
33/// The seam's whole point, pinned at compile time: if `RouteObserver` ever
34/// stops being `Send + Sync + 'static` it can no longer ride
35/// `provide_context`, and this becomes a compile error rather than a review
36/// finding — mirrors `RouteNavigator`'s own assertion
37/// (`frust_widgets::nav::route`).
38const _: fn() = || {
39 fn assert<T: Send + Sync + 'static>() {}
40 assert::<RouteObserver>();
41};
42
43impl RouteObserver {
44 /// A fresh observer at rest (`NavChange::Initial`, depth 0, generation 0,
45 /// no current route) — construct once, typically in `Component::init`,
46 /// and keep the result in `State`.
47 pub fn new() -> Self {
48 Self {
49 current: RwSignal::new(None),
50 stack: RwSignal::new(Vec::new()),
51 depth: RwSignal::new(0),
52 change: RwSignal::new(NavChange::default()),
53 generation: RwSignal::new(0),
54 }
55 }
56
57 /// Attach: installs [`NavigatorView::on_route_change`], writing these
58 /// signals from inside the navigator's own rebuild whenever the published
59 /// [`RouteStack`] actually changes. Chainable and cheap to call again
60 /// (`RouteObserver` is `Copy`), so a second navigator elsewhere in the app
61 /// gets its own observer the same way:
62 /// `state.routes.observe(navigator(&controller, initial))`.
63 ///
64 /// # Wake correctness
65 ///
66 /// The write happens inside the navigator's rebuild, *after* `apply_ops`:
67 /// a navigation requested in frame N is published in N, read by the app's
68 /// `build` in N+1, and the write itself schedules N+1 — the same
69 /// tracked-write-wakes-the-shell bridge every other signal in the
70 /// framework rides (`docs/CODE_STANDARDS.md`'s tracked-scope rule), not a
71 /// manual `ReactiveRuntime::wake()` call. One frame behind is possible;
72 /// *silently* behind is not (a page transition runs ~300ms; 16ms of bar
73 /// lag is below the perceptual floor).
74 pub fn observe<State: 'static>(self, view: NavigatorView<State>) -> NavigatorView<State> {
75 view.on_route_change(move |stack: &RouteStack| {
76 self.current.set(stack.current_route().cloned());
77 self.stack.set(stack.entries().to_vec());
78 self.depth.set(stack.depth());
79 self.change.set(stack.change());
80 self.generation.set(stack.generation());
81 })
82 }
83
84 /// The topmost page that **has** a route — skips a route-less
85 /// (overlay/dialog) page on top, the read chrome (an app bar title, e.g.)
86 /// wants. Tracked read; see [`RouteStack::current_route`]'s doc for the
87 /// full contract.
88 pub fn current(&self) -> Option<Location> {
89 self.current.get()
90 }
91
92 /// [`current`](Self::current)'s path, or an empty string before the first
93 /// publish (or if no page in the stack carries a route at all).
94 pub fn path(&self) -> String {
95 self.current().map(|loc| loc.path).unwrap_or_default()
96 }
97
98 /// A query parameter off [`current`](Self::current)'s location. `None` if
99 /// there is no current routed page, or the key is absent.
100 pub fn param(&self, key: &str) -> Option<String> {
101 self.current().and_then(|loc| loc.query.get(key).cloned())
102 }
103
104 /// The full stack, bottom→top; `None` at a route-less (overlay/dialog)
105 /// page — the tracked-read mirror of [`RouteStack::entries`].
106 pub fn stack(&self) -> Vec<Option<Location>> {
107 self.stack.get()
108 }
109
110 /// The stack depth as of the last publish.
111 pub fn depth(&self) -> usize {
112 self.depth.get()
113 }
114
115 /// The diff label for the most recent publish — see [`NavChange`].
116 pub fn change(&self) -> NavChange {
117 self.change.get()
118 }
119
120 /// Whether the most recent change was a [`NavChange::Pop`] — the
121 /// direction chrome resolves a title-transition from, e.g.
122 /// `.title_direction(if routes.is_back() { Back } else { Forward })`.
123 pub fn is_back(&self) -> bool {
124 self.change() == NavChange::Pop
125 }
126}
127
128impl Default for RouteObserver {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use frust_core::{BuildCtx, ChangeFlags, FrameTime, PaintScene, RenderRoot, View};
138 use frust_reactive::ReactiveRuntime;
139 use frust_widgets::{NavigatorController, NavigatorView, PushOptions, navigator};
140 use kurbo::Size;
141 use std::sync::Arc;
142
143 struct SizedLeaf {
144 size: Size,
145 }
146 struct SizedLeafWidget {
147 size: Size,
148 }
149 impl View<()> for SizedLeaf {
150 type Element = SizedLeafWidget;
151 fn build(&self, _ctx: &mut BuildCtx<'_>) -> SizedLeafWidget {
152 SizedLeafWidget { size: self.size }
153 }
154 fn rebuild(
155 &self,
156 _prev: &Self,
157 element: &mut SizedLeafWidget,
158 _ctx: &mut BuildCtx<'_>,
159 ) -> ChangeFlags {
160 element.size = self.size;
161 ChangeFlags::NONE
162 }
163 }
164 impl frust_core::Widget for SizedLeafWidget {
165 fn layout(
166 &mut self,
167 _ctx: &mut frust_core::LayoutCtx,
168 bc: &frust_core::BoxConstraints,
169 ) -> Size {
170 bc.constrain(self.size)
171 }
172 fn paint(&mut self, _ctx: &mut frust_core::PaintCtx, _scene: &mut dyn PaintScene) {}
173 }
174 fn sized(w: f64, h: f64) -> impl View<()> {
175 SizedLeaf {
176 size: Size::new(w, h),
177 }
178 }
179
180 // A minimal `PaintScene` that just needs to exist for `paint` to run —
181 // mirrors `router_glue.rs`'s own test fixture (this crate has no
182 // `test-support` feature dependency on `frust-widgets`).
183 #[derive(Default)]
184 struct RecordingScene;
185 impl PaintScene for RecordingScene {
186 fn fill_rect(&mut self, _origin: kurbo::Point, _size: Size, _color: peniko::Color) {}
187 fn draw_text(&mut self, _origin: kurbo::Point, _text: &str) {}
188 }
189
190 /// `RouteObserver` compile-asserted `Send + Sync` (the `const _` above),
191 /// round-trips `provide_context`, and a navigation applied in rebuild N is
192 /// readable in build N+1.
193 #[test]
194 fn route_observer_rides_context_and_updates_across_a_navigation() {
195 use frust_reactive::{provide_context, use_context};
196
197 let rt = ReactiveRuntime::init(Arc::new(|| {}));
198 let controller: NavigatorController<()> = NavigatorController::new();
199 let observer = rt.with_owner(RouteObserver::new);
200
201 let recovered = rt.with_owner(|| {
202 provide_context(observer);
203 use_context::<RouteObserver>()
204 });
205 let recovered = recovered.expect("a RouteObserver must survive provide_context");
206
207 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
208 let mut app = {
209 let ctrl = controller.clone();
210 move |_: &mut ()| recovered.observe(navigator(&ctrl, || sized(10.0, 10.0)))
211 };
212 let mut state = ();
213
214 // Build N: no navigation yet — at rest.
215 rt.with_owner(|| root.rebuild(&mut app, &mut state));
216 assert_eq!(recovered.depth(), 1);
217 assert_eq!(recovered.change(), NavChange::Initial);
218
219 // A navigation applied in rebuild N ...
220 controller.push_with_options(
221 || sized(20.0, 20.0),
222 PushOptions::opaque().route(Location::parse("/detail")),
223 );
224 rt.with_owner(|| root.rebuild(&mut app, &mut state));
225 root.layout(Size::new(100.0, 100.0));
226 let mut scene = RecordingScene;
227 root.paint(&mut scene, FrameTime::ZERO);
228
229 // ... is readable via the SAME `RouteObserver` handle in build N+1
230 // (rebuild already ran build N+1's logic above; the write happened
231 // inside it, which is the point — no extra frame was needed for the
232 // signal write itself to land).
233 assert_eq!(recovered.depth(), 2);
234 assert_eq!(recovered.change(), NavChange::Push);
235 assert_eq!(recovered.path(), "/detail");
236 assert!(!recovered.is_back());
237 }
238}