next_rs/
router.rs

1use crate::log;
2use std::borrow::Cow;
3
4use crate::history::{AnyHistory, BrowserHistory, History, HistoryError, HistoryResult};
5use crate::prelude::*;
6use crate::use_context;
7use serde_json::Value;
8use std::collections::{HashMap, HashSet};
9use std::rc::Rc;
10use yew_router::prelude::Location;
11
12use gloo_net::http::Request;
13use web_sys::{EventListener, RequestCache};
14
15use wasm_bindgen_futures::spawn_local;
16use web_sys::js_sys::Function;
17
18/// Represents errors related to navigation.
19pub type NavigationError = HistoryError;
20
21/// Represents results of navigation operations.
22pub type NavigationResult<T> = HistoryResult<T>;
23
24/// Represents the context of the current location.
25#[derive(Clone)]
26pub struct LocationContext {
27    location: Location,
28    // Counter to force update.
29    ctr: u32,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct ComponentInfo {
34    pub component: Html,
35    pub err: &'static str,
36}
37
38impl LocationContext {
39    /// Returns the current location.
40    pub fn location(&self) -> Location {
41        self.location.clone()
42    }
43}
44
45impl PartialEq for LocationContext {
46    fn eq(&self, rhs: &Self) -> bool {
47        self.ctr == rhs.ctr
48    }
49}
50
51impl Reducible for LocationContext {
52    type Action = Location;
53
54    /// Reduces the state by applying the provided action.
55    ///
56    /// # Arguments
57    ///
58    /// * `action` - The action to apply to the state.
59    ///
60    /// # Returns
61    ///
62    /// (Rc<Self>): A new reference-counted state after applying the action.
63    fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
64        Self {
65            location: action,
66            ctr: self.ctr + 1,
67        }
68        .into()
69    }
70}
71
72/// Props for [`NextRouter`].
73#[derive(Properties, PartialEq, Clone)]
74pub struct RouterProps {
75    /// Children components to be rendered.
76    #[prop_or_default]
77    pub children: Html,
78    /// The history instance for navigation.
79    #[prop_or(AnyHistory::Browser(BrowserHistory::new()))]
80    pub history: AnyHistory,
81    /// The base URL for the router.
82    #[prop_or_default]
83    pub basename: &'static str,
84}
85
86/// The kind of Router Provider.
87#[derive(Debug, PartialEq, Eq, Clone, Copy)]
88pub enum RouterKind {
89    /// Browser History.
90    Browser,
91    /// Hash History.
92    Hash,
93    /// Memory History.
94    Memory,
95}
96
97/// Represents the context of the current router.
98#[derive(Clone, PartialEq)]
99pub struct RouterContext {
100    router: Router,
101}
102
103impl RouterContext {
104    /// Returns the current router instance.
105    pub fn router(&self) -> Router {
106        self.router.clone()
107    }
108}
109/// A struct representing the router for navigation.
110#[derive(Debug, Clone)]
111pub struct Router {
112    /// The history instance for navigation.
113    history: AnyHistory,
114
115    /// The base URL for the router.
116    basename: &'static str,
117
118    /// The current route of the router.
119    route: &'static str,
120
121    /// A mapping of route names to corresponding component information.
122    components: HashMap<&'static str, ComponentInfo>,
123
124    /// Set of routes currently being fetched or loaded.
125    fetching_routes: HashSet<String>,
126
127    /// Event listener for router events.
128    events: EventListener,
129
130    /// The error component to be rendered in case of errors.
131    error_component: Html,
132
133    /// The current pathname of the router.
134    pathname: &'static str,
135
136    /// The query parameters associated with the current route.
137    query: Value,
138
139    /// The path for the current route.
140    as_path: &'static str,
141
142    /// Subscriptions to router events with corresponding callbacks.
143    subscriptions: Vec<Callback<ComponentInfo>>,
144
145    /// Callback to cancel the loading of a component.
146    component_load_cancel: Callback<()>,
147}
148
149// Implement PartialEq manually for Router
150impl PartialEq for Router {
151    fn eq(&self, other: &Self) -> bool {
152        self.history == other.history
153            && self.basename == other.basename
154            && self.route == other.route
155            && self.components == other.components
156            && self.fetching_routes.len() == other.fetching_routes.len()
157            && self.events == other.events
158            && self.error_component == other.error_component
159            && self.pathname == other.pathname
160            && self.query == other.query
161            && self.as_path == other.as_path
162            && self.subscriptions.len() == other.subscriptions.len()
163            && self.component_load_cancel == other.component_load_cancel
164    }
165}
166
167impl Router {
168    /// Creates a new router instance.
169    ///
170    /// # Arguments
171    ///
172    /// * `history` - The history instance for navigation.
173    /// * `basename` - The base URL for the router.
174    /// * `route` - The default route for the router.
175    /// * `components` - A mapping of route names to component information.
176    /// * `fetching_routes` - Set of routes currently being fetched.
177    /// * `events` - Event listener for handling router events.
178    /// * `error_component` - The component to display in case of navigation errors.
179    /// * `pathname` - The current pathname of the router.
180    /// * `query` - The current query parameters of the router.
181    /// * `as_path` - The current path as a string.
182    /// * `subscriptions` - List of callbacks for component information updates.
183    /// * `component_load_cancel` - Callback for cancelling component loading.
184    ///
185    /// # Returns
186    ///
187    /// A new `Router` instance.
188    pub fn new(
189        history: AnyHistory,
190        basename: &'static str,
191        route: &'static str,
192        components: HashMap<&'static str, ComponentInfo>,
193        fetching_routes: HashSet<String>,
194        events: EventListener,
195        error_component: Html,
196        pathname: &'static str,
197        query: Value,
198        as_path: &'static str,
199        subscriptions: Vec<Callback<ComponentInfo>>,
200        component_load_cancel: Callback<()>,
201    ) -> Self {
202        Self {
203            history,
204            basename,
205            route,
206            components,
207            fetching_routes,
208            events,
209            error_component,
210            pathname,
211            query,
212            as_path,
213            subscriptions,
214            component_load_cancel,
215        }
216    }
217
218    /// Returns the basename of the current router.
219    pub fn basename(&self) -> &'static str {
220        self.basename
221    }
222
223    /// Navigates back by one page.
224    pub fn back(&self) {
225        self.go(-1);
226    }
227
228    /// Navigates forward by one page.
229    pub fn forward(&self) {
230        self.go(1);
231    }
232
233    /// Navigates to a specific page with a `delta` relative to the current page.
234    ///
235    /// # Arguments
236    ///
237    /// * `delta` - The number of pages to navigate (positive for forward, negative for backward).
238    ///
239    /// See: <https://developer.mozilla.org/en-US/docs/Web/API/History/go>
240    pub fn go(&self, delta: isize) {
241        self.history.go(delta);
242    }
243
244    /// Pushes a route onto the history stack.
245    ///
246    /// # Arguments
247    ///
248    /// * `route` - The route to be pushed.
249    pub fn push(&mut self, route: &'static str) {
250        self.route = route;
251        self.history.push(self.prefix_basename(route));
252    }
253
254    /// Replaces the current history entry with the provided route.
255    ///
256    /// # Arguments
257    ///
258    /// * `route` - The route to replace the current history entry.
259    pub fn replace(&mut self, route: &'static str) {
260        self.route = route;
261        self.history.replace(self.prefix_basename(route));
262    }
263
264    /// Pushes a route onto the history stack with state.
265    ///
266    /// # Arguments
267    ///
268    /// * `route` - The route to be pushed.
269    /// * `state` - The state to be associated with the route.
270    pub fn push_with_state(&mut self, route: &'static str, state: &'static str) {
271        self.route = route;
272        self.history
273            .push_with_state(self.prefix_basename(route), state);
274    }
275
276    /// Replaces the current history entry with the provided route and state.
277    ///
278    /// # Arguments
279    ///
280    /// * `route` - The route to replace the current history entry.
281    /// * `state` - The state to be associated with the route.
282    pub fn replace_with_state(&mut self, route: &'static str, state: &'static str) {
283        self.route = route;
284        self.history
285            .replace_with_state(self.prefix_basename(route), state);
286    }
287
288    /// Pushes a route onto the history stack with query parameters.
289    ///
290    /// # Arguments
291    ///
292    /// * `route` - The route to be pushed.
293    /// * `query` - The query parameters to be associated with the route.
294    ///
295    /// # Returns
296    ///
297    /// A `NavigationResult` indicating the success of the operation.
298    pub fn push_with_query(&mut self, route: &'static str, query: &Value) -> NavigationResult<()> {
299        self.route = route;
300        self.query = query.clone();
301        self.history
302            .push_with_query(self.prefix_basename(route), query)
303    }
304
305    /// Pushes a route onto the history stack with query parameters and state.
306    ///
307    /// # Arguments
308    ///
309    /// * `route` - The route to be pushed.
310    /// * `query` - The query parameters to be associated with the route.
311    /// * `state` - The state to be associated with the route.
312    ///
313    /// # Returns
314    ///
315    /// A `NavigationResult` indicating the success of the operation.
316    pub fn push_with_query_and_state(
317        &mut self,
318        route: &'static str,
319        query: &Value,
320        state: &'static str,
321    ) -> NavigationResult<()> {
322        self.route = route;
323        self.query = query.clone();
324        self.history
325            .push_with_query_and_state(self.prefix_basename(route), query, state)
326    }
327
328    /// Replaces the current history entry with the provided route, query parameters, and state.
329    ///
330    /// # Arguments
331    ///
332    /// * `route` - The route to replace the current history entry.
333    /// * `query` - The query parameters to be associated with the route.
334    /// * `state` - The state to be associated with the route.
335    ///
336    /// # Returns
337    ///
338    /// A `NavigationResult` indicating the success of the operation.
339    pub fn replace_with_query_and_state(
340        &mut self,
341        route: &'static str,
342        query: &Value,
343        state: Value,
344    ) -> NavigationResult<()> {
345        self.route = route;
346        self.query = query.clone();
347        self.history
348            .replace_with_query_and_state(self.prefix_basename(route), query, state)
349    }
350
351    /// Returns the kind of the router.
352    ///
353    /// # Returns
354    ///
355    /// A `RouterKind` enum representing the type of the router.
356    pub fn kind(&self) -> RouterKind {
357        match &self.history {
358            AnyHistory::Browser(_) => RouterKind::Browser,
359            AnyHistory::Hash(_) => RouterKind::Hash,
360            AnyHistory::Memory(_) => RouterKind::Memory,
361        }
362    }
363
364    /// Prefixes the basename to the route.
365    ///
366    /// # Arguments
367    ///
368    /// * `route_s` - The route to prefix with the basename.
369    ///
370    /// # Returns
371    ///
372    /// A `Cow<'a, str>` containing the combined route with the basename.
373    pub fn prefix_basename<'a>(&self, route_s: &'a str) -> Cow<'a, str> {
374        let base = self.basename();
375        if !base.is_empty() {
376            if route_s.is_empty() && route_s.is_empty() {
377                Cow::from("/")
378            } else {
379                Cow::from(format!("{base}{route_s}"))
380            }
381        } else {
382            route_s.into()
383        }
384    }
385
386    /// Strips the basename from the path.
387    ///
388    /// # Arguments
389    ///
390    /// * `path` - The path to strip the basename from.
391    ///
392    /// # Returns
393    ///
394    /// A `Cow<'a, str>` containing the path with the basename stripped.
395    pub fn strip_basename<'a>(&self, path: Cow<'a, str>) -> Cow<'a, str> {
396        let m = self.basename();
397        if !m.is_empty() {
398            let mut path = path
399                .strip_prefix(m)
400                .map(|m| Cow::from(m.to_owned()))
401                .unwrap_or(path);
402
403            if !path.starts_with('/') {
404                path = format!("/{m}").into();
405            }
406
407            path
408        } else {
409            path
410        }
411    }
412
413    /// Prefetches the specified URL by fetching its route information.
414    ///
415    /// # Arguments
416    ///
417    /// * `url` - The URL to prefetch.
418    pub fn prefetch(&mut self, url: &'static str) {
419        self.fetch_route(url.to_string());
420    }
421
422    /// Asynchronously fetches route information for the given URL.
423    ///
424    /// # Arguments
425    ///
426    /// * `url` - The URL for which to fetch route information.
427    ///
428    /// # Returns
429    ///
430    /// A `Result` containing `ComponentInfo` on success and an error `Value` on failure.
431    async fn fetch_gloo_net(url: &str) -> Result<ComponentInfo, Value> {
432        let response = match Request::get(url).cache(RequestCache::Reload).send().await {
433            Ok(res) => res,
434            Err(err) => {
435                return Err(err.to_string().into());
436            }
437        };
438
439        let _json_result = match response.json::<serde_json::Value>().await {
440            Ok(data) => data,
441            Err(err) => {
442                return Err(err.to_string().into());
443            }
444        };
445
446        Ok(ComponentInfo {
447            component: rsx! {},
448            err: "",
449        })
450    }
451    /// Initiates the fetching of route information for the specified route.
452    ///
453    /// # Arguments
454    ///
455    /// * `route` - The route to fetch.
456    fn fetch_route(&mut self, route: String) {
457        let url = format!("/{}/index.json", route);
458        let events = EventListener::new();
459        let subscriptions = self.subscriptions.clone();
460        let as_path = self.as_path;
461        let route = route.clone();
462        let self_route = self.route;
463        let fetching_routes = Callback::from(move |_: String| {
464            let url = url.clone();
465            let mut fetching_routes = HashSet::new();
466            let mut events = events.clone();
467            let subscriptions = subscriptions.clone();
468            let as_path = as_path;
469            let route = route.clone();
470            let self_route = self_route;
471            spawn_local(async move {
472                let result = match Self::fetch_gloo_net(&url).await {
473                    Ok(component_info) => {
474                        fetching_routes.insert(route.clone());
475                        if self_route == route {
476                            if !component_info.err.is_empty() {
477                                events.handle_event(&Function::new_with_args(
478                                    "route_change_error",
479                                    as_path,
480                                ));
481                            }
482                            Self::notify(subscriptions, component_info);
483                            events.handle_event(&Function::new_with_args(
484                                "route_change_complete",
485                                as_path,
486                            ));
487                        }
488                        Ok(())
489                    }
490                    Err(fetch_error) => {
491                        fetching_routes.insert(route.clone());
492                        log(&format!("Error fetching route: {:?}", fetch_error).into());
493                        if self_route == route {
494                            let component_info = ComponentInfo {
495                                component: rsx! {},
496                                err: "Error fetching route",
497                            };
498                            Self::notify(subscriptions, component_info);
499                            events.handle_event(&Function::new_with_args(
500                                "route_change_complete",
501                                as_path,
502                            ));
503                        }
504                        Err(fetch_error)
505                    }
506                };
507
508                if let Err(error) = result {
509                    log(&format!("Failed to handle fetch result: {:?}", error).into());
510                }
511            });
512            // fetching_routes.clone()
513        });
514        // self.fetching_routes = fetching_routes.emit("".to_string());
515        fetching_routes.emit("".to_string())
516    }
517
518    /// Notifies all subscribed callbacks with the provided route information.
519    ///
520    /// # Arguments
521    ///
522    /// * `subscriptions` - A vector of callbacks to notify.
523    /// * `data` - The route information to emit to the callbacks.
524    fn notify(subscriptions: Vec<Callback<ComponentInfo>>, data: ComponentInfo) {
525        subscriptions.iter().for_each(|callback| {
526            callback.emit(data.clone());
527        });
528    }
529
530    /// Subscribes to route change events and returns an unsubscribe callback.
531    ///
532    /// # Arguments
533    ///
534    /// * `callback` - The callback to be notified on route changes.
535    ///
536    /// # Returns
537    ///
538    /// A `Callback<()>` that can be used to unsubscribe from route change events.
539    fn _subscribe(&mut self, callback: Callback<ComponentInfo>) -> Callback<()> {
540        // Creates a Listener that will be notified when current state changes.
541        // self.history.listen(callback);
542        self.subscriptions.push(callback.clone());
543        Callback::from(move |_| {
544            // TODO: Implement unsubscribe, rm from subscriptions vec
545        })
546    }
547}
548
549/// The base router component.
550///
551/// This component ensures that `<Router />` has the same virtual DOM layout as `<BrowserRouter />`
552/// and `<HashRouter />`.
553///
554/// # Arguments
555///
556/// * `props` - The properties of the router.
557///
558/// # Returns
559///
560/// (Html): An HTML representation of the router component.
561///
562/// # Example
563/// ```
564/// use next_rs::prelude::*;
565/// use next_rs::router::*;
566/// use next_rs::history::{BrowserHistory, AnyHistory};
567///
568/// #[func]
569/// fn MyComponent() -> Html {
570///     rsx! {
571///         <BaseRouter basename="" history={AnyHistory::Browser(BrowserHistory::new())}>
572///             <div />
573///         </BaseRouter>
574///     }
575/// }
576/// ```
577#[func]
578pub fn BaseRouter(props: &RouterProps) -> Html {
579    let RouterProps {
580        history,
581        children,
582        basename,
583    } = props.clone();
584
585    let loc_ctx = use_reducer(|| LocationContext {
586        location: history.location(),
587        ctr: 0,
588    });
589
590    let trigger = use_force_update();
591    let prefetched_component = use_state(|| rsx! {<></>});
592    let component_value = (*prefetched_component).clone();
593
594    let basename = basename.strip_suffix('/').unwrap_or(basename);
595
596    let route = "/";
597    let components = HashMap::new();
598    let fetching_routes = HashSet::new();
599    let events = EventListener::new();
600    let error_component = Html::default();
601    let pathname = "";
602    let query = Value::default();
603    let as_path = "";
604    let mut subscriptions = Vec::new();
605    subscriptions.push(Callback::from(move |component: ComponentInfo| {
606        prefetched_component.set(component.component);
607        trigger.force_update();
608        log(&format!("prefetch callback...").into());
609    }));
610    let component_load_cancel = Callback::default();
611
612    let router = Router::new(
613        history.clone(),
614        basename,
615        route,
616        components,
617        fetching_routes,
618        events,
619        error_component,
620        pathname,
621        query,
622        as_path,
623        subscriptions,
624        component_load_cancel,
625    );
626    let navi_ctx = RouterContext {
627        router: router.clone(),
628    };
629
630    {
631        let loc_ctx_dispatcher = loc_ctx.dispatcher();
632
633        use_effect_with(history, move |history| {
634            let history = history.clone();
635            // Force location update when history changes.
636            loc_ctx_dispatcher.dispatch(history.location());
637
638            let history_cb = {
639                let history = history.clone();
640                move || loc_ctx_dispatcher.dispatch(history.location())
641            };
642
643            let listener = history.listen(history_cb);
644
645            // We hold the listener in the destructor.
646            move || {
647                std::mem::drop(listener);
648            }
649        });
650    }
651
652    rsx! {
653        <ContextProvider<RouterContext> context={navi_ctx}>
654            <ContextProvider<LocationContext> context={(*loc_ctx).clone()}>
655                {children}
656                {component_value}
657            </ContextProvider<LocationContext>>
658        </ContextProvider<RouterContext>>
659    }
660}
661
662/// Props for [`Switch`]
663#[derive(Properties, PartialEq, Clone)]
664pub struct SwitchProps {
665    /// Callback which returns [`Html`] to be rendered for the current route.
666    pub render: Callback<String, Html>,
667    #[prop_or_default]
668    pub pathname: &'static str,
669}
670
671/// A Switch that dispatches routes among variants of a [`Routable`].
672///
673/// When a route can't be matched, including when the path is matched but the deserialization fails,
674/// it looks for the route with the `not_found` attribute.
675/// If such a route is provided, it redirects to the specified route.
676/// Otherwise, an empty HTML element is rendered, and a message is logged to the console
677/// stating that no route can be matched.
678/// See the [crate level document][crate] for more information.
679///
680/// # Arguments
681///
682/// * `props` - The properties of the switch.
683///
684/// # Returns
685///
686/// (Html): An HTML representation of the switch component.
687///
688/// # Example
689/// ```
690/// use next_rs::prelude::*;
691/// use next_rs::router::*;
692///
693/// pub fn switch(route: String) -> Html {
694///     match route.as_str() {
695///         "/" => rsx! {<div />},
696///         _ => rsx! {<></>},
697///     }
698/// }
699///
700/// #[func]
701/// fn MySwitch() -> Html {
702///     rsx! {
703///         <Switch render={switch} />
704///     }
705/// }
706/// ```
707#[func]
708pub fn Switch(props: &SwitchProps) -> Html {
709    let mut route = use_route();
710
711    if route.is_empty() {
712        route = std::borrow::Cow::Borrowed(props.pathname);
713    }
714
715    if !route.is_empty() {
716        props.render.emit(route.to_string())
717    } else {
718        Html::default()
719    }
720}
721
722/// The NextRouter component.
723///
724/// This component provides location and navigator context to its children and switches.
725///
726/// If you are building a web application, you may want to consider using [`BrowserRouter`] instead.
727///
728/// You only need one `<Router />` for each application.
729///
730/// # Arguments
731///
732/// * `props` - The properties of the router.
733///
734/// # Returns
735///
736/// (Html): An HTML representation of the router component.
737///
738/// # Example
739/// ```
740/// use next_rs::prelude::*;
741/// use next_rs::router::*;
742/// use next_rs::history::{BrowserHistory, AnyHistory};
743///
744/// #[func]
745/// fn MyComponent() -> Html {
746///     rsx! {
747///         <NextRouter basename="" history={AnyHistory::Browser(BrowserHistory::new())}>
748///             <div />
749///         </NextRouter>
750///     }
751/// }
752/// ```
753#[func]
754pub fn NextRouter(props: &RouterProps) -> Html {
755    rsx! {
756        <BaseRouter ..props.clone() />
757    }
758}
759
760/// A hook to access the [`Router`] instance.
761///
762/// This hook allows components to access the router, which manages the application's navigation and routes.
763/// It retrieves the router from the current context and returns it.
764#[hook]
765pub fn use_router() -> Router {
766    use_context::<RouterContext>()
767        .map(|m| m.router())
768        .expect("router")
769}
770
771/// A hook to access the current [`Location`] information.
772///
773/// This hook provides components with access to the current location, including details such as the path and query parameters.
774/// It retrieves the location from the current context and returns it as an `Option`.
775#[hook]
776pub fn use_location() -> Option<Location> {
777    Some(use_context::<LocationContext>()?.location())
778}
779
780/// A hook to access the current route path with the basename stripped.
781///
782/// This hook is useful for components that need the current route path with the basename removed.
783/// It uses the `use_router` and `use_location` hooks to get the router and location information,
784/// then strips the basename from the location path, returning the stripped path as a `Cow<'static, str>`.
785#[hook]
786pub fn use_route() -> Cow<'static, str> {
787    let router = use_router();
788    let location = use_location().expect("location");
789
790    let stripped_path: Cow<'static, str> = router
791        .strip_basename(Cow::Borrowed(location.path()))
792        .into_owned()
793        .into();
794
795    stripped_path
796}