Skip to main content

tessera_shard/router/
controller.rs

1use std::sync::Arc;
2
3use tessera_ui::{State, StateOwner};
4
5use crate::{RouterDestination, ShardState, ShardStateLifeCycle};
6
7struct RouteEntry {
8    id: u64,
9    destination: Arc<dyn RouterDestination>,
10    owner: StateOwner,
11}
12
13/// Owns the destination stack and reactive state for its routes.
14pub struct RouterController {
15    route_stack: Vec<RouteEntry>,
16    next_route_id: u64,
17    scope: StateOwner,
18    marker: State<()>,
19}
20
21impl RouterController {
22    /// Creates an empty controller in the active UI session.
23    pub fn new() -> Self {
24        let scope = StateOwner::new();
25        let marker = scope.state_with_key("controller", || ());
26        Self {
27            route_stack: Vec::new(),
28            next_route_id: 0,
29            scope,
30            marker,
31        }
32    }
33
34    /// Creates a controller seeded with a root destination.
35    pub fn with_root(root_dest: impl RouterDestination + 'static) -> Self {
36        let mut router = Self::new();
37        router.push(root_dest);
38        router
39    }
40
41    /// Creates a controller seeded with a shared root destination.
42    pub fn with_root_shared(root_dest: Arc<dyn RouterDestination>) -> Self {
43        let mut router = Self::new();
44        router.push_shared(root_dest);
45        router
46    }
47
48    /// Pushes a destination with its own state lifetime.
49    pub fn push<T: RouterDestination + 'static>(&mut self, destination: T) {
50        self.push_shared(Arc::new(destination));
51    }
52
53    /// Pushes a shared destination with its own state lifetime.
54    pub fn push_shared(&mut self, destination: Arc<dyn RouterDestination>) {
55        self.route_stack.push(RouteEntry {
56            id: self.next_route_id,
57            destination,
58            owner: StateOwner::new(),
59        });
60        self.next_route_id += 1;
61    }
62
63    /// Pops the current destination and releases its route state.
64    pub fn pop(&mut self) -> Option<Arc<dyn RouterDestination>> {
65        let removed = self.route_stack.pop()?;
66        removed.owner.close();
67        Some(removed.destination)
68    }
69
70    /// Replaces the current destination, or pushes into an empty stack.
71    pub fn replace<T: RouterDestination + 'static>(
72        &mut self,
73        destination: T,
74    ) -> Option<Arc<dyn RouterDestination>> {
75        let previous = self.pop();
76        self.push(destination);
77        previous
78    }
79
80    /// Replaces the current destination with a shared destination.
81    pub fn replace_shared(
82        &mut self,
83        destination: Arc<dyn RouterDestination>,
84    ) -> Option<Arc<dyn RouterDestination>> {
85        let previous = self.pop();
86        self.push_shared(destination);
87        previous
88    }
89
90    /// Returns whether the stack is empty.
91    pub fn is_empty(&self) -> bool {
92        self.route_stack.is_empty()
93    }
94    /// Returns the number of destinations.
95    pub fn len(&self) -> usize {
96        self.route_stack.len()
97    }
98    /// Returns the current destination.
99    pub fn last(&self) -> Option<&dyn RouterDestination> {
100        self.route_stack.last().map(|entry| &*entry.destination)
101    }
102    /// Returns whether the current destination has type `D`.
103    pub fn current_is<D: RouterDestination + 'static>(&self) -> bool {
104        self.last()
105            .is_some_and(|current| current.type_id() == std::any::TypeId::of::<D>())
106    }
107
108    pub(crate) fn route_at_depth(&self, depth: usize) -> Option<(u64, Arc<dyn RouterDestination>)> {
109        self.route_stack
110            .iter()
111            .rev()
112            .nth(depth)
113            .map(|entry| (entry.id, entry.destination.clone()))
114    }
115
116    pub(crate) fn state_context(&self, lifecycle: ShardStateLifeCycle) -> (StateOwner, State<()>) {
117        self.route_state_context(lifecycle, None)
118    }
119
120    pub(crate) fn route_state_context(
121        &self,
122        lifecycle: ShardStateLifeCycle,
123        route: Option<u64>,
124    ) -> (StateOwner, State<()>) {
125        let owner = match lifecycle {
126            ShardStateLifeCycle::Scope => &self.scope,
127            ShardStateLifeCycle::Shard => {
128                let entry = match route {
129                    Some(id) => self.route_stack.iter().rev().find(|entry| entry.id == id),
130                    None => self.route_stack.last(),
131                };
132                &entry
133                    .expect("route-scoped shard state requires a non-empty router stack")
134                    .owner
135            }
136        };
137        (owner.clone(), self.marker)
138    }
139
140    /// Gets or initializes state owned by the current route.
141    pub fn init_or_get<T, F, R>(&self, id: &str, f: F) -> R
142    where
143        T: Default + Send + Sync + 'static,
144        F: FnOnce(ShardState<T>) -> R,
145    {
146        self.init_or_get_with_lifecycle(id, ShardStateLifeCycle::Shard, f)
147    }
148
149    /// Gets or initializes state owned by a route or this controller.
150    pub fn init_or_get_with_lifecycle<T, F, R>(
151        &self,
152        id: &str,
153        lifecycle: ShardStateLifeCycle,
154        f: F,
155    ) -> R
156    where
157        T: Default + Send + Sync + 'static,
158        F: FnOnce(ShardState<T>) -> R,
159    {
160        let (owner, marker) = self.state_context(lifecycle);
161        f(ShardState::new(
162            owner.state_with_key(("shard", id), T::default),
163            marker,
164        ))
165    }
166
167    /// Clears all destinations and releases their route state.
168    pub fn clear(&mut self) {
169        self.route_stack.clear();
170    }
171    /// Clears the stack and installs a new root destination.
172    pub fn reset(&mut self, root_dest: impl RouterDestination + 'static) {
173        self.clear();
174        self.push(root_dest);
175    }
176    /// Clears the stack and installs a shared root destination.
177    pub fn reset_shared(&mut self, root_dest: Arc<dyn RouterDestination>) {
178        self.clear();
179        self.push_shared(root_dest);
180    }
181}
182
183impl Default for RouterController {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188impl Drop for RouterController {
189    fn drop(&mut self) {
190        self.route_stack.clear();
191        self.scope.close();
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use std::panic::{AssertUnwindSafe, catch_unwind};
198
199    use super::RouterController;
200    use crate::{RouterDestination, ShardStateLifeCycle};
201
202    #[derive(Default)]
203    struct CounterState {
204        value: usize,
205    }
206
207    struct DummyDestination;
208
209    impl RouterDestination for DummyDestination {
210        fn exec_component(&self, _: tessera_ui::State<RouterController>) {}
211
212        fn exec_component_handle(
213            &self,
214        ) -> std::sync::Arc<dyn Fn(tessera_ui::State<RouterController>) + Send + Sync> {
215            std::sync::Arc::new(|_| {})
216        }
217
218        fn destination_id() -> &'static str {
219            "dummy"
220        }
221    }
222
223    fn increment_state(
224        router: &RouterController,
225        shard_id: &str,
226        life_cycle: ShardStateLifeCycle,
227    ) -> usize {
228        router.init_or_get_with_lifecycle::<CounterState, _, _>(shard_id, life_cycle, |state| {
229            state.with_mut(|value| {
230                value.value += 1;
231                value.value
232            })
233        })
234    }
235
236    #[test]
237    fn route_scoped_state_is_released_on_pop() {
238        let session = tessera_ui::RuntimeSession::new();
239        let _session = session.enter();
240        let shard_id = "route_scoped";
241        let mut router = RouterController::with_root(DummyDestination);
242
243        assert_eq!(
244            increment_state(&router, shard_id, ShardStateLifeCycle::Shard),
245            1
246        );
247        assert_eq!(
248            increment_state(&router, shard_id, ShardStateLifeCycle::Shard),
249            2
250        );
251
252        assert!(router.pop().is_some());
253        router.push(DummyDestination);
254        assert_eq!(
255            increment_state(&router, shard_id, ShardStateLifeCycle::Shard),
256            1
257        );
258    }
259
260    #[test]
261    fn scope_scoped_state_persists_inside_scope_but_resets_across_scopes() {
262        let session = tessera_ui::RuntimeSession::new();
263        let _session = session.enter();
264        let shard_id = "scope_scoped";
265        let mut router = RouterController::with_root(DummyDestination);
266
267        assert_eq!(
268            increment_state(&router, shard_id, ShardStateLifeCycle::Scope),
269            1
270        );
271
272        router.push(DummyDestination);
273        assert_eq!(
274            increment_state(&router, shard_id, ShardStateLifeCycle::Scope),
275            2
276        );
277
278        assert!(router.pop().is_some());
279        assert_eq!(
280            increment_state(&router, shard_id, ShardStateLifeCycle::Scope),
281            3
282        );
283
284        drop(router);
285
286        let router = RouterController::with_root(DummyDestination);
287        assert_eq!(
288            increment_state(&router, shard_id, ShardStateLifeCycle::Scope),
289            1
290        );
291    }
292
293    #[test]
294    fn route_scoped_state_requires_active_route() {
295        let session = tessera_ui::RuntimeSession::new();
296        let _session = session.enter();
297        let shard_id = "route_context_required";
298        let router = RouterController::new();
299        let result = catch_unwind(AssertUnwindSafe(|| {
300            let _ = increment_state(&router, shard_id, ShardStateLifeCycle::Shard);
301        }));
302        assert!(result.is_err());
303    }
304
305    #[test]
306    fn controllers_do_not_share_or_alias_slots() {
307        let session = tessera_ui::RuntimeSession::new();
308        let _session = session.enter();
309        let shard_id = "isolated";
310        let left = RouterController::with_root(DummyDestination);
311        let right = RouterController::with_root(DummyDestination);
312
313        left.init_or_get::<CounterState, _, _>(shard_id, |state| {
314            state.with_mut(|value| value.value = 11);
315        });
316        right.init_or_get::<CounterState, _, _>(shard_id, |state| {
317            state.with_mut(|value| value.value = 22);
318        });
319
320        assert_eq!(
321            left.init_or_get::<CounterState, _, _>(shard_id, |state| {
322                state.with(|value| value.value)
323            }),
324            11
325        );
326        assert_eq!(
327            right.init_or_get::<CounterState, _, _>(shard_id, |state| {
328                state.with(|value| value.value)
329            }),
330            22
331        );
332
333        let stale = left.init_or_get::<CounterState, _, _>(shard_id, |state| state);
334        drop(left);
335
336        right.init_or_get::<CounterState, _, _>(shard_id, |state| {
337            state.with_mut(|value| value.value = 33);
338        });
339        assert!(stale.try_with(|value| value.value).is_err());
340        assert_eq!(
341            right.init_or_get::<CounterState, _, _>(shard_id, |state| {
342                state.with(|value| value.value)
343            }),
344            33
345        );
346    }
347    #[test]
348    fn panicking_mutation_restores_the_live_shard_value() {
349        let session = tessera_ui::RuntimeSession::new();
350        let _session = session.enter();
351        let router = RouterController::with_root(DummyDestination);
352        let state = router.init_or_get::<usize, _, _>("panic", |state| state);
353        let result = catch_unwind(AssertUnwindSafe(|| {
354            state.with_mut(|value| {
355                *value = 42;
356                panic!("abort mutation");
357            });
358        }));
359        assert!(result.is_err());
360        assert_eq!(state.get(), 42);
361        state.set(43);
362        assert_eq!(state.get(), 43);
363    }
364    type Observed = (
365        tessera_ui::State<RouterController>,
366        crate::ShardState<usize>,
367        usize,
368    );
369
370    #[tessera_ui::tessera]
371    fn reactive_fixture(output: tessera_ui::testing::TestProbe<Option<Observed>>) {
372        let router = tessera_ui::remember(|| RouterController::with_root(DummyDestination));
373        let state = crate::router::with_current_router_shard_state(
374            "reactive",
375            ShardStateLifeCycle::Shard,
376            router,
377            |state: crate::ShardState<usize>| state,
378        );
379        output.set(Some((router, state, state.get())));
380    }
381
382    #[test]
383    fn shard_writes_rebuild_consumers_and_route_owners_preserve_only_live_state() {
384        use tessera_ui::testing::{
385            TestProbe, layout_test, pump_layout_test_session, start_layout_test_session,
386        };
387
388        let output = TestProbe::default();
389        let probe = output.clone();
390        let mut session = start_layout_test_session(layout_test(move || {
391            reactive_fixture(probe.clone());
392        }));
393        pump_layout_test_session(&mut session, 0);
394        let (router, root_state, value) = output.get().unwrap();
395        assert_eq!(value, 0);
396        pump_layout_test_session(&mut session, 1);
397        assert_eq!(
398            session
399                .work()
400                .count("reactive_fixture", tessera_ui::WorkPhase::Build),
401            0
402        );
403        session.run(|| root_state.set(7));
404        pump_layout_test_session(&mut session, 2);
405        assert_eq!(output.get().unwrap().2, 7);
406        assert_eq!(
407            session
408                .work()
409                .count("reactive_fixture", tessera_ui::WorkPhase::Build),
410            1
411        );
412
413        session.run(|| router.with_mut(|router| router.push(DummyDestination)));
414        pump_layout_test_session(&mut session, 3);
415        let (_, top_state, value) = output.get().unwrap();
416        assert_eq!(value, 0);
417        assert!(top_state != root_state);
418        session.run(|| top_state.set(12));
419        pump_layout_test_session(&mut session, 4);
420        assert_eq!(output.get().unwrap().2, 12);
421        session.run(|| {
422            router.with_mut(|router| {
423                router.pop();
424            })
425        });
426        pump_layout_test_session(&mut session, 5);
427        assert!(output.get().unwrap().1 == root_state);
428        assert_eq!(output.get().unwrap().2, 7);
429        assert_eq!(
430            session.run(|| top_state.try_with(|value| *value)),
431            Err(crate::state::ShardStateError::Stale)
432        );
433        session.run(|| {
434            router.with_mut(|router| {
435                router.clear();
436            })
437        });
438        assert_eq!(
439            session.run(|| root_state.try_with(|value| *value)),
440            Err(crate::state::ShardStateError::Stale)
441        );
442    }
443}