Compare commits

..

12 Commits

Author SHA1 Message Date
Greg Johnston
2483616d0d Remove route data loaders for now. 2022-11-05 09:32:12 -04:00
Greg Johnston
2595ffe10e Fix doctests 2022-11-05 09:22:02 -04:00
Greg Johnston
221cdf2685 Doc updates, cleanups 2022-11-05 09:12:42 -04:00
Greg Johnston
bd652ec542 Adding docs 2022-11-04 16:50:03 -04:00
Greg Johnston
d8852f909e Remove action 2022-11-03 21:31:32 -04:00
Greg Johnston
e16cc4fc4a Remove actions 2022-11-03 21:11:59 -04:00
Greg Johnston
d5e3661bcf Remove actions (moved to leptos_server) 2022-11-03 21:07:05 -04:00
Greg Johnston
8873ddc40a Require docs 2022-11-03 21:06:46 -04:00
Greg Johnston
b7e2e983f0 Update main docs 2022-11-03 21:06:23 -04:00
Greg Johnston
3701f65693 Add missing leptos_server metadata 2022-11-03 20:05:44 -04:00
Greg Johnston
a5712d3e17 0.0.12 2022-11-03 20:00:26 -04:00
Greg Johnston
4fba035f19 Merge pull request #47 from gbj/allow-on-dash-syntax-in-macro
Allow on-, class-, prop-, and attr- as equivalent to on:, class:, pro…
2022-11-03 19:57:56 -04:00
25 changed files with 308 additions and 467 deletions

View File

@@ -5,6 +5,7 @@ pub fn simple_counter(cx: Scope) -> web_sys::Element {
view! { cx,
<div>
<MyComponent><p>"Here's the child"</p></MyComponent>
<button on:click=move |_| set_value(0)>"Clear"</button>
<button on:click=move |_| set_value.update(|value| *value -= 1)>"-1"</button>
<span>"Value: " {move || value().to_string()} "!"</span>
@@ -12,3 +13,14 @@ pub fn simple_counter(cx: Scope) -> web_sys::Element {
</div>
}
}
#[component]
fn MyComponent(cx: Scope, children: Option<Box<dyn Fn() -> Vec<Element>>>) -> Element {
view! {
cx,
<my-component>
<p>"Here's the child you passed in: "</p>
<slot></slot>
</my-component>
}
}

View File

@@ -31,46 +31,26 @@ async fn render_app(req: HttpRequest) -> impl Responder {
view! { cx, <App/> }
};
let accepts_type = req.headers().get("Accept").map(|h| h.to_str());
match accepts_type {
// if asks for JSON, send the loader function JSON or 404
Some(Ok("application/json")) => {
let json = loader_to_json(app).await;
let head = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<script type="module">import init, { main } from '/pkg/hackernews_client.js'; init().then(main);</script>"#;
let tail = "</body></html>";
let res = if let Some(json) = json {
HttpResponse::Ok()
.content_type("application/json")
.body(json)
} else {
HttpResponse::NotFound().body(())
};
res
}
// otherwise, send HTML
_ => {
let head = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<script type="module">import init, { main } from '/pkg/hackernews_client.js'; init().then(main);</script>"#;
let tail = "</body></html>";
HttpResponse::Ok().content_type("text/html").streaming(
futures::stream::once(async { head.to_string() })
.chain(render_to_stream(move |cx| {
let app = app(cx);
let head = use_context::<MetaContext>(cx)
.map(|meta| meta.dehydrate())
.unwrap_or_default();
format!("{head}</head><body>{app}")
}))
.chain(futures::stream::once(async { tail.to_string() }))
.map(|html| Ok(web::Bytes::from(html)) as Result<web::Bytes>),
)
}
}
HttpResponse::Ok().content_type("text/html").streaming(
futures::stream::once(async { head.to_string() })
.chain(render_to_stream(move |cx| {
let app = app(cx);
let head = use_context::<MetaContext>(cx)
.map(|meta| meta.dehydrate())
.unwrap_or_default();
format!("{head}</head><body>{app}")
}))
.chain(futures::stream::once(async { tail.to_string() }))
.map(|html| Ok(web::Bytes::from(html)) as Result<web::Bytes>),
)
}
#[actix_web::main]

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos"
version = "0.0.11"
version = "0.0.12"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
@@ -9,11 +9,11 @@ description = "Leptos is a full-stack, isomorphic Rust web framework leveraging
readme = "../README.md"
[dependencies]
leptos_core = { path = "../leptos_core", default-features = false, version = "0.0.11" }
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.11" }
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
leptos_server = { path = "../leptos_server", default-features = false, version = "0.0.11" }
leptos_core = { path = "../leptos_core", default-features = false, version = "0.0.12" }
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.12" }
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.12" }
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
leptos_server = { path = "../leptos_server", default-features = false, version = "0.0.12" }
[features]
default = ["csr", "serde"]

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_core"
version = "0.0.11"
version = "0.0.12"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
@@ -8,9 +8,9 @@ repository = "https://github.com/gbj/leptos"
description = "Core functionality for the Leptos web framework."
[dependencies]
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.11" }
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.12" }
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.12" }
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
log = "0.4"
[features]

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_dom"
version = "0.0.11"
version = "0.0.12"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
@@ -12,7 +12,7 @@ cfg-if = "1"
futures = "0.3"
html-escape = "0.2"
js-sys = "0.3"
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
serde_json = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4.31"

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_macro"
version = "0.0.11"
version = "0.0.12"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
@@ -17,8 +17,8 @@ quote = "1"
syn = { version = "1", features = ["full", "parsing", "extra-traits"] }
syn-rsx = "0.8.1"
uuid = { version = "1", features = ["v4"] }
leptos_dom = { path = "../leptos_dom", version = "0.0.11" }
leptos_reactive = { path = "../leptos_reactive", version = "0.0.11" }
leptos_dom = { path = "../leptos_dom", version = "0.0.12" }
leptos_reactive = { path = "../leptos_reactive", version = "0.0.12" }
[dev-dependencies]
log = "0.4"

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_reactive"
version = "0.0.11"
version = "0.0.12"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"

