From 3c7e3e3e79e047922aad04ccf11d48ddac91cc36 Mon Sep 17 00:00:00 2001 From: Finn Bear Date: Sat, 12 Oct 2024 05:18:29 -0700 Subject: [PATCH] yew-router: Dynamic basename. (#3725) * yew-router: Dynamic basename. * Revisions. * Test location.path and navigator.basename match expectations at each step. * Better coverage of edge case. --- packages/yew-router/src/navigator.rs | 2 +- packages/yew-router/src/router.rs | 39 ++++++- packages/yew-router/tests/link.rs | 159 +++++++++++++++++++++++++-- 3 files changed, 183 insertions(+), 17 deletions(-) diff --git a/packages/yew-router/src/navigator.rs b/packages/yew-router/src/navigator.rs index 48345609f83..dec228821b8 100644 --- a/packages/yew-router/src/navigator.rs +++ b/packages/yew-router/src/navigator.rs @@ -159,7 +159,7 @@ impl Navigator { pub(crate) fn prefix_basename<'a>(&self, route_s: &'a str) -> Cow<'a, str> { match self.basename() { Some(base) => { - if route_s.is_empty() && route_s.is_empty() { + if base.is_empty() && route_s.is_empty() { Cow::from("/") } else { Cow::from(format!("{base}{route_s}")) diff --git a/packages/yew-router/src/router.rs b/packages/yew-router/src/router.rs index b3becda9ea1..0c4b4dedf4c 100644 --- a/packages/yew-router/src/router.rs +++ b/packages/yew-router/src/router.rs @@ -1,6 +1,8 @@ //! Router Component. +use std::borrow::Cow; use std::rc::Rc; +use gloo::history::query::Raw; use yew::prelude::*; use yew::virtual_dom::AttrValue; @@ -72,16 +74,43 @@ fn base_router(props: &RouterProps) -> Html { basename, } = props.clone(); + let basename = basename.map(|m| strip_slash_suffix(&m).to_owned()); + let navigator = Navigator::new(history.clone(), basename.clone()); + + let old_basename = use_mut_ref(|| Option::::None); + let mut old_basename = old_basename.borrow_mut(); + if basename != *old_basename { + // If `old_basename` is `Some`, path is probably prefixed with `old_basename`. + // If `old_basename` is `None`, path may or may not be prefixed with the new `basename`, + // depending on whether this is the first render. + let old_navigator = Navigator::new( + history.clone(), + old_basename.as_ref().or(basename.as_ref()).cloned(), + ); + *old_basename = basename.clone(); + let location = history.location(); + let stripped = old_navigator.strip_basename(Cow::from(location.path())); + let prefixed = navigator.prefix_basename(&stripped); + + if prefixed != location.path() { + history + .replace_with_query(prefixed, Raw(location.query_str())) + .unwrap_or_else(|never| match never {}); + } else { + // Reaching here is possible if the page loads with the correct path, including the + // initial basename. In that case, the new basename would be stripped and then + // prefixed right back. While replacing the history would probably be harmless, + // we might as well avoid doing it. + } + } + + let navi_ctx = NavigatorContext { navigator }; + let loc_ctx = use_reducer(|| LocationContext { location: history.location(), ctr: 0, }); - let basename = basename.map(|m| strip_slash_suffix(&m).to_string()); - let navi_ctx = NavigatorContext { - navigator: Navigator::new(history.clone(), basename), - }; - { let loc_ctx_dispatcher = loc_ctx.dispatcher(); diff --git a/packages/yew-router/tests/link.rs b/packages/yew-router/tests/link.rs index 50510b79242..8e3f5bc5f41 100644 --- a/packages/yew-router/tests/link.rs +++ b/packages/yew-router/tests/link.rs @@ -1,5 +1,8 @@ +use std::sync::atomic::{AtomicU8, Ordering}; use std::time::Duration; +use gloo::utils::window; +use js_sys::{JsString, Object, Reflect}; use serde::{Deserialize, Serialize}; use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure}; use yew::functional::function_component; @@ -47,8 +50,20 @@ enum Routes { Search, } +#[derive(PartialEq, Properties)] +struct NavigationMenuProps { + #[prop_or(None)] + assertion: Option, +} + #[function_component(NavigationMenu)] -fn navigation_menu() -> Html { +fn navigation_menu(props: &NavigationMenuProps) -> Html { + let navigator = use_navigator().unwrap(); + let location = use_location().unwrap(); + if let Some(assertion) = props.assertion { + assertion(&navigator, &location); + } + html! {
  • @@ -89,12 +104,11 @@ fn root_for_browser_router() -> Html { } } -#[test] async fn link_in_browser_router() { let div = gloo::utils::document().create_element("div").unwrap(); let _ = div.set_attribute("id", "browser-router"); let _ = gloo::utils::body().append_child(&div); - yew::Renderer::::with_root(div).render(); + let handle = yew::Renderer::::with_root(div).render(); sleep(Duration::ZERO).await; @@ -113,26 +127,77 @@ async fn link_in_browser_router() { "/search?q=Rust&lang=en_US", link_href("#browser-router ul > li.search-q-lang > a") ); + + handle.destroy(); +} + +#[derive(PartialEq, Properties)] +struct BasenameProps { + basename: Option, + assertion: fn(&Navigator, &Location), } #[function_component(RootForBasename)] -fn root_for_basename() -> Html { +fn root_for_basename(props: &BasenameProps) -> Html { html! { - - + + } } -#[test] -async fn link_with_basename() { +async fn link_with_basename(correct_initial_path: bool) { + if correct_initial_path { + let cookie = Object::new(); + Reflect::set(&cookie, &JsString::from("foo"), &JsString::from("bar")).unwrap(); + window() + .history() + .unwrap() + .replace_state_with_url(&cookie, "", Some("/base/")) + .unwrap(); + } + + static RENDERS: AtomicU8 = AtomicU8::new(0); + RENDERS.store(0, Ordering::Relaxed); + let div = gloo::utils::document().create_element("div").unwrap(); let _ = div.set_attribute("id", "with-basename"); let _ = gloo::utils::body().append_child(&div); - yew::Renderer::::with_root(div).render(); + + let mut handle = yew::Renderer::::with_root_and_props( + div, + BasenameProps { + basename: Some("/base/".to_owned()), + assertion: |navigator, location| { + RENDERS.fetch_add(1, Ordering::Relaxed); + assert_eq!(navigator.basename(), Some("/base")); + assert_eq!(location.path(), "/base/"); + }, + }, + ) + .render(); sleep(Duration::ZERO).await; + if correct_initial_path { + // If the initial path was correct, the router shouldn't have mutated the history. + assert_eq!( + Reflect::get( + &window().history().unwrap().state().unwrap(), + &JsString::from("foo") + ) + .unwrap() + .as_string() + .as_deref(), + Some("bar") + ); + } + + assert_eq!( + "/base/", + gloo::utils::window().location().pathname().unwrap() + ); + assert_eq!("/base/posts", link_href("#with-basename ul > li.posts > a")); assert_eq!( "/base/posts?page=2", @@ -151,6 +216,68 @@ async fn link_with_basename() { "/base/search?q=Rust&lang=en_US", link_href("#with-basename ul > li.search-q-lang > a") ); + + // Some(a) -> Some(b) + handle.update(BasenameProps { + basename: Some("/bayes/".to_owned()), + assertion: |navigator, location| { + RENDERS.fetch_add(1, Ordering::Relaxed); + assert_eq!(navigator.basename(), Some("/bayes")); + assert_eq!(location.path(), "/bayes/"); + }, + }); + + sleep(Duration::ZERO).await; + + assert_eq!( + "/bayes/", + gloo::utils::window().location().pathname().unwrap() + ); + + assert_eq!( + "/bayes/posts", + link_href("#with-basename ul > li.posts > a") + ); + + // Some -> None + handle.update(BasenameProps { + basename: None, + assertion: |navigator, location| { + RENDERS.fetch_add(1, Ordering::Relaxed); + assert_eq!(navigator.basename(), None); + assert_eq!(location.path(), "/"); + }, + }); + + sleep(Duration::ZERO).await; + + assert_eq!("/", gloo::utils::window().location().pathname().unwrap()); + + assert_eq!("/posts", link_href("#with-basename ul > li.posts > a")); + + // None -> Some + handle.update(BasenameProps { + basename: Some("/bass/".to_string()), + assertion: |navigator, location| { + RENDERS.fetch_add(1, Ordering::Relaxed); + assert_eq!(navigator.basename(), Some("/bass")); + assert_eq!(location.path(), "/bass/"); + }, + }); + + sleep(Duration::ZERO).await; + + assert_eq!( + "/bass/", + gloo::utils::window().location().pathname().unwrap() + ); + + assert_eq!("/bass/posts", link_href("#with-basename ul > li.posts > a")); + + handle.destroy(); + + // 1 initial, 1 rerender after initial, 3 props changes + assert_eq!(RENDERS.load(Ordering::Relaxed), 5); } #[function_component(RootForHashRouter)] @@ -162,12 +289,11 @@ fn root_for_hash_router() -> Html { } } -#[test] async fn link_in_hash_router() { let div = gloo::utils::document().create_element("div").unwrap(); let _ = div.set_attribute("id", "hash-router"); let _ = gloo::utils::body().append_child(&div); - yew::Renderer::::with_root(div).render(); + let handle = yew::Renderer::::with_root(div).render(); sleep(Duration::ZERO).await; @@ -186,4 +312,15 @@ async fn link_in_hash_router() { "#/search?q=Rust&lang=en_US", link_href("#hash-router ul > li.search-q-lang > a") ); + + handle.destroy(); +} + +// These cannot be run in concurrently because they all read/write the URL. +#[test] +async fn sequential_tests() { + link_in_hash_router().await; + link_in_browser_router().await; + link_with_basename(false).await; + link_with_basename(true).await; }