This commit is contained in:
Greg Johnston 2024-03-06 09:08:09 -05:00
parent c3b9932172
commit 9f02cc8cc1
26 changed files with 630 additions and 469 deletions

View File

@ -33,6 +33,8 @@ members = [
# libraries
"meta",
"router",
"routing",
"is_server",
]
exclude = ["benchmarks", "examples"]

View File

@ -10,12 +10,16 @@ lto = true
[dependencies]
console_log = "1"
log = "0.4"
leptos = { path = "../../leptos", features = ["csr"] }
leptos_router = { path = "../../router", features = ["csr"] }
leptos = { path = "../../leptos", features = ["csr", "tracing"] }
routing = { path = "../../routing", features = ["tracing"] }
#leptos_router = { path = "../../router", features = ["csr"] }
serde = { version = "1", features = ["derive"] }
futures = "0.3"
console_error_panic_hook = "0.1.7"
leptos_meta = { path = "../../meta", features = ["csr"] }
tracing-subscriber = "0.3.18"
tracing-subscriber-wasm = "0.1.0"
tracing = "0.1.40"
#leptos_meta = { path = "../../meta", features = ["csr"] }
[dev-dependencies]
wasm-bindgen-test = "0.3.0"

View File

@ -2,7 +2,7 @@ use futures::{
channel::oneshot::{self, Canceled},
Future,
};
use leptos::set_timeout;
use leptos::leptos_dom::helpers::set_timeout;
use serde::{Deserialize, Serialize};
use std::time::Duration;

View File

