dioxus_retrouter/
service.rs

1// todo: how does router work in multi-window contexts?
2// does each window have its own router? probably, lol
3
4use crate::cfg::RouterCfg;
5use dioxus::core::{ScopeId, ScopeState, VirtualDom};
6use std::any::Any;
7use std::rc::Weak;
8use std::{
9    cell::{Cell, RefCell},
10    collections::{HashMap, HashSet},
11    rc::Rc,
12    str::FromStr,
13    sync::Arc,
14};
15use url::Url;
16
17/// A clonable handle to the router
18pub type RouterContext = Rc<RouterService>;
19
20/// An abstraction over the platform's history API.
21///
22/// The history is denoted using web-like semantics, with forward slashes delmitiing
23/// routes and question marks denoting optional parameters.
24///
25/// This RouterService is exposed so you can modify the history directly. It
26/// does not provide a high-level ergonomic API for your components. Instead,
27/// you should consider using the components and hooks instead.
28/// - [`Route`](struct.Route.html)
29/// - [`Link`](struct.Link.html)
30/// - [`UseRoute`](struct.UseRoute.html)
31/// - [`Router`](struct.Router.html)
32///
33///
34/// # Example
35///
36/// ```rust, ignore
37/// let router = Router::new();
38/// router.push_route("/home/custom");
39/// cx.provide_context(router);
40/// ```
41///
42/// # Platform Specific
43///
44/// - On the web, this is a [`BrowserHistory`](https://docs.rs/gloo/0.3.0/gloo/history/struct.BrowserHistory.html).
45/// - On desktop, mobile, and SSR, this is just a Vec of Strings. Currently on
46///   desktop, there is no way to tap into forward/back for the app unless explicitly set.
47pub struct RouterService {
48    pub(crate) route_found: Cell<Option<ScopeId>>,
49
50    pub(crate) stack: RefCell<Vec<Arc<ParsedRoute>>>,
51
52    pub(crate) slots: Rc<RefCell<HashMap<ScopeId, String>>>,
53
54    pub(crate) ordering: Rc<RefCell<Vec<ScopeId>>>,
55
56    pub(crate) onchange_listeners: Rc<RefCell<HashSet<ScopeId>>>,
57
58    pub(crate) history: Box<dyn RouterProvider>,
59
60    pub(crate) regen_any_route: Arc<dyn Fn(ScopeId)>,
61
62    pub(crate) router_id: ScopeId,
63
64    pub(crate) cfg: RouterCfg,
65}
66
67/// A route is a combination of window title, saved state, and a URL.
68#[derive(Debug, Clone)]
69pub struct ParsedRoute {
70    /// The URL of the route.
71    pub url: Url,
72
73    /// The title of the route.
74    pub title: Option<String>,
75
76    /// The serialized state of the route.
77    pub serialized_state: Option<String>,
78}
79
80impl RouterService {
81    pub(crate) fn new(cx: &ScopeState, cfg: RouterCfg) -> RouterContext {
82        #[cfg(feature = "web")]
83        let history = Box::new(web::new());
84
85        #[cfg(not(feature = "web"))]
86        let history = Box::new(hash::new());
87
88        let route = match &cfg.initial_url {
89            Some(url) => Arc::new(ParsedRoute {
90                url: Url::from_str(url).unwrap_or_else(|_|
91                    panic!(
92                        "RouterCfg expects a valid initial_url, but got '{}'. Example: '{{scheme}}://{{?authority}}/{{?path}}'",
93                        &url
94                    )
95                ),
96                title: None,
97                serialized_state: None,
98            }),
99            None => Arc::new(history.init_location()),
100        };
101
102        let svc = Rc::new(Self {
103            cfg,
104            regen_any_route: cx.schedule_update_any(),
105            router_id: cx.scope_id(),
106            route_found: Cell::new(None),
107            stack: RefCell::new(vec![route]),
108            ordering: Default::default(),
109            slots: Default::default(),
110            onchange_listeners: Default::default(),
111            history,
112        });
113
114        svc.history.attach_listeners(Rc::downgrade(&svc));
115
116        svc
117    }
118
119    /// Push a new route with no custom title or serialized state.
120    ///
121    /// This is a convenience method for easily navigating.
122    pub fn navigate_to(&self, route: &str) {
123        self.push_route(route, None, None);
124    }
125
126    /// Push a new route to the history.
127    ///
128    /// This will trigger a route change event.
129    ///
130    /// This does not modify the current route
131    pub fn push_route(&self, route: &str, title: Option<String>, serialized_state: Option<String>) {
132        let new_route = Arc::new(ParsedRoute {
133            url: self.current_location().url.join(route).ok().unwrap(),
134            title,
135            serialized_state,
136        });
137
138        self.history.push(&new_route);
139        self.stack.borrow_mut().push(new_route);
140
141        self.regen_routes();
142    }
143
144    /// Instead of pushing a new route, replaces the current route.
145    pub fn replace_route(
146        &self,
147        route: &str,
148        title: Option<String>,
149        serialized_state: Option<String>,
150    ) {
151        let new_route = Arc::new(ParsedRoute {
152            url: self.current_location().url.join(route).ok().unwrap(),
153            title,
154            serialized_state,
155        });
156
157        self.history.replace(&new_route);
158        *self.stack.borrow_mut().last_mut().unwrap() = new_route;
159
160        self.regen_routes();
161    }
162
163    /// Pop the current route from the history.
164    pub fn pop_route(&self) {
165        let mut stack = self.stack.borrow_mut();
166
167        if stack.len() > 1 {
168            stack.pop();
169        }
170
171        self.regen_routes();
172    }
173
174    /// Regenerate any routes that need to be regenerated, discarding the currently found route
175    ///
176    /// You probably don't need this method
177    pub fn regen_routes(&self) {
178        self.route_found.set(None);
179
180        (self.regen_any_route)(self.router_id);
181
182        for listener in self.onchange_listeners.borrow().iter() {
183            log::trace!("Regenerating scope {:?}", listener);
184            (self.regen_any_route)(*listener);
185        }
186
187        for route in self.ordering.borrow().iter().rev() {
188            (self.regen_any_route)(*route);
189        }
190    }
191
192    /// Get the current location of the Router
193    pub fn current_location(&self) -> Arc<ParsedRoute> {
194        self.stack.borrow().last().unwrap().clone()
195    }
196
197    /// Get the current native location of the Router
198    pub fn native_location<T: 'static>(&self) -> Option<Box<T>> {
199        self.history.native_location().downcast::<T>().ok()
200    }
201
202    /// Registers a scope to regenerate on route change.
203    ///
204    /// This is useful if you've built some abstraction on top of the router service.
205    pub fn subscribe_onchange(&self, id: ScopeId) {
206        self.onchange_listeners.borrow_mut().insert(id);
207    }
208
209    /// Unregisters a scope to regenerate on route change.
210    ///
211    /// This is useful if you've built some abstraction on top of the router service.
212    pub fn unsubscribe_onchange(&self, id: ScopeId) {
213        self.onchange_listeners.borrow_mut().remove(&id);
214    }
215
216    pub(crate) fn register_total_route(&self, route: String, scope: ScopeId) {
217        let clean = clean_route(route);
218        self.slots.borrow_mut().insert(scope, clean);
219        self.ordering.borrow_mut().push(scope);
220    }
221
222    pub(crate) fn should_render(&self, scope: ScopeId) -> bool {
223        if let Some(root_id) = self.route_found.get() {
224            return root_id == scope;
225        }
226
227        let roots = self.slots.borrow();
228
229        if let Some(route) = roots.get(&scope) {
230            let cur = &self.current_location().url;
231            log::trace!("Checking if {} matches {}", cur, route);
232
233            if route_matches_path(cur, route, self.cfg.base_url.as_ref()) || route.is_empty() {
234                self.route_found.set(Some(scope));
235                true
236            } else {
237                false
238            }
239        } else {
240            false
241        }
242    }
243}
244
245/// Get the router service from an existing VirtualDom.
246///
247/// Takes an optional target_scope parameter to specify the scope to use if ScopeId is not the component
248/// that owns the router.
249///
250/// This might change in the future.
251pub fn get_router_from_vdom(dom: &VirtualDom, target_scope: ScopeId) -> Option<RouterContext> {
252    dom.get_scope(target_scope)
253        .and_then(|scope| scope.consume_context::<RouterContext>())
254}
255
256fn clean_route(route: String) -> String {
257    if route.as_str() == "/" {
258        return route;
259    }
260    route.trim_end_matches('/').to_string()
261}
262
263fn clean_path(path: &str) -> &str {
264    if path == "/" {
265        return path;
266    }
267    let sub = path.trim_end_matches('/');
268
269    if sub.starts_with('/') {
270        &path[1..]
271    } else {
272        sub
273    }
274}
275
276fn route_matches_path(cur: &Url, attempt: &str, base_url: Option<&String>) -> bool {
277    let cur_piece_iter = cur.path_segments().unwrap();
278
279    let mut cur_pieces = match base_url {
280        // baseurl is naive right now and doesn't support multiple nesting levels
281        Some(_) => cur_piece_iter.skip(1).collect::<Vec<_>>(),
282        None => cur_piece_iter.collect::<Vec<_>>(),
283    };
284
285    if attempt == "/" && cur_pieces.len() == 1 && cur_pieces[0].is_empty() {
286        return true;
287    }
288
289    // allow slashes at the end of the path
290    if cur_pieces.last() == Some(&"") {
291        cur_pieces.pop();
292    }
293
294    let attempt_pieces = clean_path(attempt).split('/').collect::<Vec<_>>();
295
296    if attempt_pieces.len() != cur_pieces.len() {
297        return false;
298    }
299
300    for (i, r) in attempt_pieces.iter().enumerate() {
301        // If this is a parameter then it matches as long as there's
302        // _any_thing in that spot in the path.
303        if r.starts_with(':') {
304            continue;
305        }
306
307        if cur_pieces[i] != *r {
308            return false;
309        }
310    }
311
312    true
313}
314
315pub(crate) trait RouterProvider {
316    fn push(&self, route: &ParsedRoute);
317    fn replace(&self, route: &ParsedRoute);
318    fn native_location(&self) -> Box<dyn Any>;
319    fn init_location(&self) -> ParsedRoute;
320    fn attach_listeners(&self, svc: Weak<RouterService>);
321}
322
323#[cfg(not(feature = "web"))]
324mod hash {
325    use super::*;
326
327    pub fn new() -> HashRouter {
328        HashRouter {}
329    }
330
331    /// a simple cross-platform hash-based router
332    pub struct HashRouter {}
333
334    impl RouterProvider for HashRouter {
335        fn push(&self, _route: &ParsedRoute) {}
336
337        fn native_location(&self) -> Box<dyn Any> {
338            Box::new(())
339        }
340
341        fn init_location(&self) -> ParsedRoute {
342            ParsedRoute {
343                url: Url::parse("app:///").unwrap(),
344                title: None,
345                serialized_state: None,
346            }
347        }
348
349        fn replace(&self, _route: &ParsedRoute) {}
350
351        fn attach_listeners(&self, _svc: Weak<RouterService>) {}
352    }
353}
354
355#[cfg(feature = "web")]
356mod web {
357    use super::RouterProvider;
358    use crate::ParsedRoute;
359
360    use gloo_events::EventListener;
361    use std::{any::Any, cell::Cell};
362    use web_sys::History;
363
364    pub struct WebRouter {
365        // keep it around so it drops when the router is dropped
366        _listener: Cell<Option<gloo_events::EventListener>>,
367
368        window: web_sys::Window,
369        history: History,
370    }
371
372    impl RouterProvider for WebRouter {
373        fn push(&self, route: &ParsedRoute) {
374            let ParsedRoute {
375                url,
376                title,
377                serialized_state,
378            } = route;
379
380            let _ = self.history.push_state_with_url(
381                &wasm_bindgen::JsValue::from_str(serialized_state.as_deref().unwrap_or("")),
382                title.as_deref().unwrap_or(""),
383                Some(url.as_str()),
384            );
385        }
386
387        fn replace(&self, route: &ParsedRoute) {
388            let ParsedRoute {
389                url,
390                title,
391                serialized_state,
392            } = route;
393
394            let _ = self.history.replace_state_with_url(
395                &wasm_bindgen::JsValue::from_str(serialized_state.as_deref().unwrap_or("")),
396                title.as_deref().unwrap_or(""),
397                Some(url.as_str()),
398            );
399        }
400
401        fn native_location(&self) -> Box<dyn Any> {
402            Box::new(self.window.location())
403        }
404
405        fn init_location(&self) -> ParsedRoute {
406            ParsedRoute {
407                url: url::Url::parse(&web_sys::window().unwrap().location().href().unwrap())
408                    .unwrap(),
409                title: web_sys::window()
410                    .unwrap()
411                    .document()
412                    .unwrap()
413                    .title()
414                    .into(),
415                serialized_state: None,
416            }
417        }
418
419        fn attach_listeners(&self, svc: std::rc::Weak<crate::RouterService>) {
420            self._listener.set(Some(EventListener::new(
421                &web_sys::window().unwrap(),
422                "popstate",
423                move |_| {
424                    if let Some(svc) = svc.upgrade() {
425                        svc.pop_route();
426                    }
427                },
428            )));
429        }
430    }
431
432    pub(crate) fn new() -> WebRouter {
433        WebRouter {
434            history: web_sys::window().unwrap().history().unwrap(),
435            window: web_sys::window().unwrap(),
436            _listener: Cell::new(None),
437        }
438    }
439}