sycamore_router/
router.rs

1use std::cell::Cell;
2use std::marker::PhantomData;
3use std::rc::Rc;
4
5use sycamore::prelude::*;
6use wasm_bindgen::prelude::*;
7use web_sys::{Element, HtmlAnchorElement, HtmlBaseElement, KeyboardEvent};
8
9use crate::Route;
10
11/// A router integration provides the methods for adapting a router to a certain environment (e.g.
12/// history API).
13pub trait Integration {
14    /// Get the current pathname.
15    fn current_pathname(&self) -> String;
16
17    /// Add a callback for listening to the `popstate` event.
18    fn on_popstate(&self, f: Box<dyn FnMut()>);
19
20    /// Get the click handler that is run when links are clicked.
21
22    fn click_handler(&self) -> Box<dyn Fn(web_sys::MouseEvent)>;
23}
24
25thread_local! {
26    static PATHNAME: Cell<Option<Signal<String>>> = const { Cell::new(None) };
27}
28
29/// A router integration that uses the
30/// [HTML5 History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) to keep the
31/// UI in sync with the URL.
32#[derive(Default, Debug)]
33pub struct HistoryIntegration {
34    /// This field is to prevent downstream users from creating a new `HistoryIntegration` without
35    /// the `new` method.
36    _internal: (),
37}
38
39impl HistoryIntegration {
40    /// Create a new [`HistoryIntegration`].
41    pub fn new() -> Self {
42        Self::default()
43    }
44}
45
46impl Integration for HistoryIntegration {
47    fn current_pathname(&self) -> String {
48        window().location().pathname().unwrap_throw()
49    }
50
51    fn on_popstate(&self, f: Box<dyn FnMut()>) {
52        let closure = Closure::wrap(f);
53        window()
54            .add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref())
55            .unwrap_throw();
56        closure.forget();
57    }
58
59    fn click_handler(&self) -> Box<dyn Fn(web_sys::MouseEvent)> {
60        Box::new(|ev| {
61            if let Some(a) = ev
62                .target()
63                .unwrap_throw()
64                .unchecked_into::<Element>()
65                .closest("a[href]")
66                .unwrap_throw()
67            {
68                let location = window().location();
69
70                let a = a.unchecked_into::<HtmlAnchorElement>();
71
72                // Check if a has `rel="external"`.
73                if a.rel() == "external" {
74                    // Use default browser behaviour.
75                    return;
76                }
77
78                let origin = a.origin();
79                let a_pathname = a.pathname();
80                let hash = a.hash();
81
82                let meta_keys_pressed = meta_keys_pressed(ev.unchecked_ref::<KeyboardEvent>());
83                if !meta_keys_pressed && location.origin() == Ok(origin) {
84                    if location.pathname().as_ref() != Ok(&a_pathname) {
85                        // Same origin, different path. Navigate to new page.
86                        ev.prevent_default();
87                        PATHNAME.with(|pathname| {
88                            let pathname = pathname.get().unwrap_throw();
89                            let path = a_pathname
90                                .strip_prefix(&base_pathname())
91                                .unwrap_or(&a_pathname);
92                            pathname.set(path.to_string());
93
94                            // Update History API.
95                            let history = window().history().unwrap_throw();
96                            history
97                                .push_state_with_url(&JsValue::UNDEFINED, "", Some(&a_pathname))
98                                .unwrap_throw();
99                            window().scroll_to_with_x_and_y(0.0, 0.0);
100                        });
101                    } else if location.hash().as_ref() != Ok(&hash) {
102                        // Same origin, same pathname, different hash. Use default browser behavior.
103                    } else {
104                        // Same page. Do nothing.
105                        ev.prevent_default();
106                    }
107                }
108            }
109        })
110    }
111}
112
113/// Gets the base pathname from `document.baseURI`.
114fn base_pathname() -> String {
115    match document().query_selector("base[href]") {
116        Ok(Some(base)) => {
117            let base = base.unchecked_into::<HtmlBaseElement>().href();
118
119            let url = web_sys::Url::new(&base).unwrap_throw();
120            let mut pathname = url.pathname();
121            // Strip trailing `/` character from the pathname.
122            pathname.ends_with('/');
123            pathname.pop(); // Pop the `/` character.
124            pathname
125        }
126        _ => "".to_string(),
127    }
128}
129
130/// Props for [`Router`].
131#[derive(Props, Debug)]
132pub struct RouterProps<R, F, I>
133where
134    R: Route + 'static,
135    F: FnOnce(ReadSignal<R>) -> View + 'static,
136    I: Integration,
137{
138    view: F,
139    integration: I,
140    #[prop(default, setter(skip))]
141    _phantom: PhantomData<R>,
142}
143
144impl<R, F, I> RouterProps<R, F, I>
145where
146    R: Route + 'static,
147    F: FnOnce(ReadSignal<R>) -> View + 'static,
148    I: Integration,
149{
150    /// Create a new [`RouterProps`].
151    pub fn new(integration: I, view: F) -> Self {
152        Self {
153            view,
154            integration,
155            _phantom: PhantomData,
156        }
157    }
158}
159
160/// Props for [`RouterBase`].
161#[derive(Props, Debug)]
162pub struct RouterBaseProps<R, F, I>
163where
164    R: Route + 'static,
165    F: FnOnce(ReadSignal<R>) -> View + 'static,
166    I: Integration,
167{
168    view: F,
169    integration: I,
170    route: R,
171}
172
173impl<R, F, I> RouterBaseProps<R, F, I>
174where
175    R: Route + 'static,
176    F: FnOnce(ReadSignal<R>) -> View + 'static,
177    I: Integration,
178{
179    /// Create a new [`RouterBaseProps`].
180    pub fn new(integration: I, view: F, route: R) -> Self {
181        Self {
182            view,
183            integration,
184            route,
185        }
186    }
187}
188
189/// The sycamore router component. This component expects to be used inside a browser environment.
190/// For server environments, see [`StaticRouter`].
191#[component]
192pub fn Router<R, F, I>(props: RouterProps<R, F, I>) -> View
193where
194    R: Route + 'static,
195    F: FnOnce(ReadSignal<R>) -> View + 'static,
196    I: Integration + 'static,
197{
198    view! {
199        RouterBase(
200            view=props.view,
201            integration=props.integration,
202            // The derive macro makes this the `#[not_found]` route (always present)
203            route=R::default(),
204        )
205    }
206}
207
208/// A lower-level router component that takes an instance of your [`Route`] type. This is designed
209/// for `struct` [`Route`]s, which can be used to store additional information along with routes.
210///
211/// This is a very specific use-case, and you probably actually want [`Router`]!
212#[component]
213pub fn RouterBase<R, F, I>(props: RouterBaseProps<R, F, I>) -> View
214where
215    R: Route + 'static,
216    F: FnOnce(ReadSignal<R>) -> View + 'static,
217    I: Integration + 'static,
218{
219    let RouterBaseProps {
220        view,
221        integration,
222        route,
223    } = props;
224    let integration = Rc::new(integration);
225    let base_pathname = base_pathname();
226
227    PATHNAME.with(|pathname| {
228        assert!(
229            pathname.get().is_none(),
230            "cannot have more than one Router component initialized"
231        );
232        // Get initial url from window.location.
233        let path = integration.current_pathname();
234        let path = path.strip_prefix(&base_pathname).unwrap_or(&path);
235        pathname.set(Some(create_signal(path.to_string())));
236    });
237    let pathname = PATHNAME.with(|p| p.get().unwrap_throw());
238
239    // Set PATHNAME to None when the Router is destroyed.
240    on_cleanup(|| PATHNAME.with(|pathname| pathname.set(None)));
241
242    // Listen to popstate event.
243    integration.on_popstate(Box::new({
244        let integration = integration.clone();
245        move || {
246            let path = integration.current_pathname();
247            let path = path.strip_prefix(&base_pathname).unwrap_or(&path);
248            if pathname.with(|pathname| pathname != path) {
249                pathname.set(path.to_string());
250            }
251        }
252    }));
253    let route_signal = create_memo(move || pathname.with(|pathname| route.match_path(pathname)));
254    let view = view(route_signal);
255    let nodes = view.as_web_sys();
256    on_mount(move || {
257        for node in nodes {
258            let handler: Closure<dyn FnMut(web_sys::MouseEvent)> =
259                Closure::new(integration.click_handler());
260            node.add_event_listener_with_callback("click", handler.into_js_value().unchecked_ref())
261                .unwrap(); // TODO: manage in scope
262        }
263        // TODO: this does not work for dynamic views
264    });
265    view
266}
267
268/// Props for [`StaticRouter`].
269#[derive(Props, Debug)]
270pub struct StaticRouterProps<R, F>
271where
272    R: Route + 'static,
273    F: Fn(ReadSignal<R>) -> View + 'static,
274{
275    view: F,
276    route: R,
277}
278
279impl<R, F> StaticRouterProps<R, F>
280where
281    R: Route + 'static,
282    F: Fn(ReadSignal<R>) -> View + 'static,
283{
284    /// Create a new [`StaticRouterProps`].
285    pub fn new(route: R, view: F) -> Self {
286        Self { view, route }
287    }
288}
289
290/// A router that only renders once with the given `route`.
291///
292/// This is useful for SSR where we want the HTML to be rendered instantly instead of waiting for
293/// the route preload to finish loading.
294#[component]
295pub fn StaticRouter<R, F>(props: StaticRouterProps<R, F>) -> View
296where
297    R: Route + 'static,
298    F: Fn(ReadSignal<R>) -> View + 'static,
299{
300    view! {
301        StaticRouterBase(view=props.view, route=props.route)
302    }
303}
304
305/// Implementation detail for [`StaticRouter`]. The extra component is needed to make sure hydration
306/// keys are consistent.
307#[component]
308fn StaticRouterBase<R, F>(props: StaticRouterProps<R, F>) -> View
309where
310    R: Route + 'static,
311    F: Fn(ReadSignal<R>) -> View + 'static,
312{
313    let StaticRouterProps { view, route } = props;
314
315    view(*create_signal(route))
316}
317
318/// Navigates to the specified `url`. The url should have the same origin as the app.
319///
320/// This is useful for imperatively navigating to an url when using an anchor tag (`<a>`) is not
321/// possible/suitable (e.g. when submitting a form).
322///
323/// # Panics
324/// This function will `panic!()` if a [`Router`] has not yet been created.
325pub fn navigate(url: &str) {
326    PATHNAME.with(|pathname| {
327        assert!(
328            pathname.get().is_some(),
329            "navigate can only be used with a Router"
330        );
331
332        let pathname = pathname.get().unwrap_throw();
333        let path = url.strip_prefix(&base_pathname()).unwrap_or(url);
334        pathname.set(path.to_string());
335
336        // Update History API.
337        let history = window().history().unwrap_throw();
338        history
339            .push_state_with_url(&JsValue::UNDEFINED, "", Some(url))
340            .unwrap_throw();
341        window().scroll_to_with_x_and_y(0.0, 0.0);
342    });
343}
344
345/// Navigates to the specified `url` without adding a new history entry. Instead, this replaces the
346/// current location with the new `url`. The url should have the same origin as the app.
347///
348/// This is useful for imperatively navigating to an url when using an anchor tag (`<a>`) is not
349/// possible/suitable (e.g. when submitting a form).
350///
351/// # Panics
352/// This function will `panic!()` if a [`Router`] has not yet been created.
353pub fn navigate_replace(url: &str) {
354    PATHNAME.with(|pathname| {
355        assert!(
356            pathname.get().is_some(),
357            "navigate_replace can only be used with a Router"
358        );
359
360        let pathname = pathname.get().unwrap_throw();
361        let path = url.strip_prefix(&base_pathname()).unwrap_or(url);
362        pathname.set(path.to_string());
363
364        // Update History API.
365        let history = window().history().unwrap_throw();
366        history
367            .replace_state_with_url(&JsValue::UNDEFINED, "", Some(url))
368            .unwrap_throw();
369        window().scroll_to_with_x_and_y(0.0, 0.0);
370    });
371}
372
373fn meta_keys_pressed(kb_event: &KeyboardEvent) -> bool {
374    kb_event.meta_key() || kb_event.ctrl_key() || kb_event.shift_key() || kb_event.alt_key()
375}
376
377#[cfg(test)]
378mod tests {
379    use sycamore::prelude::*;
380
381    use super::*;
382
383    #[test]
384    fn static_router() {
385        #[derive(Route, Clone, Copy)]
386        enum Routes {
387            #[to("/")]
388            Home,
389            #[to("/about")]
390            About,
391            #[not_found]
392            NotFound,
393        }
394
395        #[component(inline_props)]
396        fn Comp(path: String) -> View {
397            let route = Routes::match_route(
398                // The user would never use this directly, so they'd never have to do this trick
399                // It doesn't matter which variant we provide here, it just needs to conform to
400                // `&self` (designed for `struct`s, as in Perseus' router)
401                &Routes::Home,
402                &path
403                    .split('/')
404                    .filter(|s| !s.is_empty())
405                    .collect::<Vec<_>>(),
406            );
407
408            view! {
409                StaticRouter(
410                    route=route,
411                    view=|route: ReadSignal<Routes>| {
412                        match route.get() {
413                            Routes::Home => view! {
414                                "Home"
415                            },
416                            Routes::About => view! {
417                                "About"
418                            },
419                            Routes::NotFound => view! {
420                                "Not Found"
421                            }
422                        }
423                    },
424                )
425            }
426        }
427
428        assert_eq!(
429            sycamore::render_to_string(|| view! { Comp(path="/".to_string()) }),
430            "Home"
431        );
432
433        assert_eq!(
434            sycamore::render_to_string(|| view! { Comp(path="/about".to_string()) }),
435            "About"
436        );
437
438        assert_eq!(
439            sycamore::render_to_string(|| view! { Comp(path="/404".to_string()) }),
440            "Not Found"
441        );
442    }
443}