@ -1,20 +1,69 @@
mod api;
use crate::api::*;
use leptos::{logging::log, *};
use leptos_router::*;
use leptos::{
component,
prelude::*,
reactive_graph::{
owner::{provide_context, use_context, Owner},
signal::ArcRwSignal,
},
view, IntoView,
};
use log::{debug, info};
use routing::{
location::{BrowserUrl, Location},
NestedRoute, Router, Routes, StaticSegment,
};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct ExampleContext(i32);
#[component]
pub fn RouterExample() -> impl IntoView {
log::debug!("rendering <RouterExample/>");
info!("rendering <RouterExample/>");
// contexts are passed down through the route tree
provide_context(ExampleContext(0));
let router = Router::new(
BrowserUrl::new().unwrap(),
Routes::new((
NestedRoute {
segments: StaticSegment("settings"),
children: (),
data: (),
view: Settings,
},
NestedRoute {
segments: StaticSegment("about"),
children: (),
data: (),
view: About,
},
)),
|| "This page could not be found.",
);
view! {
<Router>
<nav>
// ordinary <a> elements can be used for client-side navigation
// using <A> has two effects:
// 1) ensuring that relative routing works properly for nested routes
// 2) setting the `aria-current` attribute on the current link,
// for a11y and styling purposes
/*
<A exact=true href="/">"Contacts"</A>
<A href="about">"About"</A>
<A href="settings">"Settings"</A>
<A href="redirect-home">"Redirect to Home"</A>
*/
<a href="/">"Contacts"</a>
<a href="about">"About"</a>
<a href="settings">"Settings"</a>
<a href="redirect-home">"Redirect to Home"</a>
</nav>
{router}
/*<Router>
<nav>
// ordinary <a> elements can be used for client-side navigation
// using <A> has two effects:
@ -48,10 +97,11 @@ pub fn RouterExample() -> impl IntoView {
/>
</AnimatedRoutes>
</main>
</Router>
</Router>*/
}
}
/*
// You can define other routes in their own component.
// Use a #[component(transparent)] that returns a <Route/>.
#[component(transparent)]
@ -71,8 +121,8 @@ pub fn ContactRoutes() -> impl IntoView {
/>
</Route>
}
}
}*/
/*
#[component]
pub fn ContactList() -> impl IntoView {
log::debug!("rendering <ContactList/>");
@ -81,7 +131,7 @@ pub fn ContactList() -> impl IntoView {
provide_context(ExampleContext(42));
on_cleanup(|| {
log!("cleaning up <ContactList/>");
info!("cleaning up <ContactList/>");
});
let location = use_location();
@ -113,8 +163,8 @@ pub fn ContactList() -> impl IntoView {
/>
</div>
}
}
}*/
/*
#[derive(Params, PartialEq, Clone, Debug)]
pub struct ContactParams {
// Params isn't implemented for usize, only Option<usize>
@ -123,15 +173,15 @@ pub struct ContactParams {
#[component]
pub fn Contact() -> impl IntoView {
log!("rendering <Contact/>");
info!("rendering <Contact/>");
log!(
info!(
"ExampleContext should be Some(42). It is {:?}",
use_context::<ExampleContext>()
);
on_cleanup(|| {
log!("cleaning up <Contact/>");
info!("cleaning up <Contact/>");
});
let params = use_params::<ContactParams>();
@ -150,7 +200,7 @@ pub fn Contact() -> impl IntoView {
);
create_effect(move |_| {
log!("params = {:#?}", params.get());
info!("params = {:#?}", params.get());
});
let contact_display = move || match contact.get() {
@ -180,45 +230,44 @@ pub fn Contact() -> impl IntoView {
</Transition>
</div>
}
}
}*/
#[component]
pub fn About() -> impl IntoView {
log!("rendering <About/>");
info!("rendering <About/>");
on_cleanup(|| {
log!("cleaning up <About/>");
Owner::on_cleanup(|| {
info!("cleaning up <About/>");
});
log!(
info!(
"ExampleContext should be Some(0). It is {:?}",
use_context::<ExampleContext>()
);
// use_navigate allows you to navigate programmatically by calling a function
let navigate = use_navigate();
// TODO
// let navigate = use_navigate();
view! {
<>
// note: this is just an illustration of how to use `use_navigate`
// <button on:click> to navigate is an *anti-pattern*
// you should ordinarily use a link instead,
// both semantically and so your link will work before WASM loads
<button on:click=move |_| navigate("/", Default::default())>
"Home"
</button>
<h1>"About"</h1>
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."</p>
</>
// note: this is just an illustration of how to use `use_navigate`
// <button on:click> to navigate is an *anti-pattern*
// you should ordinarily use a link instead,
// both semantically and so your link will work before WASM loads
/*<button on:click=move |_| navigate("/", Default::default())>
"Home"
</button>*/
<h1>"About"</h1>
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."</p>
}
}
#[component]
pub fn Settings() -> impl IntoView {
log!("rendering <Settings/>");
info!("rendering <Settings/>");
on_cleanup(|| {
log!("cleaning up <Settings/>");
Owner::on_cleanup(|| {
info!("cleaning up <Settings/>");
});
view! {

View File

@ -1,8 +1,20 @@
use leptos::*;
use router::*;
use tracing_subscriber::fmt;
use tracing_subscriber_wasm::MakeConsoleWriter;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
fmt()
.with_writer(
// To avoide trace events in the browser from showing their
// JS backtrace, which is very annoying, in my opinion
MakeConsoleWriter::default()
.map_trace_level_to(tracing::Level::DEBUG),
)
// For some reason, if we don't do this in the browser, we get
// a runtime error.
.without_time()
.init();
console_error_panic_hook::set_once();
mount_to_body(|| view! { <RouterExample/> })
}

8
is_server/Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[package]
name = "is_server"
edition = "2021"
version.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]

14
is_server/src/lib.rs Normal file
View File

@ -0,0 +1,14 @@
pub fn add(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

View File

@ -72,7 +72,9 @@ serde = ["leptos_reactive/serde"]
serde-lite = ["leptos_reactive/serde-lite"]
miniserde = ["leptos_reactive/miniserde"]
rkyv = ["leptos_reactive/rkyv", "server_fn/rkyv"]
tracing = ["leptos_macro/tracing", "leptos_dom/tracing"]
tracing = [
"reactive_graph/tracing",
] #, "leptos_macro/tracing", "leptos_dom/tracing"]
nonce = ["leptos_dom/nonce"]
spin = ["leptos_reactive/spin", "leptos-spin-macro"]
experimental-islands = [

View File

@ -14,15 +14,14 @@ pub struct View<T>(T)
where
T: Sized;
pub trait IntoView:
Sized + Render<Dom> + RenderHtml<Dom> + AddAnyAttr<Dom>
pub trait IntoView: Sized + Render<Dom> + RenderHtml<Dom> //+ AddAnyAttr<Dom>
{
fn into_view(self) -> View<Self>;
}
impl<T> IntoView for T
where
T: Sized + Render<Dom> + RenderHtml<Dom> + AddAnyAttr<Dom>,
T: Sized + Render<Dom> + RenderHtml<Dom>, //+ AddAnyAttr<Dom>,
{
fn into_view(self) -> View<Self> {
View(self)
@ -79,7 +78,7 @@ impl<T: RenderHtml<Dom>> RenderHtml<Dom> for View<T> {
}
}
impl<T: AddAnyAttr<Dom>> AddAnyAttr<Dom> for View<T> {
/*impl<T: AddAnyAttr<Dom>> AddAnyAttr<Dom> for View<T> {
type Output<SomeNewAttr: Attribute<Dom>> =
<T as AddAnyAttr<Dom>>::Output<SomeNewAttr>;
@ -102,4 +101,4 @@ impl<T: AddAnyAttr<Dom>> AddAnyAttr<Dom> for View<T> {
{
self.0.add_any_attr_by_ref(attr)
}
}
}*/

View File

@ -1,10 +1,7 @@
use crate::children::{ChildrenFn, ViewFn};
use leptos_macro::component;
use reactive_graph::{computed::ArcMemo, traits::Get};
use tachys::{
renderer::dom::Dom,
view::{either::Either, RenderHtml},
};
use tachys::{either::Either, renderer::dom::Dom, view::RenderHtml};
#[component]
pub fn Show<W>(

View File

@ -212,13 +212,12 @@ pub fn request_idle_callback_with_handle(
cb: impl Fn() + 'static,
) -> Result<IdleCallbackHandle, JsValue> {
#[cfg(feature = "tracing")]
{
let span = ::tracing::Span::current();
let cb = move || {
let _guard = span.enter();
cb();
};
}
let span = ::tracing::Span::current();
#[cfg(feature = "tracing")]
let cb = move || {
let _guard = span.enter();
cb();
};
#[inline(never)]
fn ric(cb: Box<dyn Fn()>) -> Result<IdleCallbackHandle, JsValue> {

View File

@ -26,10 +26,13 @@ impl ReactiveNode for RwLock<EffectInner> {
fn mark_subscribers_check(&self) {}
fn update_if_necessary(&self) -> bool {
let mut lock = self.write().or_poisoned();
for source in lock.sources.take() {
let sources = {
let guard = self.read().or_poisoned();
guard.sources.clone()
};
for source in sources {
if source.update_if_necessary() {
lock.observer.notify();
return true;
}
}

View File

@ -1,7 +1,9 @@
use crate::{
channel::channel,
effect::inner::EffectInner,
graph::{AnySubscriber, SourceSet, Subscriber, ToAnySubscriber},
graph::{
AnySubscriber, ReactiveNode, SourceSet, Subscriber, ToAnySubscriber,
},
owner::Owner,
};
use any_spawner::Executor;
@ -62,14 +64,16 @@ where
async move {
while rx.next().await.is_some() {
subscriber.clear_sources(&subscriber);
if subscriber.update_if_necessary() {
subscriber.clear_sources(&subscriber);
let old_value =
mem::take(&mut *value.write().or_poisoned());
let new_value = owner.with_cleanup(|| {
subscriber.with_observer(|| fun(old_value))
});
*value.write().or_poisoned() = Some(new_value);
let old_value =
mem::take(&mut *value.write().or_poisoned());
let new_value = owner.with_cleanup(|| {
subscriber.with_observer(|| fun(old_value))
});
*value.write().or_poisoned() = Some(new_value);
}
}
}
});

View File

@ -4,8 +4,40 @@ edition = "2021"
version.workspace = true
[dependencies]
any_spawner = { workspace = true }
either_of = { workspace = true }
reactive_graph = { workspace = true }
routing_utils = { workspace = true }
tachys = { workspace = true }
url = "2"
js-sys = { version = "0.3" }
wasm-bindgen = { version = "0.2" }
tracing = { version = "0.1", optional = true }
[dependencies.web-sys]
version = "0.3"
features = [
"Document",
"Window",
"console",
# History/Routing
"History",
"HtmlAnchorElement",
"Location",
"MouseEvent",
"Url",
# Form
"FormData",
"HtmlButtonElement",
"HtmlFormElement",
"HtmlInputElement",
"SubmitEvent",
"Url",
"UrlSearchParams",
# Fetching in Hydrate Mode
"Headers",
"Request",
"RequestInit",
"RequestMode",
"Response",
]

View File

@ -119,8 +119,8 @@ impl RouteList {
// this is used to indicate to the Router that we are generating
// a RouteList for server path generation
thread_local! {
static IS_GENERATING: Cell<bool> = Cell::new(false);
static GENERATED: RefCell<Option<RouteList>> = RefCell::new(None);
static IS_GENERATING: Cell<bool> = const { Cell::new(false) };
static GENERATED: RefCell<Option<RouteList>> = const { RefCell::new(None) };
}
pub fn generate<T, Rndr>(app: impl FnOnce() -> T) -> Option<Self>

View File

@ -1,12 +1,15 @@
//mod reactive;
mod generate_route_list;
pub mod location;
mod method;
mod params;
mod router;
mod ssr_mode;
mod static_route;
//pub use reactive::*;
pub use generate_route_list::*;
pub use method::*;
pub use params::*;
pub use router::*;
pub use routing_utils::*;
pub use ssr_mode::*;

View File

@ -1,282 +0,0 @@
use super::{Location, LocationChange, State, Url, BASE};
use crate::params::Params;
use alloc::{borrow::Cow, boxed::Box, rc::Rc, string::String};
use core::fmt;
use js_sys::{try_iter, Array, JsString, Reflect};
use wasm_bindgen::{closure::Closure, JsCast, JsValue};
use web_sys::{
Document, Event, HtmlAnchorElement, MouseEvent, UrlSearchParams, Window,
};
fn document() -> Document {
window().document().expect(
"router cannot be used in a JS environment without a `document`",
)
}
fn window() -> Window {
web_sys::window()
.expect("router cannot be used in a JS environment without a `window`")
}
#[derive(Clone, Default)]
pub struct BrowserUrl {
navigation_hook: Option<Rc<dyn Fn(Url)>>,
}
impl fmt::Debug for BrowserUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BrowserUrl").finish_non_exhaustive()
}
}
impl BrowserUrl {
pub fn new() -> Self {
Self::default()
}
fn unescape(s: &str) -> String {
js_sys::decode_uri(s).unwrap().into()
}
fn scroll_to_el(loc_scroll: bool) {
if let Ok(hash) = window().location().hash() {
if !hash.is_empty() {
let hash = js_sys::decode_uri(&hash[1..])
.ok()
.and_then(|decoded| decoded.as_string())
.unwrap_or(hash);
let el = document().get_element_by_id(&hash);
if let Some(el) = el {
el.scroll_into_view();
return;
}
}
}
// scroll to top
if loc_scroll {
window().scroll_to_with_x_and_y(0.0, 0.0);
}
}
}
impl Location for BrowserUrl {
type Error = JsValue;
fn current(&self) -> Result<Url, Self::Error> {
let location = window().location();
Ok(Url {
origin: location.origin()?,
path: location.pathname()?,
search: location
.search()?
.strip_prefix('?')
.map(String::from)
.unwrap_or_default(),
search_params: search_params_from_web_url(
&UrlSearchParams::new_with_str(&location.search()?)?,
)?,
hash: location.hash()?,
})
}
fn init(&self) {
let this = self.clone();
let handle_anchor_click = move |ev: Event| {
let ev = ev.unchecked_into::<MouseEvent>();
if ev.default_prevented()
|| ev.button() != 0
|| ev.meta_key()
|| ev.alt_key()
|| ev.ctrl_key()
|| ev.shift_key()
{
return;
}
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;
}
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;
}
let base = window()
.location()
.origin()
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(BASE));
let url = Self::parse_with_base(href.as_str(), &base).unwrap();
let path_name = Self::unescape(&url.path);
// let browser handle this event if it leaves our domain
// or our base path
if url.origin
!= window().location().origin().unwrap_or_default()
// TODO base path for router
/* || (true // TODO base_path //!self.base_path.is_empty()
&& !path_name.is_empty()
&& !path_name
.to_lowercase()
.starts_with(&self.base_path.to_lowercase())) */
{
return;
}
let to = path_name
+ if url.search.is_empty() { "" } else { "?" }
+ &Self::unescape(&url.search)
+ &Self::unescape(&url.hash);
let state = Reflect::get(&a, &JsValue::from_str("state"))
.ok()
.and_then(|value| {
if value == JsValue::UNDEFINED {
None
} else {
Some(value)
}
});
ev.prevent_default();
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: true,
state: State(state),
};
// run any router-specific hook
if let Some(navigate_hook) = &this.navigation_hook {
navigate_hook(url);
}
// complete navigation
this.navigate(&change);
}
};
let closure = Closure::wrap(
Box::new(handle_anchor_click) as Box<dyn FnMut(Event)>
)
.into_js_value();
window()
.add_event_listener_with_callback(
"click",
closure.as_ref().unchecked_ref(),
)
.expect(
"couldn't add `click` listener to `window` to handle `<a>` \
clicks",
);
// handle popstate event (forward/back navigation)
if let Some(navigation_hook) = self.navigation_hook.clone() {
let cb = {
let this = self.clone();
move || match this.current() {
Ok(url) => navigation_hook(url),
Err(e) => {
#[cfg(debug_assertions)]
web_sys::console::error_1(&e);
_ = e;
}
}
};
let closure =
Closure::wrap(Box::new(cb) as Box<dyn Fn()>).into_js_value();
window()
.add_event_listener_with_callback(
"popstate",
closure.as_ref().unchecked_ref(),
)
.expect("couldn't add `popstate` listener to `window`");
}
}
fn set_navigation_hook(&mut self, cb: impl Fn(Url) + 'static) {
self.navigation_hook = Some(Rc::new(cb));
}
fn navigate(&self, loc: &LocationChange) {
let history = window().history().unwrap();
if loc.replace {
history
.replace_state_with_url(
&loc.state.to_js_value(),
"",
Some(&loc.value),
)
.unwrap();
} else {
// push the "forward direction" marker
let state = &loc.state.to_js_value();
history
.push_state_with_url(state, "", Some(&loc.value))
.unwrap();
}
// scroll to el
Self::scroll_to_el(loc.scroll);
}
fn parse_with_base(url: &str, base: &str) -> Result<Url, Self::Error> {
let location = web_sys::Url::new_with_base(url, base)?;
Ok(Url {
origin: location.origin(),
path: location.pathname(),
search: location
.search()
.strip_prefix('?')
.map(String::from)
.unwrap_or_default(),
search_params: search_params_from_web_url(
&location.search_params(),
)?,
hash: location.hash(),
})
}
}
fn search_params_from_web_url(
params: &web_sys::UrlSearchParams,
) -> Result<Params<String>, JsValue> {
let mut search_params = Params::new();
for pair in try_iter(params)?.into_iter().flatten() {
let row = pair?.unchecked_into::<Array>();
search_params.push((
row.get(0).unchecked_into::<JsString>().into(),
row.get(1).unchecked_into::<JsString>().into(),
));
}
Ok(search_params)
}

View File

@ -0,0 +1,189 @@
use super::{handle_anchor_click, Location, LocationChange, State, Url, BASE};
use crate::params::Params;
use core::fmt;
use js_sys::{try_iter, Array, JsString, Reflect};
use reactive_graph::{signal::ArcRwSignal, traits::Set};
use std::{borrow::Cow, boxed::Box, rc::Rc, string::String};
use tachys::dom::{document, window};
use wasm_bindgen::{closure::Closure, JsCast, JsValue};
use web_sys::{Event, HtmlAnchorElement, MouseEvent, UrlSearchParams};
#[derive(Clone)]
pub struct BrowserUrl {
url: ArcRwSignal<Url>,
}
impl fmt::Debug for BrowserUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BrowserUrl").finish_non_exhaustive()
}
}
impl BrowserUrl {
pub fn new() -> Result<Self, JsValue> {
let url = ArcRwSignal::new(Self::current()?);
Ok(Self { url })
}
fn scroll_to_el(loc_scroll: bool) {
if let Ok(hash) = window().location().hash() {
if !hash.is_empty() {
let hash = js_sys::decode_uri(&hash[1..])
.ok()
.and_then(|decoded| decoded.as_string())
.unwrap_or(hash);
let el = document().get_element_by_id(&hash);
if let Some(el) = el {
el.scroll_into_view();
return;
}
}
}
// scroll to top
if loc_scroll {
window().scroll_to_with_x_and_y(0.0, 0.0);
}
}
}
impl Location for BrowserUrl {
type Error = JsValue;
fn as_url(&self) -> &ArcRwSignal<Url> {
&self.url
}
fn current() -> Result<Url, Self::Error> {
let location = window().location();
Ok(Url {
origin: location.origin()?,
path: location.pathname()?,
search: location
.search()?
.strip_prefix('?')
.map(String::from)
.unwrap_or_default(),
search_params: search_params_from_web_url(
&UrlSearchParams::new_with_str(&location.search()?)?,
)?,
hash: location.hash()?,
})
}
fn parse_with_base(url: &str, base: &str) -> Result<Url, Self::Error> {
let location = web_sys::Url::new_with_base(url, base)?;
Ok(Url {
origin: location.origin(),
path: location.pathname(),
search: location
.search()
.strip_prefix('?')
.map(String::from)
.unwrap_or_default(),
search_params: search_params_from_web_url(
&location.search_params(),
)?,
hash: location.hash(),
})
}
fn init(&self, base: Option<Cow<'static, str>>) {
let window = window();
let navigate = {
let url = self.url.clone();
move |new_url, loc| {
web_sys::console::log_1(&JsValue::from_str(
"updating URL signal",
));
url.set(new_url);
async move {
Self::complete_navigation(&loc);
}
}
};
let handle_anchor_click =
handle_anchor_click(base, Self::parse_with_base, navigate);
let closure = Closure::wrap(Box::new(move |ev: Event| {
if let Err(e) = handle_anchor_click(ev) {
#[cfg(feature = "tracing")]
tracing::error!("{e:?}");
#[cfg(not(feature = "tracing"))]
web_sys::console::error_1(&e);
}
}) as Box<dyn FnMut(Event)>)
.into_js_value();
window
.add_event_listener_with_callback(
"click",
closure.as_ref().unchecked_ref(),
)
.expect(
"couldn't add `click` listener to `window` to handle `<a>` \
clicks",
);
// handle popstate event (forward/back navigation)
let cb = {
let url = self.url.clone();
move || match Self::current() {
Ok(new_url) => url.set(new_url),
Err(e) => {
#[cfg(feature = "tracing")]
tracing::error!("{e:?}");
#[cfg(not(feature = "tracing"))]
web_sys::console::error_1(&e);
}
}
};
let closure =
Closure::wrap(Box::new(cb) as Box<dyn Fn()>).into_js_value();
window
.add_event_listener_with_callback(
"popstate",
closure.as_ref().unchecked_ref(),
)
.expect("couldn't add `popstate` listener to `window`");
}
fn complete_navigation(loc: &LocationChange) {
let history = window().history().unwrap();
if loc.replace {
history
.replace_state_with_url(
&loc.state.to_js_value(),
"",
Some(&loc.value),
)
.unwrap();
} else {
// push the "forward direction" marker
let state = &loc.state.to_js_value();
history
.push_state_with_url(state, "", Some(&loc.value))
.unwrap();
}
// scroll to el
Self::scroll_to_el(loc.scroll);
}
}
fn search_params_from_web_url(
params: &web_sys::UrlSearchParams,
) -> Result<Params, JsValue> {
let mut search_params = Params::new();
try_iter(params)?
.into_iter()
.flatten()
.map(|pair| {
pair.and_then(|pair| {
let row = pair.dyn_into::<Array>()?;
Ok((
row.get(0).dyn_into::<JsString>()?.into(),
row.get(1).dyn_into::<JsString>()?.into(),
))
})
})
.collect()
}

View File

@ -1,11 +1,16 @@
use crate::params::Params;
use alloc::string::String;
use any_spawner::Executor;
use core::fmt::Debug;
use wasm_bindgen::JsValue;
use js_sys::Reflect;
use reactive_graph::signal::ArcRwSignal;
use std::{borrow::Cow, future::Future};
use tachys::dom::window;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{Event, HtmlAnchorElement, MouseEvent};
mod browser;
mod history;
mod server;
pub use browser::*;
use crate::Params;
pub use history::*;
pub use server::*;
pub(crate) const BASE: &str = "https://leptos.dev";
@ -15,7 +20,7 @@ pub struct Url {
origin: String,
path: String,
search: String,
search_params: Params<String>,
search_params: Params,
hash: String,
}
@ -32,7 +37,7 @@ impl Url {
&self.search
}
pub fn search_params(&self) -> &Params<String> {
pub fn search_params(&self) -> &Params {
&self.search_params
}
@ -69,15 +74,15 @@ impl Default for LocationChange {
pub trait Location {
type Error: Debug;
fn current(&self) -> Result<Url, 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);
fn init(&self, base: Option<Cow<'static, str>>);
fn set_navigation_hook(&mut self, cb: impl Fn(Url) + 'static);
/// Navigate to a new location.
fn navigate(&self, loc: &LocationChange);
/// Update the browser's history to reflect a new location.
fn complete_navigation(loc: &LocationChange);
fn parse(url: &str) -> Result<Url, Self::Error> {
Self::parse_with_base(url, BASE)
@ -106,3 +111,115 @@ where
State(Some(value.into()))
}
}
pub(crate) fn unescape(s: &str) -> String {
js_sys::decode_uri(s).unwrap().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(Event) -> 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: Event| {
let ev = ev.unchecked_into::<MouseEvent>();
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 base = window()
.location()
.origin()
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(BASE));
let url = parse_with_base(href.as_str(), &base).unwrap();
let path_name = unescape(&url.path);
ev.prevent_default();
// let browser handle this event if it leaves our domain
// or our base path
if url.origin != window().location().origin().unwrap_or_default()
|| (!router_base.is_empty()
&& !path_name.is_empty()
&& !path_name
.to_lowercase()
.starts_with(&router_base.to_lowercase()))
{
return Ok(());
}
let to = path_name
+ if url.search.is_empty() { "" } else { "?" }
+ &unescape(&url.search)
+ &unescape(&url.hash);
let state = Reflect::get(&a, &JsValue::from_str("state"))
.ok()
.and_then(|value| {
if value == JsValue::UNDEFINED {
None
} else {
Some(value)
}
});
ev.prevent_default();
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: true,
state: State(state),
};
Executor::spawn_local(navigate(url, change));
}
Ok(())
})
}

View File

@ -1,37 +1,31 @@
use super::{Location, LocationChange, Url};
use alloc::string::{String, ToString};
use core::fmt::Display;
use super::{Url, BASE};
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestUrl(String);
pub struct RequestUrl(Arc<str>);
impl RequestUrl {
/// Creates a server-side request URL from a path.
pub fn new(path: impl Display) -> Self {
Self(path.to_string())
pub fn new(path: &str) -> Self {
Self(path.into())
}
}
impl Default for RequestUrl {
fn default() -> Self {
Self(String::from("/"))
Self::new("/")
}
}
impl Location for RequestUrl {
type Error = url::ParseError;
fn current(&self) -> Result<Url, Self::Error> {
Self::parse(&self.0)
impl RequestUrl {
fn parse(url: &str) -> Result<Url, url::ParseError> {
Self::parse_with_base(url, BASE)
}
fn init(&self) {}
fn set_navigation_hook(&mut self, _cb: impl FnMut(Url) + 'static) {}
fn navigate(&self, _loc: &LocationChange) {}
fn parse_with_base(url: &str, base: &str) -> Result<Url, Self::Error> {
pub fn parse_with_base(
url: &str,
base: &str,
) -> Result<Url, url::ParseError> {
let base = url::Url::parse(base)?;
let url = url::Url::options().base_url(Some(&base)).parse(url)?;
@ -53,7 +47,6 @@ impl Location for RequestUrl {
#[cfg(test)]
mod tests {
use super::RequestUrl;
use crate::location::Location;
#[test]
pub fn should_parse_url_without_origin() {

14
routing/src/params.rs Normal file
View File

@ -0,0 +1,14 @@
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
pub struct Params(Vec<(String, String)>);
impl Params {
pub fn new() -> Self {
Self::default()
}
}
impl FromIterator<(String, String)> for Params {
fn from_iter<T: IntoIterator<Item = (String, String)>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}

View File

@ -1,9 +1,13 @@
use crate::generate_route_list::RouteList;
use crate::{generate_route_list::RouteList, location::Location};
use core::marker::PhantomData;
use either_of::Either;
use reactive_graph::{
computed::ArcMemo,
effect::RenderEffect,
traits::{Read, Track},
};
use routing_utils::{
location::Location,
matching::{MatchInterface, MatchNestedRoutes, PossibleRouteMatch, Routes},
MatchInterface, MatchNestedRoutes, PossibleRouteMatch, Routes,
};
use std::borrow::Cow;
use tachys::{
@ -61,10 +65,6 @@ where
rndr: PhantomData,
}
}
pub fn set_location(&mut self, new_location: Loc) {
self.location = new_location;
}
}
impl<Rndr, Loc, Children, FallbackFn, Fallback>
@ -114,51 +114,54 @@ impl<Rndr, Loc, FallbackFn, Fallback, Children, View> Render<Rndr>
for Router<Rndr, Loc, Children, FallbackFn>
where
Loc: Location,
FallbackFn: Fn() -> Fallback,
FallbackFn: Fn() -> Fallback + 'static,
Fallback: Render<Rndr>,
for<'a> Children: MatchNestedRoutes<'a>,
for<'a> Children: MatchNestedRoutes<'a> + 'static,
for<'a> <<Children as MatchNestedRoutes<'a>>::Match as MatchInterface<'a>>::View:
ChooseView<Output = View>,
View: Render<Rndr>,
Rndr: Renderer,
View::State: 'static,
Fallback::State: 'static,
Rndr: Renderer + 'static,
{
type State =
EitherState<View::State, <Fallback as Render<Rndr>>::State, Rndr>;
type State = RenderEffect<
EitherState<View::State, <Fallback as Render<Rndr>>::State, Rndr>,
>;
type FallibleState = ();
fn build(self) -> Self::State {
match self
.location
.current()
.as_ref()
.map(|url| self.routes.match_route(url.path()))
{
Ok(Some(matched)) => {
let view = matched.to_view();
let view = view.choose();
Either::Left(view)
self.location.init(self.base);
let url = self.location.as_url().clone();
let path = ArcMemo::new({
let url = url.clone();
move |_| url.read().path().to_string()
});
let search_parans = ArcMemo::new({
let url = url.clone();
move |_| url.read().search_params().clone()
});
RenderEffect::new(move |prev| {
tachys::dom::log(&format!("recalculating route"));
let path = path.read();
let new_view = match self.routes.match_route(&*path) {
Some(matched) => {
let view = matched.to_view();
let view = view.choose();
Either::Left(view)
}
_ => Either::Right((self.fallback)()),
};
if let Some(mut prev) = prev {
new_view.rebuild(&mut prev);
prev
} else {
new_view.build()
}
_ => Either::Right((self.fallback)()),
}
.build()
})
}
fn rebuild(self, state: &mut Self::State) {
let new = match self
.location
.current()
.as_ref()
.map(|url| self.routes.match_route(url.path()))
{
Ok(Some(matched)) => {
let view = matched.to_view();
let view = view.choose();
Either::Left(view)
}
_ => Either::Right((self.fallback)()),
};
new.rebuild(state);
}
fn rebuild(self, state: &mut Self::State) {}
fn try_build(self) -> tachys::error::Result<Self::FallibleState> {
todo!()
@ -176,13 +179,15 @@ impl<Rndr, Loc, FallbackFn, Fallback, Children, View> RenderHtml<Rndr>
for Router<Rndr, Loc, Children, FallbackFn>
where
Loc: Location,
FallbackFn: Fn() -> Fallback,
FallbackFn: Fn() -> Fallback + 'static,
Fallback: RenderHtml<Rndr>,
for<'a> Children: MatchNestedRoutes<'a>,
for<'a> Children: MatchNestedRoutes<'a> + 'static,
for<'a> <<Children as MatchNestedRoutes<'a>>::Match as MatchInterface<'a>>::View:
ChooseView<Output = View>,
View: Render<Rndr>,
Rndr: Renderer,
View::State: 'static,
Fallback::State: 'static,
Rndr: Renderer + 'static,
{
// TODO probably pick a max length here
const MIN_LENGTH: usize = Fallback::MIN_LENGTH;

View File

@ -4,43 +4,5 @@ edition = "2021"
version.workspace = true
[dependencies]
const_str_slice_concat = { workspace = true }
either_of = { workspace = true }
next_tuple = { workspace = true }
reactive_graph = { workspace = true, optional = true }
url = "2"
js-sys = { version = "0.3" }
wasm-bindgen = { version = "0.2" }
tracing = { version = "0.1", optional = true }
[dependencies.web-sys]
version = "0.3"
features = [
"Document",
"Window",
"console",
# History/Routing
"History",
"HtmlAnchorElement",
"Location",
"MouseEvent",
"Url",
# Form
"FormData",
"HtmlButtonElement",
"HtmlFormElement",
"HtmlInputElement",
"SubmitEvent",
"Url",
"UrlSearchParams",
# Fetching in Hydrate Mode
"Headers",
"Request",
"RequestInit",
"RequestMode",
"Response",
]
[features]
tracing = ["dep:tracing"]
reactive_graph = ["dep:reactive_graph"]

View File

@ -9,7 +9,6 @@ pub use path_segment::*;
mod horizontal;
mod nested;
mod vertical;
use crate::PathSegment;
use alloc::borrow::Cow;
pub use horizontal::*;
pub use nested::*;

View File

@ -37,6 +37,7 @@ pub mod ssr;
pub mod svg;
pub mod view;
pub use either_of as either;
#[cfg(feature = "islands")]
pub use wasm_bindgen;
#[cfg(feature = "islands")]

View File

@ -1,12 +1,16 @@
use crate::{
async_views::Suspend,
error::AnyError,
html::{attribute::AttributeValue, property::IntoProperty},
html::{
attribute::{Attribute, AttributeValue},
property::IntoProperty,
},
hydration::Cursor,
renderer::{DomRenderer, Renderer},
ssr::StreamBuilder,
view::{
Mountable, Position, PositionState, Render, RenderHtml, ToTemplate,
add_attr::AddAnyAttr, Mountable, Position, PositionState, Render,
RenderHtml, ToTemplate,
},
};
use reactive_graph::{
@ -267,6 +271,37 @@ where
}
}
impl<F, V, R> AddAnyAttr<R> for F
where
F: FnMut() -> V + 'static,
V: RenderHtml<R>,
V::State: 'static,
V::FallibleState: 'static,
R: Renderer + 'static,
{
type Output<SomeNewAttr: Attribute<R>> = Self;
fn add_any_attr<NewAttr: Attribute<R>>(
self,
attr: NewAttr,
) -> Self::Output<NewAttr>
where
Self::Output<NewAttr>: RenderHtml<R>,
{
self
}
fn add_any_attr_by_ref<NewAttr: Attribute<R>>(
self,
attr: &NewAttr,
) -> Self::Output<NewAttr>
where
Self::Output<NewAttr>: RenderHtml<R>,
{
self
}
}
impl<M, R> Mountable<R> for RenderEffect<M>
where
M: Mountable<R> + 'static,