Single Page App Routing

SPA routing is often needed to represent different resources within a single page application. Here we define a method of routing that relies on conversions of types and mogwai's patch:children RSX attribute.

Contents

Explanation

We first define a Route type which will hold all our available routes. Then we create conversion implementations to convert from a window hash string into a Route and back again. For this routing example (and in order to keep it simple) we also implement From<Route> for ViewBuilder, which we'll use to create views from our route.

static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

/// Here we enumerate all our app's routes.
#[derive(Clone, Debug, PartialEq)]
pub enum Route {
    Home,
    Settings,
    Profile {
        username: String,
        is_favorites: bool,
    },
}

/// We'll use TryFrom::try_from to convert the window's url hash into a Route.
impl TryFrom<&str> for Route {
    type Error = String;

    fn try_from(s: &str) -> Result<Route, String> {
        trace!("route try_from: {}", s);
        // remove the scheme, if it has one
        let hash_split = s.split("#").collect::<Vec<_>>();
        let after_hash = match hash_split.as_slice() {
            [_, after] => Ok(after),
            _ => Err(format!("route must have a hash: {}", s)),
        }?;

        let paths: Vec<&str> = after_hash.split("/").collect::<Vec<_>>();
        trace!("route paths: {:?}", paths);

        match paths.as_slice() {
            [""] => Ok(Route::Home),
            ["", ""] => Ok(Route::Home),
            ["", "settings"] => Ok(Route::Settings),
            // Here you can see that profile may match two different routes -
            // '#/profile/{username}' and '#/profile/{username}/favorites'
            ["", "profile", username] => Ok(Route::Profile {
                username: username.to_string(),
                is_favorites: false,
            }),
            ["", "profile", username, "favorites"] => Ok(Route::Profile {
                username: username.to_string(),
                is_favorites: true,
            }),
            r => Err(format!("unsupported route: {:?}", r)),
        }
    }
}

#[cfg(test)]
mod test_route_try_from {
    use super::*;

    #[test]
    fn can_convert_string_to_route() {
        let s = "https://localhost:8080/#/";
        assert_eq!(Route::try_from(s), Ok(Route::Home));
    }
}

/// Convert the route into its hashed string.
/// This should match the inverse conversion in TryFrom above.
impl From<Route> for String {
    fn from(route: Route) -> String {
        match route {
            Route::Home => "#/".into(),
            Route::Settings => "#/settings".into(),
            Route::Profile {
                username,
                is_favorites,
            } => {
                if is_favorites {
                    format!("#/profile/{}/favorites", username)
                } else {
                    format!("#/profile/{}", username)
                }
            }
        }
    }
}

/// We can convert a route into a ViewBuilder in order to embed it in a component.
/// This is just a suggestion for this specific example. The general idea is
/// to use the route to inform your app that it needs to change the page. This
/// is just one of many ways to accomplish that.
impl From<&Route> for ViewBuilder {
    fn from(route: &Route) -> Self {
        match route {
            Route::Home => html! {
                <main>
                    <h1>"Welcome to the homepage"</h1>
                </main>
            },
            Route::Settings => html! {
                <main>
                    <h1>"Update your settings"</h1>
                </main>
            },
            Route::Profile {
                username,
                is_favorites,
            } => html! {
                <main>
                    <h1>{username}"'s Profile"</h1>
                    {if *is_favorites {
                        Some(html!{
                            <h2>"Favorites"</h2>
                        })
                    } else {
                        None
                    }}
                </main>
            },
        }
    }

The view will wait for the window's hashchange event. When it receives a hashchange event it will extract the new URL and send a message to the async logic loop. The view will contain a pre element to hold any potential error messages. We will use some convenience functions on Route to help display data in the view.

        let mut route = starting_route;
        while let Some(ev) = output_window_hashchange.get().await {
            let hash = {
                let ev = ev.browser_event().expect("not a browser event");
                let hev = ev.dyn_ref::<HashChangeEvent>().unwrap().clone();
                hev.new_url()
            };
            // When we get a hash change, attempt to convert it into one of our routes
            let msg = match Route::try_from(hash.as_str()) {
                // If we can't, let's send an error message to the view
                Err(msg) => msg,
                // If we _can_, create a new view from the route and send a patch message to
                // the view
                Ok(new_route) => {
                    trace!("got new route: {:?}", new_route);
                    if new_route != route {
                        route = new_route;
                        input_route.set(route.clone()).await.expect("could not set route");
                    }
                    // ...and clear any existing error message
                    String::new()
                }
            };
            input_error_msg
                .set(msg)
                .await
                .expect("could not set error msg");
        }
    })
}

#[wasm_bindgen]
pub fn main(parent_id: Option<String>) -> Result<(), JsValue> {
    panic::set_hook(Box::new(console_error_panic_hook::hook));
    console_log::init_with_level(Level::Trace).unwrap();

    // TODO: instantiate the route from the window location
    let route = Route::Home;

logic receives the hashchange, attempting to convert it into a Route and either patches the DOM with a new page or sends an error message to our error element.


    let builder = rsx! {
        slot(
            //window:hashchange = tx_logic.contra_filter_map(|ev: JsDomEvent| {
            //}),
            window:hashchange = output_window_hashchange.sink(),
            patch:children = input_route
                .stream()
                .unwrap()
                .map(|r| ListPatch::replace(2, ViewBuilder::from(&r)))
        ) {
            nav() {
                ul() {
                    li(class = starting_route.nav_home_class()) {
                        a(href = String::from(Route::Home)) { "Home" }
                    }
                    li(class = starting_route.nav_settings_class()) {
                        a(href = String::from(Route::Settings)) { "Settings" }
                    }
                    li(class = starting_route.nav_settings_class()) {
                        a(href = String::from(Route::Profile {
                            username: username.clone(),
                            is_favorites: true
                        })) {
                            {format!("{}'s Profile", username)}
                        }
                    }
                }
            }
            pre() { {("", input_error_msg.stream().unwrap())} }
            {&starting_route}
        }
    };

That's the bulk of the work.

Code

Here's the whole mogwai single page routing example on github.

Notice that the main of this example takes an optional string. This allows us to pass the id of an element that we'd like to append our routing component to. This allows us to load the example on the page right here. Click on the links below to see the route and our page change. Click on the links in the table of contents to see our errors propagate into the view.

Play with it