Add HashRouter, basename and use gloo-history (#2239)

* Migrate to gloo-history.

* Fix docs wording.

* Add basename handling to navigator.

* Fix basename handling.

* Add lints and tests.

* Fix wording.

* Fix docs.

* Fix pr-flow.

* Fix some documentation.

* Add Navigator Kind.

* Remove history.rs

* Add documentation for navigator & Concrete Router Props.

* Update website/docs/concepts/router.md

Co-authored-by: Julius Lungys <32368314+voidpumpkin@users.noreply.github.com>

* Add docs about basename.

* Update documentation.

* Fix docs.

Co-authored-by: Julius Lungys <32368314+voidpumpkin@users.noreply.github.com>
This commit is contained in:
Kaede Hoshikawa 2021-12-20 00:38:33 +09:00 committed by GitHub
parent 5b0bbd55c9
commit d8ec50150e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 769 additions and 944 deletions

View File

@ -13,7 +13,7 @@ pub enum Msg {
pub struct PostList {
page: u64,
_listener: HistoryListener,
_listener: LocationHandle,
}
fn current_page(ctx: &Context<PostList>) -> u64 {
@ -28,9 +28,10 @@ impl Component for PostList {
fn create(ctx: &Context<Self>) -> Self {
let link = ctx.link().clone();
let listener = ctx.link().history().unwrap().listen(move || {
link.send_message(Msg::PageUpdated);
});
let listener = ctx
.link()
.add_location_listener(link.callback(move |_| Msg::PageUpdated))
.unwrap();
Self {
page: current_page(ctx),

View File

@ -88,6 +88,22 @@ fn parse_variants_attributes(
};
let lit = attr.parse_args::<LitStr>()?;
let val = lit.value();
if val.find('#').is_some() {
return Err(syn::Error::new_spanned(
lit,
"You cannot use `#` in your routes. Please consider `HashRouter` instead.",
));
}
if !val.starts_with('/') {
return Err(syn::Error::new_spanned(
lit,
"relative paths are not supported at this moment.",
));
}
ats.push(lit);
for attr in attrs.iter() {

View File

@ -0,0 +1,7 @@
#[derive(yew_router::Routable)]
enum Routes {
#[at("one")]
One,
}
fn main() {}

View File

@ -0,0 +1,5 @@
error: relative paths are not supported at this moment.
--> tests/routable_derive/relative-path-fail.rs:3:10
|
3 | #[at("one")]
| ^^^^^

View File

@ -0,0 +1,7 @@
#[derive(yew_router::Routable)]
enum Routes {
#[at("/#/one")]
One,
}
fn main() {}

View File

@ -0,0 +1,5 @@
error: You cannot use `#` in your routes. Please consider `HashRouter` instead.
--> tests/routable_derive/route-with-hash-fail.rs:3:10
|
3 | #[at("/#/one")]
| ^^^^^^^^

View File

@ -20,7 +20,7 @@ gloo-utils = "0.1"
wasm-bindgen = "0.2"
js-sys = "0.3"
gloo = { version = "0.4", features = ["futures"] }
gloo = { version = "0.5", features = ["futures"] }
route-recognizer = "0.3"
serde = "1"
serde_urlencoded = "0.7"

View File

@ -5,7 +5,7 @@ use wasm_bindgen::UnwrapThrowExt;
use yew::prelude::*;
use yew::virtual_dom::AttrValue;
use crate::history::{BrowserHistory, History};
use crate::navigator::NavigatorKind;
use crate::scope_ext::RouterScopeExt;
use crate::Routable;
@ -63,13 +63,16 @@ where
match msg {
Msg::OnClick => {
let LinkProps { to, query, .. } = ctx.props();
let history = ctx.link().history().expect_throw("failed to read history");
let navigator = ctx
.link()
.navigator()
.expect_throw("failed to get navigator");
match query {
None => {
history.push(to.clone());
navigator.push(to.clone());
}
Some(data) => {
history
navigator
.push_with_query(to.clone(), data.clone())
.expect_throw("failed push history with query");
}
@ -91,7 +94,20 @@ where
e.prevent_default();
Msg::OnClick
});
let href: AttrValue = BrowserHistory::route_to_url(to).into();
let navigator = ctx
.link()
.navigator()
.expect_throw("failed to get navigator");
let href: AttrValue = {
let href = navigator.route_to_url(to);
match navigator.kind() {
NavigatorKind::Hash => format!("#{}", href).into(),
_ => href,
}
.into()
};
html! {
<a class={classes}
{href}

View File

@ -1,8 +1,7 @@
use wasm_bindgen::UnwrapThrowExt;
use yew::prelude::*;
use crate::history::History;
use crate::hooks::use_history;
use crate::hooks::use_navigator;
use crate::Routable;
/// Props for [`Redirect`]
@ -18,7 +17,7 @@ pub fn redirect<R>(props: &RedirectProps<R>) -> Html
where
R: Routable + 'static,
{
let history = use_history().expect_throw("failed to read history.");
let history = use_navigator().expect_throw("failed to read history.");
let target_route = props.to.clone();
use_effect(move || {

View File

@ -1,684 +0,0 @@
//! A module that provides universal session history and location information.
use std::borrow::Cow;
use std::cell::RefCell;
use std::rc::{Rc, Weak};
use gloo::events::EventListener;
use gloo_utils::window;
use serde::de::DeserializeOwned;
use serde::Serialize;
use thiserror::Error;
use wasm_bindgen::{JsValue, UnwrapThrowExt};
use yew::callback::Callback;
use crate::utils::base_url;
use crate::Routable;
/// A History Listener to manage callbacks registered on a [`History`].
///
/// This Listener has the same behaviour as the [`EventListener`] from [`gloo`]
/// that the underlying callback will be unregistered when the listener is dropped.
pub struct HistoryListener {
_listener: Rc<Callback<()>>,
}
#[derive(Error, Debug)]
pub enum HistoryError {
#[error("failed to serialize / deserialize state.")]
State(#[from] serde_wasm_bindgen::Error),
#[error("failed to serialize query.")]
QuerySer(#[from] serde_urlencoded::ser::Error),
#[error("failed to deserialize query.")]
QueryDe(#[from] serde_urlencoded::de::Error),
}
pub type HistoryResult<T> = std::result::Result<T, HistoryError>;
/// A trait to provide [`History`] access.
pub trait History: Clone + PartialEq {
type Location: Location<History = Self> + 'static;
/// Returns the number of elements in [`History`].
fn len(&self) -> usize;
/// Returns true if the current [`History`] is empty.
fn is_empty(&self) -> bool {
self.len() == 0
}
/// Moves back 1 page in [`History`].
fn back(&self) {
self.go(-1);
}
/// Moves forward 1 page in [`History`].
fn forward(&self) {
self.go(1);
}
/// Loads a specific page in [`History`] with a `delta` relative to current page.
///
/// See: <https://developer.mozilla.org/en-US/docs/Web/API/History/go>
fn go(&self, delta: isize);
/// Pushes a [`Routable`] entry with [`None`] being the state.
fn push(&self, route: impl Routable);
/// Replaces the current history entry with provided [`Routable`] and [`None`] state.
fn replace(&self, route: impl Routable);
/// Pushes a [`Routable`] entry with state.
///
/// The implementation of state serialization differs between [`History`] types.
///
/// For [`BrowserHistory`], it uses [`serde_wasm_bindgen`] where as other types uses
/// [`Any`](std::any::Any).
fn push_with_state<T>(&self, route: impl Routable, state: T) -> HistoryResult<()>
where
T: Serialize + 'static;
/// Replaces the current history entry with provided [`Routable`] and state.
///
/// The implementation of state serialization differs between [`History`] types.
///
/// For [`BrowserHistory`], it uses [`serde_wasm_bindgen`] where as other types uses
/// [`Any`](std::any::Any).
fn replace_with_state<T>(&self, route: impl Routable, state: T) -> HistoryResult<()>
where
T: Serialize + 'static;
/// Same as `.push()` but affix the queries to the end of the route.
fn push_with_query<Q>(&self, route: impl Routable, query: Q) -> HistoryResult<()>
where
Q: Serialize;
/// Same as `.replace()` but affix the queries to the end of the route.
fn replace_with_query<Q>(&self, route: impl Routable, query: Q) -> HistoryResult<()>
where
Q: Serialize;
/// Same as `.push_with_state()` but affix the queries to the end of the route.
fn push_with_query_and_state<Q, T>(
&self,
route: impl Routable,
query: Q,
state: T,
) -> HistoryResult<()>
where
Q: Serialize,
T: Serialize + 'static;
/// Same as `.replace_with_state()` but affix the queries to the end of the route.
fn replace_with_query_and_state<Q, T>(
&self,
route: impl Routable,
query: Q,
state: T,
) -> HistoryResult<()>
where
Q: Serialize,
T: Serialize + 'static;
/// Creates a Listener that will be notified when current state changes.
///
/// This method returns a [`HistoryListener`] that will automatically unregister the callback
/// when dropped.
fn listen<CB>(&self, callback: CB) -> HistoryListener
where
CB: Fn() + 'static;
/// Returns the associated [`Location`] of the current history.
fn location(&self) -> Self::Location;
fn into_any_history(self) -> AnyHistory;
/// Returns the State.
///
/// The implementation differs between [`History`] type.
///
/// For [`BrowserHistory`], it uses [`serde_wasm_bindgen`] where as other types uses
/// `downcast_ref()` on [`Any`](std::any::Any).
fn state<T>(&self) -> HistoryResult<T>
where
T: DeserializeOwned + 'static;
}
/// A [`History`] that is implemented with [`web_sys::History`] that provides native browser
/// history and state access.
#[derive(Clone)]
pub struct BrowserHistory {
inner: web_sys::History,
callbacks: Rc<RefCell<Vec<Weak<Callback<()>>>>>,
}
impl PartialEq for BrowserHistory {
fn eq(&self, _rhs: &Self) -> bool {
// All browser histories are created equal.
true
}
}
impl History for BrowserHistory {
type Location = BrowserLocation;
fn len(&self) -> usize {
self.inner.length().expect_throw("failed to get length.") as usize
}
fn go(&self, delta: isize) {
self.inner
.go_with_delta(delta as i32)
.expect_throw("failed to call go.")
}
fn push(&self, route: impl Routable) {
let url = Self::route_to_url(route);
self.inner
.push_state_with_url(&JsValue::NULL, "", Some(&url))
.expect("failed to push state.");
self.notify_callbacks();
}
fn replace(&self, route: impl Routable) {
let url = Self::route_to_url(route);
self.inner
.replace_state_with_url(&JsValue::NULL, "", Some(&url))
.expect("failed to replace history.");
self.notify_callbacks();
}
fn push_with_state<T>(&self, route: impl Routable, state: T) -> HistoryResult<()>
where
T: Serialize + 'static,
{
let url = Self::route_to_url(route);
let state = serde_wasm_bindgen::to_value(&state)?;
self.inner
.push_state_with_url(&state, "", Some(&url))
.expect("failed to push state.");
self.notify_callbacks();
Ok(())
}
fn replace_with_state<T>(&self, route: impl Routable, state: T) -> HistoryResult<()>
where
T: Serialize + 'static,
{
let url = Self::route_to_url(route);
let state = serde_wasm_bindgen::to_value(&state)?;
self.inner
.replace_state_with_url(&state, "", Some(&url))
.expect("failed to replace state.");
self.notify_callbacks();
Ok(())
}
fn push_with_query<Q>(&self, route: impl Routable, query: Q) -> HistoryResult<()>
where
Q: Serialize,
{
let url = Self::route_to_url(route);
let query = serde_urlencoded::to_string(query)?;
self.inner
.push_state_with_url(&JsValue::NULL, "", Some(&format!("{}?{}", url, query)))
.expect("failed to push history.");
self.notify_callbacks();
Ok(())
}
fn replace_with_query<Q>(&self, route: impl Routable, query: Q) -> HistoryResult<()>
where
Q: Serialize,
{
let url = Self::route_to_url(route);
let query = serde_urlencoded::to_string(query)?;
self.inner
.replace_state_with_url(&JsValue::NULL, "", Some(&format!("{}?{}", url, query)))
.expect("failed to replace history.");
self.notify_callbacks();
Ok(())
}
fn push_with_query_and_state<Q, T>(
&self,
route: impl Routable,
query: Q,
state: T,
) -> HistoryResult<()>
where
Q: Serialize,
T: Serialize + 'static,
{
let url = Self::route_to_url(route);
let query = serde_urlencoded::to_string(query)?;
let state = serde_wasm_bindgen::to_value(&state)?;
self.inner
.push_state_with_url(&state, "", Some(&format!("{}?{}", url, query)))
.expect("failed to push history.");
self.notify_callbacks();
Ok(())
}
fn replace_with_query_and_state<Q, T>(
&self,
route: impl Routable,
query: Q,
state: T,
) -> HistoryResult<()>
where
Q: Serialize,
T: Serialize + 'static,
{
let url = Self::route_to_url(route);
let query = serde_urlencoded::to_string(query)?;
let state = serde_wasm_bindgen::to_value(&state)?;
self.inner
.replace_state_with_url(&state, "", Some(&format!("{}?{}", url, query)))
.expect("failed to replace history.");
self.notify_callbacks();
Ok(())
}
fn listen<CB>(&self, callback: CB) -> HistoryListener
where
CB: Fn() + 'static,
{
// Callbacks do not receive a copy of [`History`] to prevent reference cycle.
let cb = Rc::new(Callback::from(move |_| callback()));
self.callbacks.borrow_mut().push(Rc::downgrade(&cb));
HistoryListener { _listener: cb }
}
fn location(&self) -> Self::Location {
BrowserLocation::new(self.clone())
}
fn into_any_history(self) -> AnyHistory {
AnyHistory::Browser(self)
}
fn state<T>(&self) -> HistoryResult<T>
where
T: DeserializeOwned + 'static,
{
serde_wasm_bindgen::from_value(self.inner.state().expect_throw("failed to read state."))
.map_err(|e| e.into())
}
}
impl Default for BrowserHistory {
fn default() -> Self {
// We create browser history only once.
thread_local! {
static BROWSER_HISTORY: RefCell<Option<BrowserHistory>> = RefCell::default();
static LISTENER: RefCell<Option<EventListener>> = RefCell::default();
}
BROWSER_HISTORY.with(|m| {
let mut m = m.borrow_mut();
let history = match *m {
Some(ref m) => m.clone(),
None => {
let window = window();
let inner = window
.history()
.expect_throw("Failed to create browser history. Are you using a browser?");
let callbacks = Rc::default();
let history = Self { inner, callbacks };
let history_clone = history.clone();
// Listens to popstate.
LISTENER.with(move |m| {
let mut listener = m.borrow_mut();
*listener = Some(EventListener::new(&window, "popstate", move |_| {
history_clone.notify_callbacks();
}));
});
history
}
};
*m = Some(history.clone());
history
})
}
}
impl BrowserHistory {
/// Creates a new [`BrowserHistory`]
pub fn new() -> Self {
Self::default()
}
pub(crate) fn route_to_url(route: impl Routable) -> Cow<'static, str> {
let base = base_url();
let url = route.to_path();
let path = match base {
Some(base) => {
let path = format!("{}{}", base, url);
if path.is_empty() {
Cow::from("/")
} else {
path.into()
}
}
None => url.into(),
};
path
}
fn notify_callbacks(&self) {
let callables = {
let mut callbacks_ref = self.callbacks.borrow_mut();
// Any gone weak references are removed when called.
let (callbacks, callbacks_weak) = callbacks_ref.iter().cloned().fold(
(Vec::new(), Vec::new()),
|(mut callbacks, mut callbacks_weak), m| {
if let Some(m_strong) = m.clone().upgrade() {
callbacks.push(m_strong);
callbacks_weak.push(m);
}
(callbacks, callbacks_weak)
},
);
*callbacks_ref = callbacks_weak;
callbacks
};
for callback in callables {
callback.emit(())
}
}
}
/// A trait to to provide [`Location`] information.
pub trait Location: Clone + PartialEq {
type History: History<Location = Self> + 'static;
/// Returns the `pathname` on the [`Location`] struct.
fn pathname(&self) -> String;
/// Returns the queries of current URL in [`String`]
fn search(&self) -> String;
/// Returns the queries of current URL parsed as `T`.
fn query<T>(&self) -> HistoryResult<T>
where
T: DeserializeOwned;
/// Returns the hash fragment of current URL.
fn hash(&self) -> String;
/// Returns current route or `None` if none matched.
fn route<R>(&self) -> Option<R>
where
R: Routable;
}
/// The [`Location`] type for [`BrowserHistory`].
///
/// Most functionality of this type is provided by [`web_sys::Location`].
///
/// This type also provides additional methods that are unique to Browsers and are not available in [`Location`].
///
/// This types is read-only as most setters on `window.location` would cause a reload.
#[derive(Clone)]
pub struct BrowserLocation {
inner: web_sys::Location,
_history: BrowserHistory,
}
impl PartialEq for BrowserLocation {
fn eq(&self, rhs: &Self) -> bool {
self._history == rhs._history
}
}
impl Location for BrowserLocation {
type History = BrowserHistory;
fn pathname(&self) -> String {
self.inner
.pathname()
.expect_throw("failed to get pathname.")
}
fn search(&self) -> String {
self.inner.search().expect_throw("failed to get search.")
}
fn query<T>(&self) -> HistoryResult<T>
where
T: DeserializeOwned,
{
let query = self.search();
serde_urlencoded::from_str(query.strip_prefix('?').unwrap_or("")).map_err(|e| e.into())
}
fn hash(&self) -> String {
self.inner.hash().expect_throw("failed to get hash.")
}
fn route<R>(&self) -> Option<R>
where
R: Routable,
{
R::recognize(&self.pathname())
}
}
impl BrowserLocation {
fn new(history: BrowserHistory) -> Self {
Self {
inner: window().location(),
_history: history,
}
}
/// Returns the `href` of current [`Location`].
pub fn href(&self) -> String {
self.inner.href().expect_throw("failed to get href.")
}
/// Returns the `origin` of current [`Location`].
pub fn origin(&self) -> String {
self.inner.origin().expect_throw("failed to get origin.")
}
/// Returns the `protocol` property of current [`Location`].
pub fn protocol(&self) -> String {
self.inner
.protocol()
.expect_throw("failed to get protocol.")
}
/// Returns the `host` of current [`Location`].
pub fn host(&self) -> String {
self.inner.host().expect_throw("failed to get host.")
}
/// Returns the `hostname` of current [`Location`].
pub fn hostname(&self) -> String {
self.inner
.hostname()
.expect_throw("failed to get hostname.")
}
}
/// A [`History`] that is always available under a [`Router`](crate::Router).
#[derive(Clone, PartialEq)]
pub enum AnyHistory {
Browser(BrowserHistory),
}
/// The [`Location`] for [`AnyHistory`]
#[derive(Clone, PartialEq)]
pub enum AnyLocation {
Browser(BrowserLocation),
}
impl History for AnyHistory {
type Location = AnyLocation;
fn len(&self) -> usize {
let Self::Browser(self_) = self;
self_.len()
}
fn go(&self, delta: isize) {
let Self::Browser(self_) = self;
self_.go(delta)
}
fn push(&self, route: impl Routable) {
let Self::Browser(self_) = self;
self_.push(route)
}
fn replace(&self, route: impl Routable) {
let Self::Browser(self_) = self;
self_.replace(route)
}
fn push_with_state<T>(&self, route: impl Routable, state: T) -> HistoryResult<()>
where
T: Serialize + 'static,
{
let Self::Browser(self_) = self;
self_.push_with_state(route, state)
}
fn replace_with_state<T>(&self, route: impl Routable, state: T) -> HistoryResult<()>
where
T: Serialize + 'static,
{
let Self::Browser(self_) = self;
self_.replace_with_state(route, state)
}
fn push_with_query<Q>(&self, route: impl Routable, query: Q) -> HistoryResult<()>
where
Q: Serialize,
{
let Self::Browser(self_) = self;
self_.push_with_query(route, query)
}
fn replace_with_query<Q>(&self, route: impl Routable, query: Q) -> HistoryResult<()>
where
Q: Serialize,
{
let Self::Browser(self_) = self;
self_.replace_with_query(route, query)
}
fn push_with_query_and_state<Q, T>(
&self,
route: impl Routable,
query: Q,
state: T,
) -> HistoryResult<()>
where
Q: Serialize,
T: Serialize + 'static,
{
let Self::Browser(self_) = self;
self_.push_with_query_and_state(route, query, state)
}
fn replace_with_query_and_state<Q, T>(
&self,
route: impl Routable,
query: Q,
state: T,
) -> HistoryResult<()>
where
Q: Serialize,
T: Serialize + 'static,
{
let Self::Browser(self_) = self;
self_.replace_with_query_and_state(route, query, state)
}
fn listen<CB>(&self, callback: CB) -> HistoryListener
where
CB: Fn() + 'static,
{
let Self::Browser(self_) = self;
self_.listen(callback)
}
fn location(&self) -> Self::Location {
let Self::Browser(self_) = self;
AnyLocation::Browser(self_.location())
}
fn into_any_history(self) -> AnyHistory {
self
}
fn state<T>(&self) -> HistoryResult<T>
where
T: DeserializeOwned + 'static,
{
let Self::Browser(self_) = self;
self_.state()
}
}
impl Location for AnyLocation {
type History = AnyHistory;
fn pathname(&self) -> String {
let Self::Browser(self_) = self;
self_.pathname()
}
fn search(&self) -> String {
let Self::Browser(self_) = self;
self_.search()
}
fn query<T>(&self) -> HistoryResult<T>
where
T: DeserializeOwned,
{
let Self::Browser(self_) = self;
self_.query()
}
fn hash(&self) -> String {
let Self::Browser(self_) = self;
self_.hash()
}
fn route<R>(&self) -> Option<R>
where
R: Routable,
{
let Self::Browser(self_) = self;
self_.route()
}
}

View File

@ -1,32 +1,37 @@
//! Hooks to access router state and navigate between pages.
use crate::history::*;
use crate::navigator::Navigator;
use crate::routable::Routable;
use crate::router::RouterState;
use crate::router::{LocationContext, NavigatorContext};
use yew::prelude::*;
/// A hook to access the [`AnyHistory`] type.
pub fn use_history() -> Option<AnyHistory> {
let history_state = use_context::<RouterState>()?;
Some(history_state.history())
/// A hook to access the [`Navigator`].
pub fn use_navigator() -> Option<Navigator> {
use_context::<NavigatorContext>().map(|m| m.navigator())
}
/// A hook to access the [`AnyLocation`] type.
pub fn use_location() -> Option<AnyLocation> {
Some(use_history()?.location())
/// A hook to access the current [`Location`].
pub fn use_location() -> Option<Location> {
Some(use_context::<LocationContext>()?.location())
}
/// A hook to access the current route.
///
/// This hook will return [`None`] if there's no available location or none of the routes match.
///
/// # Note
///
/// If your `Routable` has a `#[not_found]` route, you can use `.unwrap_or_default()` instead of
/// `.unwrap()` to unwrap.
pub fn use_route<R>() -> Option<R>
where
R: Routable + 'static,
{
use_location()?.route::<R>()
let navigator = use_navigator()?;
let location = use_location()?;
let path = navigator.strip_basename(location.path().into());
R::recognize(&path)
}

View File

@ -21,9 +21,9 @@
//!
//! #[function_component(Secure)]
//! fn secure() -> Html {
//! let history = use_history().unwrap();
//! let navigator = use_navigator().unwrap();
//!
//! let onclick_callback = Callback::from(move |_| history.push(Route::Home));
//! let onclick_callback = Callback::from(move |_| navigator.push(Route::Home));
//! html! {
//! <div>
//! <h1>{ "Secure" }</h1>
@ -54,13 +54,13 @@
//!
//! # Internals
//!
//! The router registers itself as a context provider and makes session history and location information
//! The router registers itself as a context provider and makes location information and navigator
//! available via [`hooks`] or [`RouterScopeExt`](scope_ext::RouterScopeExt).
//!
//! # State
//!
//! The [`history`] API has a way access / store state associated with session history. Please
//! consule [`history.state()`](history::History::state) for detailed usage.
//! The [`Location`] API has a way access / store state associated with session history. Please
//! consult [`location.state()`](crate::history::Lcation::state) for detailed usage.
extern crate self as yew_router;
@ -68,8 +68,8 @@ extern crate self as yew_router;
#[path = "macro_helpers.rs"]
pub mod __macro;
pub mod components;
pub mod history;
pub mod hooks;
pub mod navigator;
mod routable;
pub mod router;
pub mod scope_ext;
@ -77,21 +77,31 @@ pub mod switch;
pub mod utils;
pub use routable::{AnyRoute, Routable};
pub use router::{BrowserRouter, Router};
pub use router::{BrowserRouter, HashRouter, Router};
pub use switch::{RenderFn, Switch};
pub mod history {
//! A module that provides universal session history and location information.
pub use gloo::history::{
AnyHistory, BrowserHistory, HashHistory, History, HistoryError, HistoryResult, Location,
MemoryHistory,
};
}
pub mod prelude {
//! Prelude module to be imported when working with `yew-router`.
//!
//! This module re-exports the frequently used types from the crate.
pub use crate::components::{Link, Redirect};
pub use crate::history::*;
pub use crate::history::Location;
pub use crate::hooks::*;
pub use crate::scope_ext::RouterScopeExt;
pub use crate::navigator::{NavigationError, NavigationResult, Navigator};
pub use crate::scope_ext::{LocationHandle, NavigatorHandle, RouterScopeExt};
#[doc(no_inline)]
pub use crate::Routable;
pub use crate::{BrowserRouter, Router};
pub use crate::{BrowserRouter, HashRouter, Router};
pub use crate::Switch;
}

View File

@ -0,0 +1,177 @@
use std::borrow::Cow;
use serde::Serialize;
use crate::history::{AnyHistory, History, HistoryError, HistoryResult};
use crate::routable::Routable;
pub type NavigationError = HistoryError;
pub type NavigationResult<T> = HistoryResult<T>;
/// The kind of Navigator Provider.
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum NavigatorKind {
/// Browser History.
Browser,
/// Hash History.
Hash,
/// Memory History.
Memory,
}
/// A struct to navigate between locations.
#[derive(Debug, PartialEq, Clone)]
pub struct Navigator {
inner: AnyHistory,
basename: Option<String>,
}
impl Navigator {
pub(crate) fn new(history: AnyHistory, basename: Option<String>) -> Self {
Self {
inner: history,
basename,
}
}
/// Returns basename of current navigator.
pub fn basename(&self) -> Option<&str> {
self.basename.as_deref()
}
/// Navigate back 1 page.
pub fn back(&self) {
self.go(-1);
}
/// Navigate forward 1 page.
pub fn forward(&self) {
self.go(1);
}
/// Navigate to a specific page with a `delta` relative to current page.
///
/// See: <https://developer.mozilla.org/en-US/docs/Web/API/History/go>
pub fn go(&self, delta: isize) {
self.inner.go(delta);
}
/// Pushes a [`Routable`] entry.
pub fn push(&self, route: impl Routable) {
self.inner.push(self.route_to_url(route));
}
/// Replaces the current history entry with provided [`Routable`] and [`None`] state.
pub fn replace(&self, route: impl Routable) {
self.inner.replace(self.route_to_url(route));
}
/// Pushes a [`Routable`] entry with state.
pub fn push_with_state<T>(&self, route: impl Routable, state: T)
where
T: 'static,
{
self.inner.push_with_state(self.route_to_url(route), state);
}
/// Replaces the current history entry with provided [`Routable`] and state.
pub fn replace_with_state<T>(&self, route: impl Routable, state: T)
where
T: 'static,
{
self.inner
.replace_with_state(self.route_to_url(route), state);
}
/// Same as `.push()` but affix the queries to the end of the route.
pub fn push_with_query<Q>(&self, route: impl Routable, query: Q) -> NavigationResult<()>
where
Q: Serialize,
{
self.inner.push_with_query(self.route_to_url(route), query)
}
/// Same as `.replace()` but affix the queries to the end of the route.
pub fn replace_with_query<Q>(&self, route: impl Routable, query: Q) -> NavigationResult<()>
where
Q: Serialize,
{
self.inner
.replace_with_query(self.route_to_url(route), query)
}
/// Same as `.push_with_state()` but affix the queries to the end of the route.
pub fn push_with_query_and_state<Q, T>(
&self,
route: impl Routable,
query: Q,
state: T,
) -> NavigationResult<()>
where
Q: Serialize,
T: 'static,
{
self.inner
.push_with_query_and_state(self.route_to_url(route), query, state)
}
/// Same as `.replace_with_state()` but affix the queries to the end of the route.
pub fn replace_with_query_and_state<Q, T>(
&self,
route: impl Routable,
query: Q,
state: T,
) -> NavigationResult<()>
where
Q: Serialize,
T: 'static,
{
self.inner
.replace_with_query_and_state(self.route_to_url(route), query, state)
}
/// Returns the Navigator kind.
pub fn kind(&self) -> NavigatorKind {
match &self.inner {
AnyHistory::Browser(_) => NavigatorKind::Browser,
AnyHistory::Hash(_) => NavigatorKind::Hash,
AnyHistory::Memory(_) => NavigatorKind::Memory,
}
}
pub(crate) fn route_to_url(&self, route: impl Routable) -> Cow<'static, str> {
let url = route.to_path();
let path = match self.basename() {
Some(base) => {
let path = format!("{}{}", base, url);
if path.is_empty() {
Cow::from("/")
} else {
path.into()
}
}
None => url.into(),
};
path
}
pub(crate) fn strip_basename<'a>(&self, path: Cow<'a, str>) -> Cow<'a, str> {
match self.basename() {
Some(m) => {
let mut path = path
.strip_prefix(m)
.map(|m| Cow::from(m.to_owned()))
.unwrap_or(path);
if !path.starts_with('/') {
path = format!("/{}", m).into();
}
path
}
None => path,
}
}
}

View File

@ -1,81 +1,103 @@
//! Router Component.
use std::rc::Rc;
use crate::prelude::*;
use crate::history::{AnyHistory, BrowserHistory, HashHistory, History, Location};
use crate::navigator::Navigator;
use crate::utils::{base_url, strip_slash_suffix};
use yew::prelude::*;
use yew::virtual_dom::AttrValue;
/// Props for [`Router`].
#[derive(Properties, PartialEq, Clone)]
pub struct RouterProps {
pub children: Children,
pub history: AnyHistory,
#[prop_or_default]
pub basename: Option<AttrValue>,
}
/// A context for [`Router`]
#[derive(Clone)]
pub(crate) struct RouterState {
history: AnyHistory,
pub(crate) struct LocationContext {
location: Location,
// Counter to force update.
ctr: u32,
}
impl RouterState {
pub fn history(&self) -> AnyHistory {
self.history.clone()
impl LocationContext {
pub fn location(&self) -> Location {
self.location.clone()
}
}
impl PartialEq for RouterState {
impl PartialEq for LocationContext {
fn eq(&self, rhs: &Self) -> bool {
self.ctr == rhs.ctr
}
}
pub(crate) enum RouterStateAction {
Navigate,
ReplaceHistory(AnyHistory),
}
impl Reducible for RouterState {
type Action = RouterStateAction;
impl Reducible for LocationContext {
type Action = Location;
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
let history = match action {
RouterStateAction::Navigate => self.history(),
RouterStateAction::ReplaceHistory(m) => m,
};
Self {
history,
location: action,
ctr: self.ctr + 1,
}
.into()
}
}
#[derive(Clone, PartialEq)]
pub(crate) struct NavigatorContext {
navigator: Navigator,
}
impl NavigatorContext {
pub fn navigator(&self) -> Navigator {
self.navigator.clone()
}
}
/// The Router component.
///
/// This provides [`History`] context to its children and switches.
/// This provides location and navigator context to its children and switches.
///
/// If you are building a web application, you may want to consider using [`BrowserRouter`] instead.
///
/// You only need one `<Router />` for each application.
#[function_component(Router)]
pub fn router(props: &RouterProps) -> Html {
let RouterProps { history, children } = props.clone();
let RouterProps {
history,
children,
basename,
} = props.clone();
let state = use_reducer(|| RouterState {
history: history.clone(),
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 state_dispatcher = state.dispatcher();
let loc_ctx_dispatcher = loc_ctx.dispatcher();
use_effect_with_deps(
move |history| {
state_dispatcher.dispatch(RouterStateAction::ReplaceHistory(history.clone()));
let history = history.clone();
// Force location update when history changes.
loc_ctx_dispatcher.dispatch(history.location());
let listener =
history.listen(move || state_dispatcher.dispatch(RouterStateAction::Navigate));
let history_cb = {
let history = history.clone();
move || loc_ctx_dispatcher.dispatch(history.location())
};
let listener = history.listen(history_cb);
// We hold the listener in the destructor.
move || {
@ -87,28 +109,61 @@ pub fn router(props: &RouterProps) -> Html {
}
html! {
<ContextProvider<RouterState> context={(*state).clone()}>
{children}
</ContextProvider<RouterState>>
<ContextProvider<NavigatorContext> context={navi_ctx}>
<ContextProvider<LocationContext> context={(*loc_ctx).clone()}>
{children}
</ContextProvider<LocationContext>>
</ContextProvider<NavigatorContext>>
}
}
/// Props for [`BrowserRouter`] and [`HashRouter`].
#[derive(Properties, PartialEq, Clone)]
pub struct BrowserRouterProps {
pub struct ConcreteRouterProps {
pub children: Children,
#[prop_or_default]
pub basename: Option<AttrValue>,
}
/// A [`Router`] thats provides history via [`BrowserHistory`].
/// A [`Router`] that provides location information and navigator via [`BrowserHistory`].
///
/// This Router uses browser's native history to manipulate session history
/// and uses regular URL as route.
///
/// # Note
///
/// The router will by default use the value declared in `<base href="..." />` as its basename.
/// You may also specify a different basename with props.
#[function_component(BrowserRouter)]
pub fn browser_router(props: &BrowserRouterProps) -> Html {
let history = use_state(BrowserHistory::new);
let children = props.children.clone();
pub fn browser_router(props: &ConcreteRouterProps) -> Html {
let ConcreteRouterProps { children, basename } = props.clone();
let history = use_state(|| AnyHistory::from(BrowserHistory::new()));
// We acknowledge based in `<base href="..." />`
let basename = basename.map(|m| m.to_string()).or_else(base_url);
html! {
<Router history={(*history).clone().into_any_history()}>
<Router history={(*history).clone()} {basename}>
{children}
</Router>
}
}
/// A [`Router`] that provides location information and navigator via [`HashHistory`].
///
/// This Router uses browser's native history to manipulate session history
/// and stores route in hash fragment.
///
/// # Warning
///
/// Prefer [`BrowserRouter`] whenever possible and use this as a last resort.
#[function_component(HashRouter)]
pub fn hash_router(props: &ConcreteRouterProps) -> Html {
let ConcreteRouterProps { children, basename } = props.clone();
let history = use_state(|| AnyHistory::from(HashHistory::new()));
html! {
<Router history={(*history).clone()} {basename}>
{children}
</Router>
}

View File

@ -1,18 +1,25 @@
use crate::history::*;
use crate::history::Location;
use crate::navigator::Navigator;
use crate::routable::Routable;
use crate::router::RouterState;
use crate::router::{LocationContext, NavigatorContext};
use yew::context::ContextHandle;
use yew::prelude::*;
/// A [`ContextHandle`] for [`add_history_listener`](RouterScopeExt::add_history_listener).
pub struct HistoryHandle {
_inner: ContextHandle<RouterState>,
/// A [`ContextHandle`] for [`add_location_listener`](RouterScopeExt::add_location_listener).
pub struct LocationHandle {
_inner: ContextHandle<LocationContext>,
}
/// An extension to [`Scope`](yew::html::Scope) that provides session history information.
/// A [`ContextHandle`] for [`add_navigator_listener`](RouterScopeExt::add_navigator_listener).
pub struct NavigatorHandle {
_inner: ContextHandle<NavigatorContext>,
}
/// An extension to [`Scope`](yew::html::Scope) that provides location information and navigator
/// access.
///
/// You can access on `ctx.link()`
/// You can access them on `ctx.link()`
///
/// # Example
///
@ -41,8 +48,8 @@ pub struct HistoryHandle {
/// match msg {
/// Msg::OnClick => {
/// ctx.link()
/// .history()
/// .expect_throw("failed to read history")
/// .navigator()
/// .expect_throw("failed to get navigator.")
/// .push(ctx.props().to.clone());
/// false
/// }
@ -65,46 +72,70 @@ pub struct HistoryHandle {
/// }
/// ```
pub trait RouterScopeExt {
/// Returns current [`History`].
fn history(&self) -> Option<AnyHistory>;
/// Returns current [`Navigator`].
fn navigator(&self) -> Option<Navigator>;
/// Returns current [`Location`].
fn location(&self) -> Option<AnyLocation>;
fn location(&self) -> Option<Location>;
/// Returns current route.
fn route<R>(&self) -> Option<R>
where
R: Routable + 'static;
/// Adds a listener that gets notified when history changes.
/// Adds a listener that gets notified when location changes.
///
/// # Note
///
/// [`HistoryHandle`] works like a normal [`ContextHandle`] and it unregisters the callback
/// [`LocationHandle`] works like a normal [`ContextHandle`] and it unregisters the callback
/// when the handle is dropped. You need to keep the handle for as long as you need the
/// callback.
fn add_history_listener(&self, cb: Callback<AnyHistory>) -> Option<HistoryHandle>;
fn add_location_listener(&self, cb: Callback<Location>) -> Option<LocationHandle>;
/// Adds a listener that gets notified when navigator changes.
///
/// # Note
///
/// [`NavigatorHandle`] works like a normal [`ContextHandle`] and it unregisters the callback
/// when the handle is dropped. You need to keep the handle for as long as you need the
/// callback.
fn add_navigator_listener(&self, cb: Callback<Navigator>) -> Option<NavigatorHandle>;
}
impl<COMP: Component> RouterScopeExt for yew::html::Scope<COMP> {
fn history(&self) -> Option<AnyHistory> {
self.context::<RouterState>(Callback::from(|_| {}))
.map(|(m, _)| m.history())
fn navigator(&self) -> Option<Navigator> {
self.context::<NavigatorContext>(Callback::from(|_| {}))
.map(|(m, _)| m.navigator())
}
fn location(&self) -> Option<AnyLocation> {
self.history().map(|m| m.location())
fn location(&self) -> Option<Location> {
self.context::<LocationContext>(Callback::from(|_| {}))
.map(|(m, _)| m.location())
}
fn route<R>(&self) -> Option<R>
where
R: Routable + 'static,
{
self.location()?.route()
let navigator = self.navigator()?;
let location = self.location()?;
let path = navigator.strip_basename(location.path().into());
R::recognize(&path)
}
fn add_history_listener(&self, cb: Callback<AnyHistory>) -> Option<HistoryHandle> {
self.context::<RouterState>(Callback::from(move |m: RouterState| cb.emit(m.history())))
.map(|(_, m)| HistoryHandle { _inner: m })
fn add_location_listener(&self, cb: Callback<Location>) -> Option<LocationHandle> {
self.context::<LocationContext>(Callback::from(move |m: LocationContext| {
cb.emit(m.location())
}))
.map(|(_, m)| LocationHandle { _inner: m })
}
fn add_navigator_listener(&self, cb: Callback<Navigator>) -> Option<NavigatorHandle> {
self.context::<NavigatorContext>(Callback::from(move |m: NavigatorContext| {
cb.emit(m.navigator())
}))
.map(|(_, m)| NavigatorHandle { _inner: m })
}
}

View File

@ -8,7 +8,7 @@ use wasm_bindgen::UnwrapThrowExt;
use yew::prelude::*;
use crate::prelude::*;
use crate::scope_ext::HistoryHandle;
use crate::scope_ext::LocationHandle;
/// Wraps `Rc` around `Fn` so it can be passed as a prop.
pub struct RenderFn<R>(Rc<dyn Fn(&R) -> Html>);
@ -63,7 +63,7 @@ pub enum Msg {
/// stating that no route can be matched.
/// See the [crate level document][crate] for more information.
pub struct Switch<R: Routable + 'static> {
_listener: HistoryHandle,
_listener: LocationHandle,
_phantom: PhantomData<R>,
}
@ -77,7 +77,7 @@ where
fn create(ctx: &Context<Self>) -> Self {
let link = ctx.link();
let listener = link
.add_history_listener(link.callback(move |_| Msg::ReRender))
.add_location_listener(link.callback(move |_| Msg::ReRender))
.expect_throw("failed to create history handle. Do you have a router registered?");
Self {
@ -93,7 +93,7 @@ where
}
fn view(&self, ctx: &Context<Self>) -> Html {
let route = ctx.link().location().and_then(|m| m.route::<R>());
let route = ctx.link().route::<R>();
let children = match &route {
Some(ref route) => (ctx.props().render.0)(route),

View File

@ -22,7 +22,7 @@ pub fn base_url() -> Option<String> {
}
pub fn fetch_base_url() -> Option<String> {
match gloo_utils::document().query_selector("base[href]") {
match gloo::utils::document().query_selector("base[href]") {
Ok(Some(base)) => {
let base = base.unchecked_into::<web_sys::HtmlBaseElement>().href();
@ -43,7 +43,7 @@ pub fn fetch_base_url() -> Option<String> {
#[cfg(test)]
mod tests {
use gloo_utils::document;
use gloo::utils::document;
use wasm_bindgen_test::wasm_bindgen_test as test;
use yew_router::prelude::*;
use yew_router::utils::*;

View File

@ -0,0 +1,131 @@
use serde::{Deserialize, Serialize};
use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure};
use yew::functional::function_component;
use yew::prelude::*;
use yew_router::prelude::*;
mod utils;
use utils::*;
wasm_bindgen_test_configure!(run_in_browser);
#[derive(Serialize, Deserialize)]
struct Query {
foo: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Routable)]
enum Routes {
#[at("/")]
Home,
#[at("/no/:id")]
No { id: u32 },
#[at("/404")]
NotFound,
}
#[derive(Properties, PartialEq, Clone)]
struct NoProps {
id: u32,
}
#[function_component(No)]
fn no(props: &NoProps) -> Html {
let route = props.id.to_string();
let location = use_location().unwrap();
html! {
<>
<div id="result-params">{ route }</div>
<div id="result-query">{ location.query::<Query>().unwrap().foo }</div>
</>
}
}
#[function_component(Comp)]
fn component() -> Html {
let navigator = use_navigator().unwrap();
let switch = Switch::render(move |routes| {
let navigator_clone = navigator.clone();
let replace_route = Callback::from(move |_| {
navigator_clone
.replace_with_query(
Routes::No { id: 2 },
Query {
foo: "bar".to_string(),
},
)
.unwrap();
});
let navigator_clone = navigator.clone();
let push_route = Callback::from(move |_| {
navigator_clone
.push_with_query(
Routes::No { id: 3 },
Query {
foo: "baz".to_string(),
},
)
.unwrap();
});
match routes {
Routes::Home => html! {
<>
<div id="result">{"Home"}</div>
<button onclick={replace_route}>{"replace a route"}</button>
</>
},
Routes::No { id } => html! {
<>
<No id={*id} />
<button onclick={push_route}>{"push a route"}</button>
</>
},
Routes::NotFound => html! { <div id="result">{"404"}</div> },
}
});
html! {
<Switch<Routes> render={switch} />
}
}
#[function_component(Root)]
fn root() -> Html {
html! {
<BrowserRouter basename="/base/">
<Comp />
</BrowserRouter>
}
}
// all the tests are in place because document state isn't being reset between tests
// different routes at the time of execution are set and it causes weird behavior (tests
// failing randomly)
// this test tests
// - routing
// - parameters in the path
// - query parameters
// - 404 redirects
#[test]
fn router_works() {
yew::start_app_in_element::<Root>(gloo::utils::document().get_element_by_id("output").unwrap());
assert_eq!("Home", obtain_result_by_id("result"));
let initial_length = history_length();
click("button"); // replacing the current route
assert_eq!("2", obtain_result_by_id("result-params"));
assert_eq!("bar", obtain_result_by_id("result-query"));
assert_eq!(initial_length, history_length());
click("button"); // pushing a new route
assert_eq!("3", obtain_result_by_id("result-params"));
assert_eq!("baz", obtain_result_by_id("result-query"));
assert_eq!(initial_length + 1, history_length());
}

View File

@ -45,12 +45,12 @@ fn no(props: &NoProps) -> Html {
#[function_component(Comp)]
fn component() -> Html {
let history = use_history().unwrap();
let navigator = use_navigator().unwrap();
let switch = Switch::render(move |routes| {
let history_clone = history.clone();
let navigator_clone = navigator.clone();
let replace_route = Callback::from(move |_| {
history_clone
navigator_clone
.replace_with_query(
Routes::No { id: 2 },
Query {
@ -60,9 +60,9 @@ fn component() -> Html {
.unwrap();
});
let history_clone = history.clone();
let navigator_clone = navigator.clone();
let push_route = Callback::from(move |_| {
history_clone
navigator_clone
.push_with_query(
Routes::No { id: 3 },
Query {
@ -113,7 +113,7 @@ fn root() -> Html {
// - 404 redirects
#[test]
fn router_works() {
yew::start_app_in_element::<Root>(gloo_utils::document().get_element_by_id("output").unwrap());
yew::start_app_in_element::<Root>(gloo::utils::document().get_element_by_id("output").unwrap());
assert_eq!("Home", obtain_result_by_id("result"));

View File

@ -0,0 +1,131 @@
use serde::{Deserialize, Serialize};
use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure};
use yew::functional::function_component;
use yew::prelude::*;
use yew_router::prelude::*;
mod utils;
use utils::*;
wasm_bindgen_test_configure!(run_in_browser);
#[derive(Serialize, Deserialize)]
struct Query {
foo: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Routable)]
enum Routes {
#[at("/")]
Home,
#[at("/no/:id")]
No { id: u32 },
#[at("/404")]
NotFound,
}
#[derive(Properties, PartialEq, Clone)]
struct NoProps {
id: u32,
}
#[function_component(No)]
fn no(props: &NoProps) -> Html {
let route = props.id.to_string();
let location = use_location().unwrap();
html! {
<>
<div id="result-params">{ route }</div>
<div id="result-query">{ location.query::<Query>().unwrap().foo }</div>
</>
}
}
#[function_component(Comp)]
fn component() -> Html {
let navigator = use_navigator().unwrap();
let switch = Switch::render(move |routes| {
let navigator_clone = navigator.clone();
let replace_route = Callback::from(move |_| {
navigator_clone
.replace_with_query(
Routes::No { id: 2 },
Query {
foo: "bar".to_string(),
},
)
.unwrap();
});
let navigator_clone = navigator.clone();
let push_route = Callback::from(move |_| {
navigator_clone
.push_with_query(
Routes::No { id: 3 },
Query {
foo: "baz".to_string(),
},
)
.unwrap();
});
match routes {
Routes::Home => html! {
<>
<div id="result">{"Home"}</div>
<button onclick={replace_route}>{"replace a route"}</button>
</>
},
Routes::No { id } => html! {
<>
<No id={*id} />
<button onclick={push_route}>{"push a route"}</button>
</>
},
Routes::NotFound => html! { <div id="result">{"404"}</div> },
}
});
html! {
<Switch<Routes> render={switch} />
}
}
#[function_component(Root)]
fn root() -> Html {
html! {
<HashRouter>
<Comp />
</HashRouter>
}
}
// all the tests are in place because document state isn't being reset between tests
// different routes at the time of execution are set and it causes weird behavior (tests
// failing randomly)
// this test tests
// - routing
// - parameters in the path
// - query parameters
// - 404 redirects
#[test]
fn router_works() {
yew::start_app_in_element::<Root>(gloo::utils::document().get_element_by_id("output").unwrap());
assert_eq!("Home", obtain_result_by_id("result"));
let initial_length = history_length();
click("button"); // replacing the current route
assert_eq!("2", obtain_result_by_id("result-params"));
assert_eq!("bar", obtain_result_by_id("result-query"));
assert_eq!(initial_length, history_length());
click("button"); // pushing a new route
assert_eq!("3", obtain_result_by_id("result-params"));
assert_eq!("baz", obtain_result_by_id("result-query"));
assert_eq!(initial_length + 1, history_length());
}

View File

@ -1,79 +0,0 @@
use gloo::timers::future::TimeoutFuture;
use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure};
use yew_router::prelude::*;
use yew_router::AnyRoute;
use serde::{Deserialize, Serialize};
wasm_bindgen_test_configure!(run_in_browser);
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Query {
a: String,
b: u64,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct State {
i: String,
ii: u64,
}
#[test]
async fn history_works() {
let history = BrowserHistory::new();
assert_eq!(history.location().pathname(), "/");
history.push(AnyRoute::new("/path-a"));
assert_eq!(history.location().pathname(), "/path-a");
history.replace(AnyRoute::new("/path-b"));
assert_eq!(history.location().pathname(), "/path-b");
history.back();
TimeoutFuture::new(100).await;
assert_eq!(history.location().pathname(), "/");
history.forward();
TimeoutFuture::new(100).await;
assert_eq!(history.location().pathname(), "/path-b");
history
.push_with_query(
AnyRoute::new("/path"),
Query {
a: "something".to_string(),
b: 123,
},
)
.unwrap();
assert_eq!(history.location().pathname(), "/path");
assert_eq!(history.location().search(), "?a=something&b=123");
assert_eq!(
history.location().query::<Query>().unwrap(),
Query {
a: "something".to_string(),
b: 123,
}
);
history
.push_with_state(
AnyRoute::new("/path-c"),
State {
i: "something".to_string(),
ii: 123,
},
)
.unwrap();
assert_eq!(history.location().pathname(), "/path-c");
assert_eq!(
history.state::<State>().unwrap(),
State {
i: "something".to_string(),
ii: 123,
}
);
}

View File

@ -1,14 +1,14 @@
use wasm_bindgen::JsCast;
pub fn obtain_result_by_id(id: &str) -> String {
gloo_utils::document()
gloo::utils::document()
.get_element_by_id(id)
.expect("No result found. Most likely, the application crashed and burned")
.inner_html()
}
pub fn click(selector: &str) {
gloo_utils::document()
gloo::utils::document()
.query_selector(selector)
.unwrap()
.unwrap()
@ -18,7 +18,7 @@ pub fn click(selector: &str) {
}
pub fn history_length() -> u32 {
gloo_utils::window()
gloo::utils::window()
.history()
.expect("No history found")
.length()

View File

@ -44,10 +44,13 @@ current URL and passes it to the `render` callback. The callback then decides wh
matched, the router navigates to the path with `not_found` attribute. If no route is specified, nothing is rendered, and
a message is logged to console stating that no route was matched.
Finally, you need to register the `<Router />` component as a context.
`<Router />` provides session history information to its children.
Finally, you need to register one of the Router context provider components like `<BrowserRouter />`.
It provides location information and navigator to its children.
When using `yew-router` in browser environment, `<BrowserRouter />` is recommended.
:::caution
When using `yew-router` in browser environment, `<BrowserRouter />` is highly recommended.
You can find other router flavours in the [API Reference](https://docs.rs/yew-router/).
:::
```rust
use yew_router::prelude::*;
@ -66,9 +69,9 @@ enum Route {
#[function_component(Secure)]
fn secure() -> Html {
let history = use_history().unwrap();
let navigator = use_navigator().unwrap();
let onclick = Callback::once(move |_| history.push(Route::Home));
let onclick = Callback::once(move |_| navigator.push(Route::Home));
html! {
<div>
<h1>{ "Secure" }</h1>
@ -161,28 +164,10 @@ unmatched.
For more information about the route syntax and how to bind parameters, check
out [route-recognizer](https://docs.rs/route-recognizer/0.3.1/route_recognizer/#routing-params).
### History and Location
### Location
The router provides a universal `History` and `Location` struct which can be used to access routing information. They
can be retrieved by hooks or convenient functions on `ctx.link()`.
They have a couple flavours:
#### `AnyHistory` and `AnyLocation`
These types are available with all routers and should be used whenever possible. They implement a subset
of `window.history` and `window.location`.
You can access them using the following hooks:
- `use_history`
- `use_location`
#### `BrowserHistory` and `BrowserLocation`
These are only available when `<BrowserRouter />` is used. They provide additional functionality that is not available
in `AnyHistory` and
`AnyLocation` (such as: `location.host`).
The router provides a universal `Location` struct via context which can be used to access routing information.
They can be retrieved by hooks or convenient functions on `ctx.link()`.
### Navigation
@ -190,12 +175,12 @@ in `AnyHistory` and
#### Link
A `<Link/>` renders as an `<a>` element, the `onclick` event handler will call
A `<Link />` renders as an `<a>` element, the `onclick` event handler will call
[preventDefault](https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault), and push the targeted page to the
history and render the desired page, which is what should be expected from a Single Page App. The default onclick of a
normal anchor element would reload the page.
The `<Link/>` element also passes its children to the `<a>` element. Consider it a replacement of `<a/>` for in-app
The `<Link />` component also passes its children to the `<a>` element. Consider it a replacement of `<a/>` for in-app
routes. Except you supply a `to` attribute instead of a `href`. An example usage:
```rust ,ignore
@ -208,21 +193,21 @@ Struct variants work as expected too:
<Link<Route> to={Route::Post { id: "new-yew-release".to_string() }}>{ "Yew v0.19 out now!" }</Link<Route>>
```
#### History API
#### Navigator API
History API is provided for both function components and struct components. They can enable callbacks to change the
route. An `AnyHistory` instance can be obtained in either cases to manipulate the route.
Navigator API is provided for both function components and struct components. They enable callbacks to change the
route. An `Navigator` instance can be obtained in either cases to manipulate the route.
##### Function Components
For function components, the `use_history` hook re-renders the component and returns the current route whenever the
history changes. Here's how to implement a button that navigates to the `Home` route when clicked.
For function components, the `use_navigator` hook re-renders the component when the underlying navigator provider changes.
Here's how to implement a button that navigates to the `Home` route when clicked.
```rust ,ignore
#[function_component(MyComponent)]
pub fn my_component() -> Html {
let history = use_history().unwrap();
let onclick = Callback::once(move |_| history.push(Route::Home));
let navigator = use_navigator().unwrap();
let onclick = Callback::once(move |_| navigator.push(Route::Home));
html! {
<>
@ -236,13 +221,13 @@ pub fn my_component() -> Html {
The example here uses `Callback::once`. Use a normal callback if the target route can be the same with the route
the component is in, or just to play safe. For example, when you have a logo button on every page, that goes back to
home when clicked, clicking that button twice on home page causes the code to panic because the second click pushes an
identical Home route and the `use_history` hook won't trigger a re-render.
identical Home route and the `use_navigator` hook won't trigger a re-render.
:::
If you want to replace the current history instead of pushing a new history onto the stack, use `history.replace()`
instead of `history.push()`.
If you want to replace the current location instead of pushing a new location onto the stack, use `navigator.replace()`
instead of `navigator.push()`.
You may notice `history` has to move into the callback, so it can't be used again for other callbacks. Luckily `history`
You may notice `navigator` has to move into the callback, so it can't be used again for other callbacks. Luckily `navigator`
implements `Clone`, here's for example how to have multiple buttons to different routes:
```rust ,ignore
@ -251,26 +236,26 @@ use yew_router::prelude::*;
#[function_component(NavItems)]
pub fn nav_items() -> Html {
let history = use_history().unwrap();
let navigator = use_navigator().unwrap();
let go_home_button = {
let history = history.clone();
let onclick = Callback::once(move |_| history.push(Route::Home));
let navigator = navigator.clone();
let onclick = Callback::once(move |_| navigator.push(Route::Home));
html! {
<button {onclick}>{"click to go home"}</button>
}
};
let go_to_first_post_button = {
let history = history.clone();
let onclick = Callback::once(move |_| history.push(Route::Post { id: "first-post".to_string() }));
let navigator = navigator.clone();
let onclick = Callback::once(move |_| navigator.push(Route::Post { id: "first-post".to_string() }));
html! {
<button {onclick}>{"click to go the first post"}</button>
}
};
let go_to_secure_button = {
let onclick = Callback::once(move |_| history.push(Route::Secure));
let onclick = Callback::once(move |_| navigator.push(Route::Secure));
html! {
<button {onclick}>{"click to go to secure"}</button>
}
@ -286,22 +271,15 @@ pub fn nav_items() -> Html {
}
```
:::tip
This is a hack and a more idiomatic hook version will come in the future!
But if your component only needs to set the route without listening to the changes, instead of the `use_history`
hook, `BrowserHistory::default()` can be used to acquire the global history instance. The latter also works in non-threaded agent
environments (`Context` and `Job`).
:::
##### Struct Components
For struct components, the `AnyHistory` instance can be obtained through the `ctx.link().history()` API. The rest is
For struct components, the `Navigator` instance can be obtained through the `ctx.link().navigator()` API. The rest is
identical with the function component case. Here's an example of a view function that renders a single button.
```rust ,ignore
fn view(&self, ctx: &Context<Self>) -> Html {
let history = ctx.link().history().unwrap();
let onclick = Callback::once(move |_| history.push(MainRoute::Home));
let navigator = ctx.link().navigator().unwrap();
let onclick = Callback::once(move |_| navigator.push(MainRoute::Home));
html!{
<button {onclick}>{"Go Home"}</button>
}
@ -310,10 +288,10 @@ fn view(&self, ctx: &Context<Self>) -> Html {
#### Redirect
`yew-router` also provides a `<Redirect/>` element in the prelude. It can be used to achieve similar effects as the
history API. The element accepts a
`to` attribute as the target route. When a `<Redirect/>` element is rendered, it internally calls `history.push()` and
changes the route. Here is an example:
`yew-router` also provides a `<Redirect />` component in the prelude. It can be used to achieve similar effects as the
navigator API. The component accepts a
`to` attribute as the target route. When a `<Redirect/>` is rendered users will be redirect to the route specified in props.
Here is an example:
```rust ,ignore
#[function_component(SomePage)]
@ -321,9 +299,7 @@ fn some_page() -> Html {
// made-up hook `use_user`
let user = match use_user() {
Some(user) => user,
// an early return that redirects to the login page
// technicality: `Redirect` actually renders an empty html. But since it also pushes history, the target page
// shows up immediately. Consider it a "side-effect" component.
// Redirects to the login page when user is `None`.
None => return html! {
<Redirect<Route> to={Route::Login}/>
},
@ -332,9 +308,9 @@ fn some_page() -> Html {
}
```
:::tip `Redirect` vs `history`, which to use
The history API is the only way to manipulate route in callbacks.
While `<Redirect/>` can be used as return values in a component. You might also want to use `<Redirect/>` in other
:::tip `Redirect` vs `Navigator`, which to use
The Navigator API is the only way to manipulate route in callbacks.
While `<Redirect />` can be used as return values in a component. You might also want to use `<Redirect />` in other
non-component context, for example in the switch function of a [Nested Router](#nested-router).
:::
@ -342,22 +318,22 @@ non-component context, for example in the switch function of a [Nested Router](#
#### Function Components
Alongside the `use_history` hook, there are also `use_location` and `use_route`. Your components will re-render when
You can use `use_location` and `use_route` hooks. Your components will re-render when
provided values change.
#### Struct Components
In order to react on route changes, you can pass a callback closure to the `add_history_listener()` method of `ctx.link()`.
In order to react on route changes, you can pass a callback closure to the `add_location_listener()` method of `ctx.link()`.
:::note
The history listener will get unregistered once it is dropped. Make sure to store the handle inside your
The location listener will get unregistered once it is dropped. Make sure to store the handle inside your
component state.
:::
```rust ,ignore
fn create(ctx: &Context<Self>) -> Self {
let listener = ctx.link()
.add_history_listener(ctx.link().callback(
.add_location_listener(ctx.link().callback(
// handle event
))
.unwrap();
@ -373,8 +349,8 @@ fn create(ctx: &Context<Self>) -> Self {
#### Specifying query parameters when navigating
In order to specify query parameters when navigating to a new route, use either `history.push_with_query` or
the `history.replace_with_query` functions. It uses `serde` to serialize the parameters into query string for the URL so
In order to specify query parameters when navigating to a new route, use either `navigator.push_with_query` or
the `navigator.replace_with_query` functions. It uses `serde` to serialize the parameters into query string for the URL so
any type that implements `Serialize` can be passed. In its simplest form this is just a `HashMap` containing string
pairs.
@ -477,6 +453,22 @@ pub fn app() -> Html {
}
```
### Basename
It's possible to define a basename with `yew-router`.
A basename is a common prefix of all routes. Both the Navigator API and
`<Switch />` component respect basename setting. All pushed routes will be
prefixed with the basename and all switches will strip the basename before
trying to parse the path into a `Routable`.
If a basename prop is not supplied to the Router component, it will use
the href attribute of the `<base />` element in your html file and
fallback to `/` if no `<base />` presents in the html file.
## Relevant examples
- [Router](https://github.com/yewstack/yew/tree/master/examples/router)
## API Reference
- [yew-router](https://docs.rs/yew-router/)