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.
This commit is contained in:
Finn Bear 2024-10-12 05:18:29 -07:00 committed by GitHub
parent 7be9d17ec4
commit 3c7e3e3e79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 183 additions and 17 deletions

View File

@ -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}"))

View File

@ -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::<String>::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();

View File

@ -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<fn(&Navigator, &Location)>,
}
#[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! {
<ul>
<li class="posts">
@ -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::<RootForBrowserRouter>::with_root(div).render();
let handle = yew::Renderer::<RootForBrowserRouter>::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<String>,
assertion: fn(&Navigator, &Location),
}
#[function_component(RootForBasename)]
fn root_for_basename() -> Html {
fn root_for_basename(props: &BasenameProps) -> Html {
html! {
<BrowserRouter basename="/base/">
<NavigationMenu />
<BrowserRouter basename={props.basename.clone()}>
<NavigationMenu assertion={props.assertion}/>
</BrowserRouter>
}
}
#[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::<RootForBasename>::with_root(div).render();
let mut handle = yew::Renderer::<RootForBasename>::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::<RootForHashRouter>::with_root(div).render();
let handle = yew::Renderer::<RootForHashRouter>::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;
}