Files
leptos/router/src/location/mod.rs

402 lines
11 KiB
Rust

#![allow(missing_docs)]
use any_spawner::Executor;
use core::fmt::Debug;
use js_sys::Reflect;
use leptos::server::ServerActionError;
use reactive_graph::{
computed::Memo,
owner::provide_context,
signal::{ArcRwSignal, ReadSignal},
traits::With,
};
use send_wrapper::SendWrapper;
use std::{borrow::Cow, future::Future};
use tachys::dom::window;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{HtmlAnchorElement, MouseEvent};
mod history;
mod server;
use crate::params::ParamsMap;
pub use history::*;
pub use server::*;
pub(crate) const BASE: &str = "https://leptos.dev";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Url {
origin: String,
path: String,
search: String,
search_params: ParamsMap,
hash: String,
}
impl Url {
pub fn origin(&self) -> &str {
&self.origin
}
pub fn origin_mut(&mut self) -> &mut String {
&mut self.origin
}
pub fn path(&self) -> &str {
&self.path
}
pub fn path_mut(&mut self) -> &mut str {
&mut self.path
}
pub fn search(&self) -> &str {
&self.search
}
pub fn search_mut(&mut self) -> &mut String {
&mut self.search
}
pub fn search_params(&self) -> &ParamsMap {
&self.search_params
}
pub fn search_params_mut(&mut self) -> &mut ParamsMap {
&mut self.search_params
}
pub fn hash(&self) -> &str {
#[cfg(all(feature = "ssr", any(debug_assertions, leptos_debuginfo)))]
{
#[cfg(feature = "tracing")]
tracing::warn!(
"Reading hash on the server can lead to hydration errors."
);
#[cfg(not(feature = "tracing"))]
eprintln!(
"Reading hash on the server can lead to hydration errors."
);
}
&self.hash
}
pub fn hash_mut(&mut self) -> &mut String {
#[cfg(all(feature = "ssr", any(debug_assertions, leptos_debuginfo)))]
{
#[cfg(feature = "tracing")]
tracing::warn!(
"Reading hash on the server can lead to hydration errors."
);
#[cfg(not(feature = "tracing"))]
eprintln!(
"Reading hash on the server can lead to hydration errors."
);
}
&mut self.hash
}
pub fn provide_server_action_error(&self) {
let search_params = self.search_params();
if let (Some(err), Some(path)) = (
search_params.get_str("__err"),
search_params.get_str("__path"),
) {
provide_context(ServerActionError::new(path, err))
}
}
pub(crate) fn to_full_path(&self) -> String {
let mut path = self.path.to_string();
if !self.search.is_empty() {
path.push('?');
path.push_str(&self.search);
}
if !self.hash.is_empty() {
if !self.hash.starts_with('#') {
path.push('#');
}
path.push_str(&self.hash);
}
path
}
pub fn escape(s: &str) -> String {
#[cfg(not(feature = "ssr"))]
{
js_sys::encode_uri_component(s).as_string().unwrap()
}
#[cfg(feature = "ssr")]
{
percent_encoding::utf8_percent_encode(
s,
percent_encoding::NON_ALPHANUMERIC,
)
.to_string()
}
}
pub fn unescape(s: &str) -> String {
#[cfg(feature = "ssr")]
{
percent_encoding::percent_decode_str(s)
.decode_utf8()
.unwrap()
.to_string()
}
#[cfg(not(feature = "ssr"))]
{
match js_sys::decode_uri_component(s) {
Ok(v) => v.into(),
Err(_) => s.into(),
}
}
}
pub fn unescape_minimal(s: &str) -> String {
#[cfg(not(feature = "ssr"))]
{
match js_sys::decode_uri(s) {
Ok(v) => v.into(),
Err(_) => s.into(),
}
}
#[cfg(feature = "ssr")]
{
Self::unescape(s)
}
}
}
/// A reactive description of the current URL, containing equivalents to the local parts of
/// the browser's [`Location`](https://developer.mozilla.org/en-US/docs/Web/API/Location).
#[derive(Debug, Clone, PartialEq)]
pub struct Location {
/// The path of the URL, not containing the query string or hash fragment.
pub pathname: Memo<String>,
/// The raw query string.
pub search: Memo<String>,
/// The query string parsed into its key-value pairs.
pub query: Memo<ParamsMap>,
/// The hash fragment.
pub hash: Memo<String>,
/// The [`state`](https://developer.mozilla.org/en-US/docs/Web/API/History/state) at the top of the history stack.
pub state: ReadSignal<State>,
}
impl Location {
pub(crate) fn new(
url: impl Into<ReadSignal<Url>>,
state: impl Into<ReadSignal<State>>,
) -> Self {
let url = url.into();
let state = state.into();
let pathname = Memo::new(move |_| url.with(|url| url.path.clone()));
let search = Memo::new(move |_| url.with(|url| url.search.clone()));
let hash = Memo::new(move |_| url.with(|url| url.hash().to_string()));
let query =
Memo::new(move |_| url.with(|url| url.search_params.clone()));
Location {
pathname,
search,
query,
hash,
state,
}
}
}
/// A description of a navigation.
#[derive(Debug, Clone, PartialEq)]
pub struct LocationChange {
/// The new URL.
pub value: String,
/// If true, the new location will replace the current one in the history stack, i.e.,
/// clicking the "back" button will not return to the current location.
pub replace: bool,
/// If true, the router will scroll to the top of the page at the end of the navigation.
pub scroll: bool,
/// The [`state`](https://developer.mozilla.org/en-US/docs/Web/API/History/state) that will be added during navigation.
pub state: State,
}
impl Default for LocationChange {
fn default() -> Self {
Self {
value: Default::default(),
replace: true,
scroll: true,
state: Default::default(),
}
}
}
pub trait LocationProvider: Clone + 'static {
type Error: Debug;
fn new() -> Result<Self, Self::Error>;
fn as_url(&self) -> &ArcRwSignal<Url>;
fn current() -> Result<Url, Self::Error>;
/// Sets up any global event listeners or other initialization needed.
fn init(&self, base: Option<Cow<'static, str>>);
/// Should be called after a navigation when all route components and data have been loaded and
/// the URL can be updated.
fn ready_to_complete(&self);
/// Update the browser's history to reflect a new location.
fn complete_navigation(&self, loc: &LocationChange);
fn parse(url: &str) -> Result<Url, Self::Error> {
Self::parse_with_base(url, BASE)
}
fn parse_with_base(url: &str, base: &str) -> Result<Url, Self::Error>;
fn redirect(loc: &str);
/// Whether we are currently in a "back" navigation.
fn is_back(&self) -> ReadSignal<bool>;
}
#[derive(Debug, Clone, Default)]
pub struct State(Option<SendWrapper<JsValue>>);
impl State {
pub fn new(state: Option<JsValue>) -> Self {
Self(state.map(SendWrapper::new))
}
pub fn to_js_value(&self) -> JsValue {
match &self.0 {
Some(v) => v.clone().take(),
None => JsValue::UNDEFINED,
}
}
}
impl PartialEq for State {
fn eq(&self, other: &Self) -> bool {
self.0.as_ref().map(|n| n.as_ref())
== other.0.as_ref().map(|n| n.as_ref())
}
}
impl<T> From<T> for State
where
T: Into<JsValue>,
{
fn from(value: T) -> Self {
State::new(Some(value.into()))
}
}
pub(crate) fn handle_anchor_click<NavFn, NavFut>(
router_base: Option<Cow<'static, str>>,
parse_with_base: fn(&str, &str) -> Result<Url, JsValue>,
navigate: NavFn,
) -> Box<dyn Fn(MouseEvent) -> Result<(), JsValue>>
where
NavFn: Fn(Url, LocationChange) -> NavFut + 'static,
NavFut: Future<Output = ()> + 'static,
{
let router_base = router_base.unwrap_or_default();
Box::new(move |ev: MouseEvent| {
let origin = window().location().origin()?;
if ev.default_prevented()
|| ev.button() != 0
|| ev.meta_key()
|| ev.alt_key()
|| ev.ctrl_key()
|| ev.shift_key()
{
return Ok(());
}
let composed_path = ev.composed_path();
let mut a: Option<HtmlAnchorElement> = None;
for i in 0..composed_path.length() {
if let Ok(el) = composed_path.get(i).dyn_into::<HtmlAnchorElement>()
{
a = Some(el);
}
}
if let Some(a) = a {
let href = a.href();
let target = a.target();
// let browser handle this event if link has target,
// or if it doesn't have href or state
// TODO "state" is set as a prop, not an attribute
if !target.is_empty()
|| (href.is_empty() && !a.has_attribute("state"))
{
return Ok(());
}
let rel = a.get_attribute("rel").unwrap_or_default();
let mut rel = rel.split([' ', '\t']);
// let browser handle event if it has rel=external or download
if a.has_attribute("download") || rel.any(|p| p == "external") {
return Ok(());
}
let url = parse_with_base(href.as_str(), &origin).unwrap();
let path_name = Url::unescape_minimal(&url.path);
// let browser handle this event if it leaves our domain
// or our base path
if url.origin != origin
|| (!router_base.is_empty()
&& !path_name.is_empty()
// NOTE: the two `to_lowercase()` calls here added a total of about 14kb to
// release binary size, for limited gain
&& !path_name.starts_with(&*router_base))
{
return Ok(());
}
// we've passed all the checks to navigate on the client side, so we prevent the
// default behavior of the click
ev.prevent_default();
let to = path_name
+ if url.search.is_empty() { "" } else { "?" }
+ &url.search
+ &url.hash;
let state = Reflect::get(&a, &JsValue::from_str("state"))
.ok()
.and_then(|value| {
if value == JsValue::UNDEFINED {
None
} else {
Some(value)
}
});
let replace = Reflect::get(&a, &JsValue::from_str("replace"))
.ok()
.and_then(|value| value.as_bool())
.unwrap_or(false);
let change = LocationChange {
value: to,
replace,
scroll: !a.has_attribute("noscroll")
&& !a.has_attribute("data-noscroll"),
state: State::new(state),
};
Executor::spawn_local(navigate(url, change));
}
Ok(())
})
}