mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-27 09:54:41 -05:00
402 lines
11 KiB
Rust
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(())
|
|
})
|
|
}
|