View File

@@ -1,11 +1,15 @@
[package]
name = "leptos_server"
version = "0.0.11"
version = "0.0.12"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/gbj/leptos"
description = "RPC for the Leptos web framework."
[dependencies]
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.12" }
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
form_urlencoded = "1"
gloo-net = "0.2"
lazy_static = "1"

View File

@@ -6,23 +6,39 @@ use wasm_bindgen::JsCast;
use crate::{use_navigate, use_resolved_path, ToHref};
/// Properties that can be passed to the [Form] component, which is an HTML
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
/// progressively enhanced to use client-side routing.
#[derive(TypedBuilder)]
pub struct FormProps<A>
where
A: ToHref + 'static,
{
/// [`method`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-method)
/// is the HTTP method to submit the form with (`get` or `post`).
#[builder(default, setter(strip_option))]
method: Option<&'static str>,
action: A,
pub method: Option<&'static str>,
/// [`action`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-action)
/// is the URL that processes the form submission. Takes a [String], [&str], or a reactive
/// function that returns a [String].
pub action: A,
/// [`enctype`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-enctype)
/// is the MIME type of the form submission if `method` is `post`.
#[builder(default, setter(strip_option))]
enctype: Option<String>,
children: Box<dyn Fn() -> Vec<Element>>,
pub enctype: Option<String>,
/// A signal that will be incremented whenever the form is submitted with `post`. This can useful
/// for reactively updating a [Resource] or another signal whenever the form has been submitted.
#[builder(default, setter(strip_option))]
version: Option<RwSignal<usize>>,
pub version: Option<RwSignal<usize>>,
/// A signal that will be set if the form submission ends in an error.
#[builder(default, setter(strip_option))]
error: Option<RwSignal<Option<Box<dyn Error>>>>,
pub error: Option<RwSignal<Option<Box<dyn Error>>>>,
/// Component children; should include the HTML of the form elements.
pub children: Box<dyn Fn() -> Vec<Element>>,
}
/// An HTML [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) progressively
/// enhanced to use client-side routing.
#[allow(non_snake_case)]
pub fn Form<A>(cx: Scope, props: FormProps<A>) -> Element
where
@@ -159,6 +175,9 @@ where
if let Some(version) = action_version {
version.update(|n| *n += 1);
}
if let Some(error) = error {
error.set(None);
}
if resp.status() == 303 {
if let Some(redirect_url) = resp.headers().get("Location") {
@@ -190,16 +209,27 @@ where
}
}
/// Properties that can be passed to the [ActionForm] component, which
/// automatically turns a server [Action](leptos_server::Action) into an HTML
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
/// progressively enhanced to use client-side routing.
#[derive(TypedBuilder)]
pub struct ActionFormProps<I, O>
where
I: 'static,
O: 'static,
{
action: Action<I, O>,
children: Box<dyn Fn() -> Vec<Element>>,
/// The action from which to build the form. This should include a URL, which can be generated
/// by default using [create_server_action](leptos_server::create_server_action) or added
/// manually using [leptos_server::Action::using_server_fn].
pub action: Action<I, O>,
/// Component children; should include the HTML of the form elements.
pub children: Box<dyn Fn() -> Vec<Element>>,
}
/// Automatically turns a server [Action](leptos_server::Action) into an HTML
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
/// progressively enhanced to use client-side routing.
#[allow(non_snake_case)]
pub fn ActionForm<I, O>(cx: Scope, props: ActionFormProps<I, O>) -> Element
where

View File

@@ -3,12 +3,16 @@ use leptos::leptos_dom::IntoChild;
use leptos::*;
use typed_builder::TypedBuilder;
#[cfg(not(feature = "ssr"))]
#[cfg(any(feature = "csr", feature = "hydrate"))]
use wasm_bindgen::JsCast;
use crate::{use_location, use_resolved_path, State};
/// Describes a value that is either a static or a reactive URL, i.e.,
/// a [String], a [&str], or a reactive `Fn() -> String`.
pub trait ToHref {
/// Converts the (static or reactive) URL into a function that can be called to
/// return the URL.
fn to_href(&self) -> Box<dyn Fn() -> String + '_>;
}
@@ -35,6 +39,9 @@ where
}
}
/// Properties that can be passed to the [A] component, which is an HTML
/// [`a`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
/// progressively enhanced to use client-side routing.
#[derive(TypedBuilder)]
pub struct AProps<C, H>
where
@@ -43,21 +50,24 @@ where
{
/// Used to calculate the link's `href` attribute. Will be resolved relative
/// to the current route.
href: H,
pub href: H,
/// If `true`, the link is marked active when the location matches exactly;
/// if false, link is marked active if the current route starts with it.
#[builder(default)]
exact: bool,
pub exact: bool,
/// An object of any type that will be pushed to router state
#[builder(default, setter(strip_option))]
state: Option<State>,
pub state: Option<State>,
/// If `true`, the link will not add to the browser's history (so, pressing `Back`
/// will skip this page.)
#[builder(default)]
replace: bool,
children: Box<dyn Fn() -> Vec<C>>,
pub replace: bool,
/// The nodes or elements to be shown inside the link.
pub children: Box<dyn Fn() -> Vec<C>>,
}
/// An HTML [`a`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
/// progressively enhanced to use client-side routing.
#[allow(non_snake_case)]
pub fn A<C, H>(cx: Scope, props: AProps<C, H>) -> Element
where

View File

@@ -1,6 +1,8 @@
use crate::use_route;
use leptos::*;
/// Displays the child route nested in a parent route, allowing you to control exactly where
/// that child route is displayed. Renders nothing if there is no nested child.
#[component]
pub fn Outlet(cx: Scope) -> Child {
let route = use_route(cx);

View File

@@ -5,27 +5,33 @@ use typed_builder::TypedBuilder;
use crate::{
matching::{resolve_path, PathMatch, RouteDefinition, RouteMatch},
Action, Loader, ParamsMap, RouterContext,
ParamsMap, RouterContext,
};
pub struct ChildlessRoute {}
/// Properties that can be passed to a [Route] component, which describes
/// a portion of the nested layout of the app, specifying the route it should match,
/// the element it should display, and data that should be loaded alongside the route.
#[derive(TypedBuilder)]
pub struct RouteProps<E, F>
where
E: IntoChild,
F: Fn(Scope) -> E + 'static,
{
path: &'static str,
element: F,
/// The path fragment that this route should match. This can be static (`users`),
/// include a parameter (`:id`) or an optional parameter (`:id?`), or match a
/// wildcard (`user/*any`).
pub path: &'static str,
/// The view that should be shown when this route is matched. This can be any function
/// that takes a [Scope] and returns an [Element] (like `|cx| view! { cx, <p>"Show this"</p> })`
/// or `|cx| view! { cx, <MyComponent/>` } or even, for a component with no props, `MyComponent`).
pub element: F,
/// `children` may be empty or include nested routes.
#[builder(default, setter(strip_option))]
loader: Option<Loader>,
#[builder(default, setter(strip_option))]
action: Option<Action>,
#[builder(default, setter(strip_option))]
children: Option<Box<dyn Fn() -> Vec<RouteDefinition>>>,
pub children: Option<Box<dyn Fn() -> Vec<RouteDefinition>>>,
}
/// Describes a portion of the nested layout of the app, specifying the route it should match,
/// the element it should display, and data that should be loaded alongside the route.
#[allow(non_snake_case)]
pub fn Route<E, F>(_cx: Scope, props: RouteProps<E, F>) -> RouteDefinition
where
@@ -34,13 +40,12 @@ where
{
RouteDefinition {
path: props.path,
loader: props.loader,
action: props.action,
children: props.children.map(|c| c()).unwrap_or_default(),
element: Rc::new(move |cx| (props.element)(cx).into_child(cx)),
}
}
/// Context type that contains information about the current, matched route.
#[derive(Debug, Clone, PartialEq)]
pub struct RouteContext {
inner: Rc<RouteContextInner>,
@@ -57,12 +62,7 @@ impl RouteContext {
let base = base.path();
let RouteMatch { path_match, route } = matcher()?;
let PathMatch { path, .. } = path_match;
let RouteDefinition {
element,
loader,
action,
..
} = route.key;
let RouteDefinition { element, .. } = route.key;
let params = create_memo(cx, move |_| {
matcher()
.map(|matched| matched.path_match.params)
@@ -74,8 +74,6 @@ impl RouteContext {
cx,
base_path: base.to_string(),
child: Box::new(child),
loader,
action,
path,
original_path: route.original_path.to_string(),
params,
@@ -84,30 +82,27 @@ impl RouteContext {
})
}
/// Returns the reactive scope of the current route.
pub fn cx(&self) -> Scope {
self.inner.cx
}
/// Returns the URL path of the current route.
pub fn path(&self) -> &str {
&self.inner.path
}
/// A reactive wrapper for the route parameters that are currently matched.
pub fn params(&self) -> Memo<ParamsMap> {
self.inner.params
}
pub fn loader(&self) -> &Option<Loader> {
&self.inner.loader
}
pub fn base(cx: Scope, path: &str, fallback: Option<fn() -> Element>) -> Self {
pub(crate) fn base(cx: Scope, path: &str, fallback: Option<fn() -> Element>) -> Self {
Self {
inner: Rc::new(RouteContextInner {
cx,
base_path: path.to_string(),
child: Box::new(|| None),
loader: None,
action: None,
path: path.to_string(),
original_path: path.to_string(),
params: create_memo(cx, |_| ParamsMap::new()),
@@ -116,14 +111,17 @@ impl RouteContext {
}
}
/// Resolves a relative route, relative to the current route's path.
pub fn resolve_path<'a>(&'a self, to: &'a str) -> Option<Cow<'a, str>> {
resolve_path(&self.inner.base_path, to, Some(&self.inner.path))
}
/// The nested child route, if any.
pub fn child(&self) -> Option<RouteContext> {
(self.inner.child)()
}
/// The view associated with the current route.
pub fn outlet(&self) -> impl IntoChild {
(self.inner.outlet)()
}
@@ -133,8 +131,6 @@ pub(crate) struct RouteContextInner {
cx: Scope,
base_path: String,
pub(crate) child: Box<dyn Fn() -> Option<RouteContext>>,
pub(crate) loader: Option<Loader>,
pub(crate) action: Option<Action>,
pub(crate) path: String,
pub(crate) original_path: String,
pub(crate) params: Memo<ParamsMap>,
@@ -160,31 +156,3 @@ impl std::fmt::Debug for RouteContextInner {
.finish()
}
}
pub trait IntoChildRoutes {
fn into_child_routes(self) -> Vec<RouteDefinition>;
}
impl IntoChildRoutes for () {
fn into_child_routes(self) -> Vec<RouteDefinition> {
vec![]
}
}
impl IntoChildRoutes for RouteDefinition {
fn into_child_routes(self) -> Vec<RouteDefinition> {
vec![self]
}
}
impl IntoChildRoutes for Option<RouteDefinition> {
fn into_child_routes(self) -> Vec<RouteDefinition> {
self.map(|c| vec![c]).unwrap_or_default()
}
}
impl IntoChildRoutes for Vec<RouteDefinition> {
fn into_child_routes(self) -> Vec<RouteDefinition> {
self
}
}

View File

@@ -20,15 +20,23 @@ use crate::{
#[cfg(not(feature = "ssr"))]
use crate::{unescape, Url};
/// Props for the [Router] component, which sets up client-side and server-side routing.
#[derive(TypedBuilder)]
pub struct RouterProps {
/// The base URL for the router. Defaults to "".
#[builder(default, setter(strip_option))]
base: Option<&'static str>,
pub base: Option<&'static str>,
#[builder(default, setter(strip_option))]
fallback: Option<fn() -> Element>,
children: Box<dyn Fn() -> Vec<Element>>,
/// A fallback that should be shown if no route is matched.
pub fallback: Option<fn() -> Element>,
/// The `<Router/>` should usually wrap your whole page. It can contain
/// any elements, and should include a [Routes](crate::Routes) component somewhere
/// to define and display [Route](crate::Route)s.
pub children: Box<dyn Fn() -> Vec<Element>>,
}
/// Provides for client-side and server-side routing. This should usually be somewhere near
/// the root of the application.
#[allow(non_snake_case)]
pub fn Router(cx: Scope, props: RouterProps) -> impl IntoChild {
// create a new RouterContext and provide it to every component beneath the router
@@ -38,6 +46,7 @@ pub fn Router(cx: Scope, props: RouterProps) -> impl IntoChild {
props.children
}
/// Context type that contains information about the current router state.
#[derive(Debug, Clone)]
pub struct RouterContext {
pub(crate) inner: Rc<RouterContextInner>,
@@ -72,7 +81,11 @@ impl std::fmt::Debug for RouterContextInner {
}
impl RouterContext {
pub fn new(cx: Scope, base: Option<&'static str>, fallback: Option<fn() -> Element>) -> Self {
pub(crate) fn new(
cx: Scope,
base: Option<&'static str>,
fallback: Option<fn() -> Element>,
) -> Self {
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
let history = use_context::<RouterIntegrationContext>(cx)
@@ -157,10 +170,12 @@ impl RouterContext {
Self { inner }
}
/// The current [`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname).
pub fn pathname(&self) -> Memo<String> {
self.inner.location.pathname
}
/// The [RouteContext] of the base route.
pub fn base(&self) -> RouteContext {
self.inner.base.clone()
}
@@ -190,7 +205,7 @@ impl RouterContextInner {
return Err(NavigationError::MaxRedirects);
}
if resolved_to != (this.reference)() || options.state != (this.state).get() {
if resolved_to != this.reference.get() || options.state != (this.state).get() {
if cfg!(feature = "server") {
// TODO server out
self.history.navigate(&LocationChange {
@@ -336,18 +351,30 @@ impl RouterContextInner {
}
}
/// An error that occurs during navigation.
#[derive(Debug, Error)]
pub enum NavigationError {
/// The given path is not routable.
#[error("Path {0:?} is not routable")]
NotRoutable(String),
/// Too many redirects occurred during routing (prevents and infinite loop.)
#[error("Too many redirects")]
MaxRedirects,
}
/// Options that can be used to configure a navigation. Used with [use_navigate](crate::use_navigate).
#[derive(Clone, Debug)]
pub struct NavigateOptions {
/// Whether the URL being navigated to should be resolved relative to the current route.
pub resolve: bool,
/// If `true` the new location will replace the current route in the history stack, meaning
/// the "back" button will skip over the current route. (Defaults to `false`).
pub replace: bool,
/// If `true`, the router will scroll to the top of the window at the end of navigation.
/// Defaults to `true.
pub scroll: bool,
/// [State](https://developer.mozilla.org/en-US/docs/Web/API/History/state) that should be pushed
/// onto the history stack during navigation.
pub state: State,
}

View File

@@ -5,6 +5,7 @@ use typed_builder::TypedBuilder;
use crate::{matching::{expand_optionals, join_paths, Branch, Matcher, RouteDefinition, get_route_matches, RouteMatch}, RouterContext, RouteContext};
/// Props for the [Routes] component, which contains route definitions and manages routing.
#[derive(TypedBuilder)]
pub struct RoutesProps {
#[builder(default, setter(strip_option))]
@@ -12,6 +13,9 @@ pub struct RoutesProps {
children: Box<dyn Fn() -> Vec<RouteDefinition>>,
}
/// Contains route definitions and manages the actual routing process.
///
/// You should locate the `<Routes/>` component wherever on the page you want the routes to appear.
#[allow(non_snake_case)]
pub fn Routes(cx: Scope, props: RoutesProps) -> impl IntoChild {
let router = use_context::<RouterContext>(cx).unwrap_or_else(|| {

View File

@@ -1,32 +0,0 @@
use std::{future::Future, rc::Rc};
use crate::{PinnedFuture, Request, Response};
#[derive(Clone)]
pub struct Action {
f: Rc<dyn Fn(&Request) -> PinnedFuture<Response>>,
}
impl Action {
pub async fn send(&self, req: &Request) -> Response {
(self.f)(req).await
}
}
impl<F, Fu> From<F> for Action
where
F: Fn(&Request) -> Fu + 'static,
Fu: Future<Output = Response> + 'static,
{
fn from(f: F) -> Self {
Self {
f: Rc::new(move |req| Box::pin(f(req))),
}
}
}
impl std::fmt::Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Action").finish()
}
}

View File

@@ -1,207 +0,0 @@
use std::{any::Any, fmt::Debug, future::Future, rc::Rc};
use leptos::*;
use crate::{use_location, use_params_map, use_route, ParamsMap, PinnedFuture, Url};
// SSR and CSR both do the work in their own environment and return it as a resource
#[cfg(not(feature = "hydrate"))]
pub fn use_loader<T>(cx: Scope) -> Resource<(ParamsMap, Url), T>
where
T: Clone + Debug + Serializable + 'static,
{
let route = use_route(cx);
let params = use_params_map(cx);
let loader = route.loader().clone().unwrap_or_else(|| {
debug_warn!(
"use_loader() called on a route without a loader: {:?}",
route.path()
);
panic!()
});
let location = use_location(cx);
let route = use_route(cx);
let url = move || Url {
origin: String::default(), // don't care what the origin is for this purpose
pathname: route.path().into(), // only use this route path, not all matched routes
search: location.search.get(), // reload when any of query string changes
hash: String::default(), // hash is only client-side, shouldn't refire
};
let loader = loader.data.clone();
create_resource(
cx,
move || (params.get(), url()),
move |(params, url)| {
let loader = loader.clone();
async move {
let any_data = (loader.clone())(cx, params, url).await;
any_data
.as_any()
.downcast_ref::<T>()
.cloned()
.unwrap_or_else(|| {
panic!(
"use_loader() could not downcast to {:?}",
std::any::type_name::<T>(),
)
})
}
},
)
}
// In hydration mode, only run the loader on the server
#[cfg(feature = "hydrate")]
pub fn use_loader<T>(cx: Scope) -> Resource<(ParamsMap, Url), T>
where
T: Clone + Debug + Serializable + 'static,
{
use wasm_bindgen::{JsCast, UnwrapThrowExt};
use crate::use_query_map;
let route = use_route(cx);
let params = use_params_map(cx);
let location = use_location(cx);
let route = use_route(cx);
let url = move || Url {
origin: String::default(), // don't care what the origin is for this purpose
pathname: route.path().into(), // only use this route path, not all matched routes
search: location.search.get(), // reload when any of query string changes
hash: String::default(), // hash is only client-side, shouldn't refire
};
create_resource(
cx,
move || (params.get(), url()),
move |(params, url)| async move {
let route = use_route(cx);
let query = use_query_map(cx);
let mut opts = web_sys::RequestInit::new();
opts.method("GET");
let url = format!("{}{}", route.path(), query.get().to_query_string());
let request = web_sys::Request::new_with_str_and_init(&url, &opts).unwrap_throw();
request
.headers()
.set("Accept", "application/json")
.unwrap_throw();
let window = web_sys::window().unwrap_throw();
let resp_value =
wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request))
.await
.unwrap_throw();
let resp = resp_value.unchecked_into::<web_sys::Response>();
let text = wasm_bindgen_futures::JsFuture::from(resp.text().unwrap_throw())
.await
.unwrap_throw()
.as_string()
.unwrap_throw();
T::from_json(&text).expect_throw(
"couldn't deserialize loader data from serde-lite intermediate format",
)
},
)
}
pub trait AnySerialize {
fn serialize(&self) -> Option<String>;
fn as_any(&self) -> &dyn Any;
}
impl<T> AnySerialize for T
where
T: Any + Serializable + 'static,
{
fn serialize(&self) -> Option<String> {
self.to_json().ok()
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[derive(Clone)]
pub struct Loader {
#[allow(clippy::type_complexity)]
#[cfg(not(feature = "hydrate"))]
pub(crate) data: Rc<dyn Fn(Scope, ParamsMap, Url) -> PinnedFuture<Box<dyn AnySerialize>>>,
}
impl Loader {
#[cfg(not(feature = "hydrate"))]
pub fn call_loader(&self, cx: Scope) -> PinnedFuture<Box<dyn AnySerialize>> {
let route = use_route(cx);
let params = use_params_map(cx).get();
let location = use_location(cx);
let url = Url {
origin: String::default(), // don't care what the origin is for this purpose
pathname: route.path().into(), // only use this route path, not all matched routes
search: location.search.get(), // reload when any of query string changes
hash: String::default(), // hash is only client-side, shouldn't refire
};
(self.data)(cx, params, url)
}
}
impl<F, Fu, T> From<F> for Loader
where
F: Fn(Scope, ParamsMap, Url) -> Fu + 'static,
Fu: Future<Output = T> + 'static,
T: Any + Serializable + 'static,
{
#[cfg(not(feature = "hydrate"))]
fn from(f: F) -> Self {
let wrapped_fn = move |cx, params, url| {
let res = f(cx, params, url);
Box::pin(async move { Box::new(res.await) as Box<dyn AnySerialize> })
as PinnedFuture<Box<dyn AnySerialize>>
};
Self {
data: Rc::new(wrapped_fn),
}
}
#[cfg(feature = "hydrate")]
fn from(f: F) -> Self {
Self {}
}
}
impl std::fmt::Debug for Loader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Loader").finish()
}
}
#[cfg(all(feature = "ssr", not(feature = "hydrate")))]
pub async fn loader_to_json(view: impl Fn(Scope) -> String + 'static) -> Option<String> {
let (data, _, disposer) = run_scope_undisposed(move |cx| async move {
let _shell = view(cx);
let mut route = use_context::<crate::RouteContext>(cx)?;
// get the innermost route matched by this path
while let Some(child) = route.child() {
route = child;
}
let data = route
.loader()
.as_ref()
.map(|loader| loader.call_loader(cx))?;
data.await.serialize()
});
let data = data.await;
disposer.dispose();
data
}

View File

@@ -1,9 +0,0 @@
mod action;
mod loader;
use std::{future::Future, pin::Pin};
pub use action::*;
pub use loader::*;
pub(crate) type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;

View File

@@ -1,27 +0,0 @@
use std::rc::Rc;
use thiserror::Error;
#[derive(Error, Debug, Clone)]
pub enum RouterError {
#[error("loader found no data at this path")]
NoMatch(String),
#[error("route was matched, but loader returned None")]
NotFound(String),
#[error("could not find parameter {0}")]
MissingParam(String),
#[error("failed to deserialize parameters")]
Params(Rc<dyn std::error::Error + Send + Sync>),
}
impl PartialEq for RouterError {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::NoMatch(l0), Self::NoMatch(r0)) => l0 == r0,
(Self::NotFound(l0), Self::NotFound(r0)) => l0 == r0,
(Self::MissingParam(l0), Self::MissingParam(r0)) => l0 == r0,
(Self::Params(_), Self::Params(_)) => false,
_ => false,
}
}
}

View File

@@ -4,6 +4,7 @@ use crate::{State, Url};
use super::params::ParamsMap;
/// Creates a reactive location from the given path and state.
pub fn create_location(cx: Scope, path: ReadSignal<String>, state: ReadSignal<State>) -> Location {
let url = create_memo(cx, move |prev: Option<&Url>| {
path.with(|path| match Url::try_from(path.as_str()) {
@@ -29,20 +30,33 @@ pub fn create_location(cx: Scope, path: ReadSignal<String>, state: ReadSignal<St
}
}
/// 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 {
pub query: Memo<ParamsMap>,
/// 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>,
}
/// 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,
}

View File

@@ -18,12 +18,19 @@ impl std::fmt::Debug for RouterIntegrationContext {
}
}
/// The [Router](crate::Router) relies on a [RouterIntegrationContext], which tells the router
/// how to find things like the current URL, and how to navigate to a new page. The [History] trait
/// can be implemented on any type to provide this information.
pub trait History {
/// A signal that updates whenever the current location changes.
fn location(&self, cx: Scope) -> ReadSignal<LocationChange>;
/// Called to navigate to a new location.
fn navigate(&self, loc: &LocationChange);
}
/// The default integration when you are running in the browser, which uses
/// the [`History API`](https://developer.mozilla.org/en-US/docs/Web/API/History).
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct BrowserIntegration {}
@@ -66,7 +73,7 @@ impl History for BrowserIntegration {
) {
log::error!("{e:#?}");
}
set_location(Self::current());
set_location.set(Self::current());
} else {
log::warn!("RouterContext not found");
}
@@ -105,10 +112,23 @@ impl History for BrowserIntegration {
}
}
/// The wrapper type that the [Router](crate::Router) uses to interact with a [History].
/// This is automatically provided in the browser. For the server, it should be provided
/// as a context.
///
/// ```
/// # use leptos_router::*;
/// # use leptos::*;
/// # run_scope(|cx| {
/// let integration = ServerIntegration { path: "insert/current/path/here".to_string() };
/// provide_context(cx, RouterIntegrationContext::new(integration));
/// # });
/// ```
#[derive(Clone)]
pub struct RouterIntegrationContext(pub Rc<dyn History>);
impl RouterIntegrationContext {
/// Creates a new router integration.
pub fn new(history: impl History + 'static) -> Self {
Self(Rc::new(history))
}
@@ -124,6 +144,7 @@ impl History for RouterIntegrationContext {
}
}
/// A generic router integration for the server side. All its need is the current path.
#[derive(Clone, Debug)]
pub struct ServerIntegration {
pub path: String,

View File

@@ -1,9 +1,8 @@
use std::{rc::Rc, str::FromStr};
use linear_map::LinearMap;
use std::{rc::Rc, str::FromStr};
use thiserror::Error;
use crate::RouterError;
/// A key-value map of the current named route params and their values.
// For now, implemented with a `LinearMap`, as `n` is small enough
// that O(n) iteration over a vectorized map is (*probably*) more space-
// and time-efficient than hashing and using an actual `HashMap`
@@ -11,27 +10,33 @@ use crate::RouterError;
pub struct ParamsMap(pub LinearMap<String, String>);
impl ParamsMap {
/// Creates an empty map.
pub fn new() -> Self {
Self(LinearMap::new())
}
/// Creates an empty map with the given capacity.
pub fn with_capacity(capacity: usize) -> Self {
Self(LinearMap::with_capacity(capacity))
}
/// Inserts a value into the map.
pub fn insert(&mut self, key: String, value: String) -> Option<String> {
self.0.insert(key, value)
}
/// Gets a value from the map.
pub fn get(&self, key: &str) -> Option<&String> {
self.0.get(key)
}
/// Removes a value from the map.
pub fn remove(&mut self, key: &str) -> Option<String> {
self.0.remove(key)
}
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
/// Converts the map to a query string.
pub fn to_query_string(&self) -> String {
use crate::history::url::escape;
let mut buf = String::from("?");
@@ -51,6 +56,16 @@ impl Default for ParamsMap {
}
}
/// A declarative way of creating a [ParamsMap].
///
/// ```
/// # use leptos_router::params_map;
/// let map = params_map! {
/// "id".to_string() => "1".to_string()
/// };
/// assert_eq!(map.get("id"), Some(&"1".to_string()));
/// assert_eq!(map.get("missing"), None)
/// ```
// Adapted from hash_map! in common_macros crate
// Copyright (c) 2019 Philipp Korber
// https://github.com/rustonaut/common_macros/blob/master/src/lib.rs
@@ -68,15 +83,19 @@ macro_rules! params_map {
});
}
/// A simple method of deserializing key-value data (like route params or URL search)
/// into a concrete data type. `Self` should typically be a struct in which
/// each field's type implements [FromStr].
pub trait Params
where
Self: Sized,
{
fn from_map(map: &ParamsMap) -> Result<Self, RouterError>;
/// Attempts to deserialize the map into the given type.
fn from_map(map: &ParamsMap) -> Result<Self, ParamsError>;
}
impl Params for () {
fn from_map(_map: &ParamsMap) -> Result<Self, RouterError> {
fn from_map(_map: &ParamsMap) -> Result<Self, ParamsError> {
Ok(())
}
}
@@ -85,22 +104,22 @@ pub trait IntoParam
where
Self: Sized,
{
fn into_param(value: Option<&str>, name: &str) -> Result<Self, RouterError>;
fn into_param(value: Option<&str>, name: &str) -> Result<Self, ParamsError>;
}
impl<T> IntoParam for Option<T>
where
T: FromStr,
<T as FromStr>::Err: std::error::Error + Send + Sync + 'static,
<T as FromStr>::Err: std::error::Error + 'static,
{
fn into_param(value: Option<&str>, _name: &str) -> Result<Self, RouterError> {
fn into_param(value: Option<&str>, _name: &str) -> Result<Self, ParamsError> {
match value {
None => Ok(None),
Some(value) => match T::from_str(value) {
Ok(value) => Ok(Some(value)),
Err(e) => {
eprintln!("{}", e);
Err(RouterError::Params(Rc::new(e)))
Err(ParamsError::Params(Rc::new(e)))
}
},
}
@@ -115,8 +134,29 @@ where
T: FromStr + NotOption,
<T as FromStr>::Err: std::error::Error + Send + Sync + 'static,
{
fn into_param(value: Option<&str>, name: &str) -> Result<Self, RouterError> {
let value = value.ok_or_else(|| RouterError::MissingParam(name.to_string()))?;
Self::from_str(value).map_err(|e| RouterError::Params(Rc::new(e)))
fn into_param(value: Option<&str>, name: &str) -> Result<Self, ParamsError> {
let value = value.ok_or_else(|| ParamsError::MissingParam(name.to_string()))?;
Self::from_str(value).map_err(|e| ParamsError::Params(Rc::new(e)))
}
}
/// Errors that can occur while parsing params using [IntoParams].
#[derive(Error, Debug, Clone)]
pub enum ParamsError {
/// A field was missing from the route params.
#[error("could not find parameter {0}")]
MissingParam(String),
/// Something went wrong while deserializing a field.
#[error("failed to deserialize parameters")]
Params(Rc<dyn std::error::Error>),
}
impl PartialEq for ParamsError {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::MissingParam(l0), Self::MissingParam(r0)) => l0 == r0,
(Self::Params(_), Self::Params(_)) => false,
_ => false,
}
}
}

View File

@@ -3,10 +3,11 @@ use std::rc::Rc;
use leptos::{create_memo, use_context, Memo, Scope};
use crate::{
Location, NavigateOptions, NavigationError, Params, ParamsMap, RouteContext, RouterContext,
RouterError,
Location, NavigateOptions, NavigationError, Params, ParamsError, ParamsMap, RouteContext,
RouterContext,
};
/// Returns the current [RouterContext], containing information about the router's state.
pub fn use_router(cx: Scope) -> RouterContext {
if let Some(router) = use_context::<RouterContext>(cx) {
router
@@ -16,15 +17,24 @@ pub fn use_router(cx: Scope) -> RouterContext {
}
}
/// Returns the current [RouteContext], containing information about the matched route.
pub fn use_route(cx: Scope) -> RouteContext {
use_context::<RouteContext>(cx).unwrap_or_else(|| use_router(cx).base())
}
/// Returns the current [Location], which contains reactive variables
pub fn use_location(cx: Scope) -> Location {
use_router(cx).inner.location.clone()
}
pub fn use_params<T: Params>(cx: Scope) -> Memo<Result<T, RouterError>>
/// Returns a raw key-value map of route params.
pub fn use_params_map(cx: Scope) -> Memo<ParamsMap> {
let route = use_route(cx);
route.params()
}
/// Returns the current route params, parsed into the given type, or an error.
pub fn use_params<T: Params>(cx: Scope) -> Memo<Result<T, ParamsError>>
where
T: PartialEq + std::fmt::Debug,
{
@@ -32,12 +42,13 @@ where
create_memo(cx, move |_| route.params().with(T::from_map))
}
pub fn use_params_map(cx: Scope) -> Memo<ParamsMap> {
let route = use_route(cx);
route.params()
/// Returns a raw key-value map of the URL search query.
pub fn use_query_map(cx: Scope) -> Memo<ParamsMap> {
use_router(cx).inner.location.query
}
pub fn use_query<T: Params>(cx: Scope) -> Memo<Result<T, RouterError>>
/// Returns the current URL search query, parsed into the given type, or an error.
pub fn use_query<T: Params>(cx: Scope) -> Memo<Result<T, ParamsError>>
where
T: PartialEq + std::fmt::Debug,
{
@@ -47,16 +58,14 @@ where
})
}
pub fn use_query_map(cx: Scope) -> Memo<ParamsMap> {
use_router(cx).inner.location.query
}
/// Resolves the given path relative to the current route.
pub fn use_resolved_path(cx: Scope, path: impl Fn() -> String + 'static) -> Memo<Option<String>> {
let route = use_route(cx);
create_memo(cx, move |_| route.resolve_path(&path()).map(String::from))
}
/// Returns a function that can be used to navigate to a new route.
pub fn use_navigate(cx: Scope) -> impl Fn(&str, NavigateOptions) -> Result<(), NavigationError> {
let router = use_router(cx);
move |to, options| Rc::clone(&router.inner).navigate_from_route(to, &options)

View File

@@ -6,10 +6,8 @@
//! apps (SPAs), server-side rendering/multi-page apps (MPAs), or to synchronize
//! state between the two.
//!
//! **Note:** This is a work in progress. Docs are still being written,
//! and some features are only stubs, in particular
//! - passing client-side route [State] in [History.state](https://developer.mozilla.org/en-US/docs/Web/API/History/state))
//! - data mutations using [Action]s and [Form] `method="POST"`
//! **Note:** This is a work in progress. Docs are still being written, in particular
//! passing client-side route [State] in [History.state](https://developer.mozilla.org/en-US/docs/Web/API/History/state))
//!
//! ## Philosophy
//!
@@ -62,20 +60,13 @@
//! <Route
//! path=""
//! element=move |cx| view! { cx, <ContactList/> }
//! // <ContactList/> needs all the contacts, so we provide the loader here
//! // this will only be reloaded if we navigate away to /about and back to / or /:id
//! loader=contact_list_data.into()
//! >
//! // users like /gbj or /bob
//! <Route
//! path=":id"
//! // <Contact/> needs contact data, so we provide the loader here
//! // this will be reloaded when the :id changes
//! loader=contact_data.into()
//! element=move |cx| view! { cx, <Contact/> }
//! />
//! // a fallback if the /:id segment is missing from the URL
//! // doesn't need any data, so no loader is provided
//! <Route
//! path=""
//! element=move |_| view! { cx, <p class="contact">"Select a contact."</p> }
@@ -93,33 +84,46 @@
//! }
//! }
//!
//! // Loaders are async functions that have access to the reactive scope,
//! // map of matched URL params for that route, and the URL
//! // They are reloaded whenever the params or URL change
//!
//! type ContactSummary = (); // TODO!
//! type Contact = (); // TODO!()
//!
//! // contact_data reruns whenever the :id param changes
//! async fn contact_data(_cx: Scope, _params: ParamsMap, url: Url) -> Contact {
//! async fn contact_data(id: String) -> Contact {
//! todo!()
//! }
//!
//! // contact_list_data *doesn't* rerun when the :id changes,
//! // because that param is nested lower than the <ContactList/> route
//! async fn contact_list_data(_cx: Scope, _params: ParamsMap, url: Url) -> Vec<ContactSummary> {
//! async fn contact_list_data() -> Vec<ContactSummary> {
//! todo!()
//! }
//!
//! #[component]
//! fn ContactList(cx: Scope) -> Element {
//! let data = use_loader::<Vec<ContactSummary>>(cx);
//! todo!()
//! // loads the contact list data once; doesn't reload when nested routes change
//! let contacts = create_resource(cx, || (), |_| contact_list_data());
//! view! {
//! cx,
//! <div>
//! // show the contacts
//! <ul>
//! {move || contacts.read().map(|contacts| view! { cx, <li>"todo contact info"</li> } )}
//! </ul>
//!
//! // insert the nested child route here
//! <Outlet/>
//! </div>
//! }
//! }
//!
//! #[component]
//! fn Contact(cx: Scope) -> Element {
//! let data = use_loader::<Contact>(cx);
//! let params = use_params_map(cx);
//! let data = create_resource(
//! cx,
//! move || params.with(|p| p.get("id").cloned().unwrap_or_default()),
//! move |id| contact_data(id)
//! );
//! todo!()
//! }
//!
@@ -136,16 +140,12 @@
#![feature(type_name_of_val)]
mod components;
mod data;
mod error;
mod fetch;
mod history;
mod hooks;
mod matching;
pub use components::*;
pub use data::*;
pub use error::*;
pub use fetch::*;
pub use history::*;
pub use hooks::*;

View File

@@ -25,9 +25,12 @@ pub(crate) fn get_route_matches(branches: Vec<Branch>, location: String) -> Vec<
vec![]
}
/// Describes a branch of the route tree.
#[derive(Debug, Clone, PartialEq)]
pub struct Branch {
/// All the routes contained in the branch.
pub routes: Vec<RouteData>,
/// How closely this branch matches the current URL.
pub score: i32,
}

View File

@@ -3,13 +3,9 @@ use std::rc::Rc;
use leptos::leptos_dom::Child;
use leptos::*;
use crate::{Action, Loader};
#[derive(Clone)]
pub struct RouteDefinition {
pub path: &'static str,
pub loader: Option<Loader>,
pub action: Option<Action>,
pub children: Vec<RouteDefinition>,
pub element: Rc<dyn Fn(Scope) -> Child>,
}
@@ -18,8 +14,6 @@ impl std::fmt::Debug for RouteDefinition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RouteDefinition")
.field("path", &self.path)
.field("loader", &self.loader)
.field("action", &self.action)
.field("children", &self.children)
.finish()
}
@@ -35,8 +29,6 @@ impl Default for RouteDefinition {
fn default() -> Self {
Self {
path: Default::default(),
loader: Default::default(),
action: Default::default(),
children: Default::default(),
element: Rc::new(|_| Child::Null),
}