Compare commits

..

6 Commits

Author SHA1 Message Date
Greg Johnston
3d50ca32cd v0.2.0 2023-02-25 15:45:35 -05:00
tanguy-lf
e576d93f83 examples: add ssr_mode_axum (#575) 2023-02-25 11:24:24 -05:00
Greg Johnston
e71779b8a6 fix: <Transition/> with local_resource (closes #562) (#574) 2023-02-24 19:51:03 -05:00
Markus Kohlhase
0301c7f1cf example: Login with API token (CSR only) (#523) 2023-02-24 17:11:58 -05:00
Remo
46e6e7629c chore: macro panic hygiene (#568) 2023-02-24 16:36:05 -05:00
SleeplessOne1917
a985ae5660 fix: <Meta/> component as_ property outputs correct attribute html (#573) 2023-02-24 08:58:15 -05:00
78 changed files with 1850 additions and 323 deletions

View File

@@ -21,18 +21,18 @@ members = [
exclude = ["benchmarks", "examples"]
[workspace.package]
version = "0.2.0-beta"
version = "0.2.0"
[workspace.dependencies]
leptos = { path = "./leptos", default-features = false, version = "0.2.0-beta" }
leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.0-beta" }
leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.0-beta" }
leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.0-beta" }
leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.0-beta" }
leptos_config = { path = "./leptos_config", default-features = false, version = "0.2.0-beta" }
leptos_router = { path = "./router", version = "0.2.0-beta" }
leptos_meta = { path = "./meta", default-feature = false, version = "0.2.0-beta" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.2.0-beta" }
leptos = { path = "./leptos", default-features = false, version = "0.2.0" }
leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.0" }
leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.0" }
leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.0" }
leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.0" }
leptos_config = { path = "./leptos_config", default-features = false, version = "0.2.0" }
leptos_router = { path = "./router", version = "0.2.0" }
leptos_meta = { path = "./meta", default-feature = false, version = "0.2.0" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.2.0" }
[profile.release]
codegen-units = 1

View File

@@ -4,10 +4,11 @@ version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
leptos = { path = "../../leptos" }
console_log = "0.2"
log = "0.4"
console_error_panic_hook = "0.1.7"
[dev-dependencies]
wasm-bindgen-test = "0.3.0"

View File

@@ -16,10 +16,12 @@ serde = { version = "1", features = ["derive"] }
futures = "0.3"
cfg-if = "1"
lazy_static = "1"
leptos = { path = "../../leptos", features = ["serde"] }
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
leptos_meta = { path = "../../meta", default-features = false }
leptos_router = { path = "../../router", default-features = false }
log = "0.4"
simple_logger = "4.0.0"
gloo-net = { git = "https://github.com/rustwasm/gloo" }
@@ -36,9 +38,10 @@ ssr = [
"leptos_meta/ssr",
"leptos_router/ssr",
]
stable = ["leptos/stable", "leptos_router/stable"]
[package.metadata.cargo-all-features]
denylist = ["actix-files", "actix-web", "leptos_actix"]
denylist = ["actix-files", "actix-web", "leptos_actix", "stable"]
skip_feature_sets = [["ssr", "hydrate"]]
[package.metadata.leptos]

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { path = "../../leptos", default-features = false, features = ["csr"] }
leptos = { path = "../../leptos", features = ["stable"] }
console_log = "0.2"
log = "0.4"
console_error_panic_hook = "0.1.7"

View File

@@ -4,10 +4,11 @@ version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
leptos = { path = "../../leptos" }
log = "0.4"
console_log = "0.2"
console_error_panic_hook = "0.1.7"
[dev-dependencies]
wasm-bindgen-test = "0.3.0"

View File

@@ -4,10 +4,11 @@ version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { path = "../../leptos", default-features = false, features = ["csr"] }
leptos = { path = "../../leptos", features = ["stable"] }
log = "0.4"
console_log = "0.2"
console_error_panic_hook = "0.1.7"
[dev-dependencies]
wasm-bindgen-test = "0.3.0"

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
leptos = { path = "../../leptos" }
console_log = "0.2"
log = "0.4"
console_error_panic_hook = "0.1.7"

View File

@@ -12,11 +12,13 @@ console_log = "0.2.0"
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../../leptos/leptos", features = ["serde"] }
leptos_axum = { path = "../../../leptos/integrations/axum", optional = true }
leptos_meta = { path = "../../../leptos/meta" }
leptos_router = { path = "../../../leptos/router" }
leptos_reactive = { path = "../../../leptos/leptos_reactive" }
leptos = { path = "../../../leptos/leptos", default-features = false, features = [
"serde",
] }
leptos_axum = { path = "../../../leptos/integrations/axum", default-features = false, optional = true }
leptos_meta = { path = "../../../leptos/meta", default-features = false }
leptos_router = { path = "../../../leptos/router", default-features = false }
leptos_reactive = { path = "../../../leptos/leptos_reactive", default-features = false }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }

View File

@@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
anyhow = "1.0.58"
leptos = { path = "../../leptos", features = ["csr"] }
leptos = { path = "../../leptos" }
reqwasm = "0.5.0"
serde = { version = "1", features = ["derive"] }
log = "0.4"
@@ -14,3 +14,4 @@ console_error_panic_hook = "0.1.7"
[dev-dependencies]
wasm-bindgen-test = "0.3.0"

View File

@@ -14,10 +14,12 @@ console_log = "0.2"
console_error_panic_hook = "0.1"
futures = "0.3"
cfg-if = "1"
leptos = { path = "../../leptos", features = ["serde"] }
leptos_meta = { path = "../../meta" }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_router = { path = "../../router" }
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_meta = { path = "../../meta", default-features = false }
leptos_actix = { path = "../../integrations/actix", default-features = false, optional = true }
leptos_router = { path = "../../router", default-features = false }
log = "0.4"
simple_logger = "4.0.0"
serde = { version = "1", features = ["derive"] }

View File

@@ -12,10 +12,12 @@ console_log = "0.2.0"
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../leptos", features = ["serde"] }
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
leptos_meta = { path = "../../meta", default-features = false }
leptos_router = { path = "../../router", default-features = false }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
@@ -53,26 +55,26 @@ skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "hackernews_axum"
output-name = "hackernews_axum"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "npx playwright test"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
browserquery = "defaults"
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
watch = false
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
@@ -93,4 +95,4 @@ lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
lib-default-features = false

View File

@@ -0,0 +1,7 @@
[workspace]
members = ["client", "api-boundary", "server"]
[patch.crates-io]
leptos = { path = "../../leptos" }
leptos_router = { path = "../../router" }
api-boundary = { path = "api-boundary" }

View File

@@ -0,0 +1,23 @@
# Leptos Login Example
This example demonstrates a scenario of a client-side rendered application
that uses uses an existing API that you cannot or do not want to change.
The authentications of this example are done using an API token.
## Run
First start the example server:
```
cd server/ && cargo run
```
then use [`trunk`](https://trunkrs.dev) to serve the SPA:
```
cd client/ && trunk serve
```
finally you can visit the web application at `http://localhost:8080`
The `api-boundary` crate contains data structures that are used by the server and the client.

View File

@@ -0,0 +1,8 @@
[package]
name = "api-boundary"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
serde = { version = "1.0", features = ["derive"] }

View File

@@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Credentials {
pub email: String,
pub password: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct UserInfo {
pub email: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct ApiToken {
pub token: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Error {
pub message: String,
}

View File

@@ -0,0 +1,19 @@
[package]
name = "client"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
api-boundary = "*"
leptos = { version = "0.2.0-alpha2", features = ["stable"] }
leptos_router = { version = "0.2.0-alpha2", features = ["stable", "csr"] }
log = "0.4"
console_error_panic_hook = "0.1"
console_log = "0.2"
gloo-net = "0.2"
gloo-storage = "0.2"
serde = "1.0"
thiserror = "1.0"

View File

@@ -0,0 +1,3 @@
[[proxy]]
rewrite = "/api/"
backend = "http://0.0.0.0:3000/"

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs/>
</head>
<body></body>
</html>

View File

@@ -0,0 +1,94 @@
use gloo_net::http::{Request, Response};
use serde::de::DeserializeOwned;
use thiserror::Error;
use api_boundary::*;
#[derive(Clone, Copy)]
pub struct UnauthorizedApi {
url: &'static str,
}
#[derive(Clone)]
pub struct AuthorizedApi {
url: &'static str,
token: ApiToken,
}
impl UnauthorizedApi {
pub const fn new(url: &'static str) -> Self {
Self { url }
}
pub async fn register(&self, credentials: &Credentials) -> Result<()> {
let url = format!("{}/users", self.url);
let response = Request::post(&url).json(credentials)?.send().await?;
into_json(response).await
}
pub async fn login(
&self,
credentials: &Credentials,
) -> Result<AuthorizedApi> {
let url = format!("{}/login", self.url);
let response = Request::post(&url).json(credentials)?.send().await?;
let token = into_json(response).await?;
Ok(AuthorizedApi::new(self.url, token))
}
}
impl AuthorizedApi {
pub const fn new(url: &'static str, token: ApiToken) -> Self {
Self { url, token }
}
fn auth_header_value(&self) -> String {
format!("Bearer {}", self.token.token)
}
async fn send<T>(&self, req: Request) -> Result<T>
where
T: DeserializeOwned,
{
let response = req
.header("Authorization", &self.auth_header_value())
.send()
.await?;
into_json(response).await
}
pub async fn logout(&self) -> Result<()> {
let url = format!("{}/logout", self.url);
self.send(Request::post(&url)).await
}
pub async fn user_info(&self) -> Result<UserInfo> {
let url = format!("{}/users", self.url);
self.send(Request::get(&url)).await
}
pub fn token(&self) -> &ApiToken {
&self.token
}
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Fetch(#[from] gloo_net::Error),
#[error("{0:?}")]
Api(api_boundary::Error),
}
impl From<api_boundary::Error> for Error {
fn from(e: api_boundary::Error) -> Self {
Self::Api(e)
}
}
async fn into_json<T>(response: Response) -> Result<T>
where
T: DeserializeOwned,
{
// ensure we've got 2xx status
if response.ok() {
Ok(response.json().await?)
} else {
Err(response.json::<api_boundary::Error>().await?.into())
}
}

View File

@@ -0,0 +1,73 @@
use leptos::{ev, *};
#[component]
pub fn CredentialsForm(
cx: Scope,
title: &'static str,
action_label: &'static str,
action: Action<(String, String), ()>,
error: Signal<Option<String>>,
disabled: Signal<bool>,
) -> impl IntoView {
let (password, set_password) = create_signal(cx, String::new());
let (email, set_email) = create_signal(cx, String::new());
let dispatch_action =
move || action.dispatch((email.get(), password.get()));
let button_is_disabled = Signal::derive(cx, move || {
disabled.get() || password.get().is_empty() || email.get().is_empty()
});
view! { cx,
<form on:submit=|ev|ev.prevent_default()>
<p>{ title }</p>
{move || error.get().map(|err| view!{ cx,
<p style ="color:red;" >{ err }</p>
})}
<input
type = "email"
required
placeholder = "Email address"
prop:disabled = move || disabled.get()
on:keyup = move |ev: ev::KeyboardEvent| {
let val = event_target_value(&ev);
set_email.update(|v|*v = val);
}
// The `change` event fires when the browser fills the form automatically,
on:change = move |ev| {
let val = event_target_value(&ev);
set_email.update(|v|*v = val);
}
/>
<input
type = "password"
required
placeholder = "Password"
prop:disabled = move || disabled.get()
on:keyup = move |ev: ev::KeyboardEvent| {
match &*ev.key() {
"Enter" => {
dispatch_action();
}
_=> {
let val = event_target_value(&ev);
set_password.update(|p|*p = val);
}
}
}
// The `change` event fires when the browser fills the form automatically,
on:change = move |ev| {
let val = event_target_value(&ev);
set_password.update(|p|*p = val);
}
/>
<button
prop:disabled = move || button_is_disabled.get()
on:click = move |_| dispatch_action()
>
{ action_label }
</button>
</form>
}
}

View File

@@ -0,0 +1,4 @@
pub mod credentials;
pub mod navbar;
pub use self::{credentials::*, navbar::*};

View File

@@ -0,0 +1,32 @@
use leptos::*;
use leptos_router::*;
use crate::Page;
#[component]
pub fn NavBar<F>(
cx: Scope,
logged_in: Signal<bool>,
on_logout: F,
) -> impl IntoView
where
F: Fn() + 'static + Clone,
{
view! { cx,
<nav>
<Show
when = move || logged_in.get()
fallback = |cx| view! { cx,
<A href=Page::Login.path() >"Login"</A>
" | "
<A href=Page::Register.path() >"Register"</A>
}
>
<a href="#" on:click={
let on_logout = on_logout.clone();
move |_| on_logout()
}>"Logout"</a>
</Show>
</nav>
}
}

View File

@@ -0,0 +1,130 @@
use gloo_storage::{LocalStorage, Storage};
use leptos::*;
use leptos_router::*;
use api_boundary::*;
mod api;
mod components;
mod pages;
use self::{components::*, pages::*};
const DEFAULT_API_URL: &str = "/api";
const API_TOKEN_STORAGE_KEY: &str = "api-token";
#[component]
pub fn App(cx: Scope) -> impl IntoView {
// -- signals -- //
let authorized_api = create_rw_signal(cx, None::<api::AuthorizedApi>);
let user_info = create_rw_signal(cx, None::<UserInfo>);
let logged_in = Signal::derive(cx, move || authorized_api.get().is_some());
// -- actions -- //
let fetch_user_info = create_action(cx, move |_| async move {
match authorized_api.get() {
Some(api) => match api.user_info().await {
Ok(info) => {
user_info.update(|i| *i = Some(info));
}
Err(err) => {
log::error!("Unable to fetch user info: {err}")
}
},
None => {
log::error!("Unable to fetch user info: not logged in")
}
}
});
let logout = create_action(cx, move |_| async move {
match authorized_api.get() {
Some(api) => match api.logout().await {
Ok(_) => {
authorized_api.update(|a| *a = None);
user_info.update(|i| *i = None);
}
Err(err) => {
log::error!("Unable to logout: {err}")
}
},
None => {
log::error!("Unable to logout user: not logged in")
}
}
});
// -- callbacks -- //
let on_logout = move || {
logout.dispatch(());
};
// -- init API -- //
let unauthorized_api = api::UnauthorizedApi::new(DEFAULT_API_URL);
if let Ok(token) = LocalStorage::get(API_TOKEN_STORAGE_KEY) {
let api = api::AuthorizedApi::new(DEFAULT_API_URL, token);
authorized_api.update(|a| *a = Some(api));
fetch_user_info.dispatch(());
}
log::debug!("User is logged in: {}", logged_in.get());
// -- effects -- //
create_effect(cx, move |_| {
log::debug!("API authorization state changed");
match authorized_api.get() {
Some(api) => {
log::debug!(
"API is now authorized: save token in LocalStorage"
);
LocalStorage::set(API_TOKEN_STORAGE_KEY, api.token())
.expect("LocalStorage::set");
}
None => {
log::debug!("API is no longer authorized: delete token from LocalStorage");
LocalStorage::delete(API_TOKEN_STORAGE_KEY);
}
}
});
view! { cx,
<Router>
<NavBar logged_in on_logout />
<main>
<Routes>
<Route
path=Page::Home.path()
view=move |cx| view! { cx,
<Home user_info = user_info.into() />
}
/>
<Route
path=Page::Login.path()
view=move |cx| view! { cx,
<Login
api = unauthorized_api
on_success = move |api| {
log::info!("Successfully logged in");
authorized_api.update(|v| *v = Some(api));
let navigate = use_navigate(cx);
navigate(Page::Home.path(), Default::default()).expect("Home route");
fetch_user_info.dispatch(());
} />
}
/>
<Route
path=Page::Register.path()
view=move |cx| view! { cx,
<Register api = unauthorized_api />
}
/>
</Routes>
</main>
</Router>
}
}

View File

@@ -0,0 +1,9 @@
use leptos::*;
use client::*;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(|cx| view! { cx, <App /> })
}

View File

@@ -0,0 +1,20 @@
use crate::Page;
use api_boundary::UserInfo;
use leptos::*;
use leptos_router::*;
#[component]
pub fn Home(cx: Scope, user_info: Signal<Option<UserInfo>>) -> impl IntoView {
view! { cx,
<h2>"Leptos Login example"</h2>
{move || match user_info.get() {
Some(info) => view!{ cx,
<p>"You are logged in with "{ info.email }"."</p>
}.into_view(cx),
None => view!{ cx,
<p>"You are not logged in."</p>
<A href=Page::Login.path() >"Login now."</A>
}.into_view(cx)
}}
}
}

View File

@@ -0,0 +1,66 @@
use leptos::*;
use leptos_router::*;
use api_boundary::*;
use crate::{
api::{self, AuthorizedApi, UnauthorizedApi},
components::credentials::*,
Page,
};
#[component]
pub fn Login<F>(cx: Scope, api: UnauthorizedApi, on_success: F) -> impl IntoView
where
F: Fn(AuthorizedApi) + 'static + Clone,
{
let (login_error, set_login_error) = create_signal(cx, None::<String>);
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
let login_action =
create_action(cx, move |(email, password): &(String, String)| {
log::debug!("Try to login with {email}");
let email = email.to_string();
let password = password.to_string();
let credentials = Credentials { email, password };
let on_success = on_success.clone();
async move {
set_wait_for_response.update(|w| *w = true);
let result = api.login(&credentials).await;
set_wait_for_response.update(|w| *w = false);
match result {
Ok(res) => {
set_login_error.update(|e| *e = None);
on_success(res);
}
Err(err) => {
let msg = match err {
api::Error::Fetch(js_err) => {
format!("{js_err:?}")
}
api::Error::Api(err) => err.message,
};
error!(
"Unable to login with {}: {msg}",
credentials.email
);
set_login_error.update(|e| *e = Some(msg));
}
}
}
});
let disabled = Signal::derive(cx, move || wait_for_response.get());
view! { cx,
<CredentialsForm
title = "Please login to your account"
action_label = "Login"
action = login_action
error = login_error.into()
disabled
/>
<p>"Don't have an account?"</p>
<A href=Page::Register.path()>"Register"</A>
}
}

View File

@@ -0,0 +1,23 @@
pub mod home;
pub mod login;
pub mod register;
pub use self::{home::*, login::*, register::*};
#[derive(Debug, Clone, Copy, Default)]
pub enum Page {
#[default]
Home,
Login,
Register,
}
impl Page {
pub fn path(&self) -> &'static str {
match self {
Self::Home => "/",
Self::Login => "/login",
Self::Register => "/register",
}
}
}

View File

@@ -0,0 +1,77 @@
use leptos::*;
use leptos_router::*;
use api_boundary::*;
use crate::{
api::{self, UnauthorizedApi},
components::credentials::*,
Page,
};
#[component]
pub fn Register(cx: Scope, api: UnauthorizedApi) -> impl IntoView {
let (register_response, set_register_response) =
create_signal(cx, None::<()>);
let (register_error, set_register_error) =
create_signal(cx, None::<String>);
let (wait_for_response, set_wait_for_response) = create_signal(cx, false);
let register_action =
create_action(cx, move |(email, password): &(String, String)| {
let email = email.to_string();
let password = password.to_string();
let credentials = Credentials { email, password };
log!("Try to register new account for {}", credentials.email);
async move {
set_wait_for_response.update(|w| *w = true);
let result = api.register(&credentials).await;
set_wait_for_response.update(|w| *w = false);
match result {
Ok(res) => {
set_register_response.update(|v| *v = Some(res));
set_register_error.update(|e| *e = None);
}
Err(err) => {
let msg = match err {
api::Error::Fetch(js_err) => {
format!("{js_err:?}")
}
api::Error::Api(err) => err.message,
};
log::warn!(
"Unable to register new account for {}: {msg}",
credentials.email
);
set_register_error.update(|e| *e = Some(msg));
}
}
}
});
let disabled = Signal::derive(cx, move || wait_for_response.get());
view! { cx,
<Show
when = move || register_response.get().is_some()
fallback = move |_| view!{ cx,
<CredentialsForm
title = "Please enter the desired credentials"
action_label = "Register"
action = register_action
error = register_error.into()
disabled
/>
<p>"Your already have an account?"</p>
<A href=Page::Login.path()>"Login"</A>
}
>
<p>"You have successfully registered."</p>
<p>
"You can now "
<A href=Page::Login.path()>"login"</A>
" with your new account."
</p>
</Show>
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "server"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
anyhow = "1.0"
api-boundary = "*"
axum = { version = "0.6", features = ["headers"] }
env_logger = "0.10"
log = "0.4"
mailparse = "0.14"
pwhash = "1.0"
thiserror = "1.0"
tokio = { version = "1.25", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.3", features = ["cors"] }
uuid = { version = "1.3", features = ["v4"] }

View File

@@ -0,0 +1,114 @@
use crate::{application::*, Error};
use api_boundary as json;
use axum::{
http::StatusCode,
response::Json,
response::{IntoResponse, Response},
};
use thiserror::Error;
impl From<InvalidEmailAddress> for json::Error {
fn from(_: InvalidEmailAddress) -> Self {
Self {
message: "Invalid email address".to_string(),
}
}
}
impl From<InvalidPassword> for json::Error {
fn from(err: InvalidPassword) -> Self {
let InvalidPassword::TooShort(min_len) = err;
Self {
message: format!("Invalid password (min. length = {min_len})"),
}
}
}
impl From<CreateUserError> for json::Error {
fn from(err: CreateUserError) -> Self {
let message = match err {
CreateUserError::UserExists => "User already exits".to_string(),
};
Self { message }
}
}
impl From<LoginError> for json::Error {
fn from(err: LoginError) -> Self {
let message = match err {
LoginError::InvalidEmailOrPassword => {
"Invalid email or password".to_string()
}
};
Self { message }
}
}
impl From<LogoutError> for json::Error {
fn from(err: LogoutError) -> Self {
let message = match err {
LogoutError::NotLoggedIn => "No user is logged in".to_string(),
};
Self { message }
}
}
impl From<AuthError> for json::Error {
fn from(err: AuthError) -> Self {
let message = match err {
AuthError::NotAuthorized => "Not authorized".to_string(),
};
Self { message }
}
}
impl From<CredentialParsingError> for json::Error {
fn from(err: CredentialParsingError) -> Self {
match err {
CredentialParsingError::EmailAddress(err) => err.into(),
CredentialParsingError::Password(err) => err.into(),
}
}
}
#[derive(Debug, Error)]
pub enum CredentialParsingError {
#[error(transparent)]
EmailAddress(#[from] InvalidEmailAddress),
#[error(transparent)]
Password(#[from] InvalidPassword),
}
impl TryFrom<json::Credentials> for Credentials {
type Error = CredentialParsingError;
fn try_from(
json::Credentials { email, password }: json::Credentials,
) -> Result<Self, Self::Error> {
let email: EmailAddress = email.parse()?;
let password = Password::try_from(password)?;
Ok(Self { email, password })
}
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
let (code, value) = match self {
Self::Logout(err) => {
(StatusCode::BAD_REQUEST, json::Error::from(err))
}
Self::Login(err) => {
(StatusCode::BAD_REQUEST, json::Error::from(err))
}
Self::Credentials(err) => {
(StatusCode::BAD_REQUEST, json::Error::from(err))
}
Self::CreateUser(err) => {
(StatusCode::BAD_REQUEST, json::Error::from(err))
}
Self::Auth(err) => {
(StatusCode::UNAUTHORIZED, json::Error::from(err))
}
};
(code, Json(value)).into_response()
}
}

View File

@@ -0,0 +1,159 @@
use mailparse::addrparse;
use pwhash::bcrypt;
use std::{collections::HashMap, str::FromStr, sync::RwLock};
use thiserror::Error;
use uuid::Uuid;
#[derive(Default)]
pub struct AppState {
users: RwLock<HashMap<EmailAddress, Password>>,
tokens: RwLock<HashMap<Uuid, EmailAddress>>,
}
impl AppState {
pub fn create_user(
&self,
credentials: Credentials,
) -> Result<(), CreateUserError> {
let Credentials { email, password } = credentials;
let user_exists = self.users.read().unwrap().get(&email).is_some();
if user_exists {
return Err(CreateUserError::UserExists);
}
self.users.write().unwrap().insert(email, password);
Ok(())
}
pub fn login(
&self,
email: EmailAddress,
password: &str,
) -> Result<Uuid, LoginError> {
let valid_credentials = self
.users
.read()
.unwrap()
.get(&email)
.map(|hashed_password| hashed_password.verify(password))
.unwrap_or(false);
if !valid_credentials {
Err(LoginError::InvalidEmailOrPassword)
} else {
let token = Uuid::new_v4();
self.tokens.write().unwrap().insert(token, email);
Ok(token)
}
}
pub fn logout(&self, token: &str) -> Result<(), LogoutError> {
let token = token
.parse::<Uuid>()
.map_err(|_| LogoutError::NotLoggedIn)?;
self.tokens.write().unwrap().remove(&token);
Ok(())
}
pub fn authorize_user(
&self,
token: &str,
) -> Result<CurrentUser, AuthError> {
token
.parse::<Uuid>()
.map_err(|_| AuthError::NotAuthorized)
.and_then(|token| {
self.tokens
.read()
.unwrap()
.get(&token)
.cloned()
.map(|email| CurrentUser { email, token })
.ok_or(AuthError::NotAuthorized)
})
}
}
#[derive(Debug, Error)]
pub enum CreateUserError {
#[error("The user already exists")]
UserExists,
}
#[derive(Debug, Error)]
pub enum LoginError {
#[error("Invalid email or password")]
InvalidEmailOrPassword,
}
#[derive(Debug, Error)]
pub enum LogoutError {
#[error("You are not logged in")]
NotLoggedIn,
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("You are not authorized")]
NotAuthorized,
}
pub struct Credentials {
pub email: EmailAddress,
pub password: Password,
}
#[derive(Clone, Eq, PartialEq, Hash)]
pub struct EmailAddress(String);
#[derive(Debug, Error)]
#[error("The given email address is invalid")]
pub struct InvalidEmailAddress;
impl FromStr for EmailAddress {
type Err = InvalidEmailAddress;
fn from_str(s: &str) -> Result<Self, Self::Err> {
addrparse(s)
.ok()
.and_then(|parsed| parsed.extract_single_info())
.map(|single_info| Self(single_info.addr))
.ok_or(InvalidEmailAddress)
}
}
impl EmailAddress {
pub fn into_string(self) -> String {
self.0
}
}
#[derive(Clone)]
pub struct CurrentUser {
pub email: EmailAddress,
pub token: Uuid,
}
const MIN_PASSWORD_LEN: usize = 3;
pub struct Password(String);
impl Password {
pub fn verify(&self, password: &str) -> bool {
bcrypt::verify(password, &self.0)
}
}
#[derive(Debug, Error)]
pub enum InvalidPassword {
#[error("Password is too short (min. length is {0})")]
TooShort(usize),
}
impl TryFrom<String> for Password {
type Error = InvalidPassword;
fn try_from(p: String) -> Result<Self, Self::Error> {
if p.len() < MIN_PASSWORD_LEN {
return Err(InvalidPassword::TooShort(MIN_PASSWORD_LEN));
}
let hashed = bcrypt::hash(&p).unwrap();
Ok(Self(hashed))
}
}

View File

@@ -0,0 +1,113 @@
use std::{env, sync::Arc};
use axum::{
extract::{State, TypedHeader},
headers::{authorization::Bearer, Authorization},
http::Method,
response::Json,
routing::{get, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use api_boundary as json;
mod adapters;
mod application;
use self::application::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
if let Err(err) = env::var("RUST_LOG") {
match err {
env::VarError::NotPresent => {
env::set_var("RUST_LOG", "debug");
}
env::VarError::NotUnicode(_) => {
return Err(anyhow::anyhow!("The value of 'RUST_LOG' does not contain valid unicode data."));
}
}
}
env_logger::init();
let shared_state = Arc::new(AppState::default());
let cors_layer = CorsLayer::new()
.allow_methods([Method::GET, Method::POST])
.allow_origin(Any);
let app = Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/users", post(create_user))
.route("/users", get(get_user_info))
.route_layer(cors_layer)
.with_state(shared_state);
let addr = "0.0.0.0:3000".parse().unwrap();
log::info!("Listen on {addr}");
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await?;
Ok(())
}
type Result<T> = std::result::Result<Json<T>, Error>;
/// API error
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
enum Error {
#[error(transparent)]
CreateUser(#[from] CreateUserError),
#[error(transparent)]
Login(#[from] LoginError),
#[error(transparent)]
Logout(#[from] LogoutError),
#[error(transparent)]
Auth(#[from] AuthError),
#[error(transparent)]
Credentials(#[from] adapters::CredentialParsingError),
}
async fn create_user(
State(state): State<Arc<AppState>>,
Json(credentials): Json<json::Credentials>,
) -> Result<()> {
let credentials = Credentials::try_from(credentials)?;
state.create_user(credentials)?;
Ok(Json(()))
}
async fn login(
State(state): State<Arc<AppState>>,
Json(credentials): Json<json::Credentials>,
) -> Result<json::ApiToken> {
let json::Credentials { email, password } = credentials;
log::debug!("{email} tries to login");
let email = email.parse().map_err(|_|
// Here we don't want to leak detailed info.
LoginError::InvalidEmailOrPassword)?;
let token = state.login(email, &password).map(|s| s.to_string())?;
Ok(Json(json::ApiToken { token }))
}
async fn logout(
State(state): State<Arc<AppState>>,
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> Result<()> {
state.logout(auth.token())?;
Ok(Json(()))
}
async fn get_user_info(
State(state): State<Arc<AppState>>,
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> Result<json::UserInfo> {
let user = state.authorize_user(auth.token())?;
let CurrentUser { email, .. } = user;
Ok(Json(json::UserInfo {
email: email.into_string(),
}))
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
leptos = { path = "../../leptos" }
console_log = "0.2"
log = "0.4"
console_error_panic_hook = "0.1.7"

View File

@@ -6,7 +6,7 @@ edition = "2021"
[dependencies]
console_log = "0.2"
log = "0.4"
leptos = { path = "../../leptos", features = ["csr"] }
leptos = { path = "../../leptos" }
leptos_router = { path = "../../router", features = ["csr"] }
serde = { version = "1", features = ["derive"] }
futures = "0.3"

View File

@@ -13,10 +13,12 @@ console_error_panic_hook = "0.1"
console_log = "0.2"
cfg-if = "1"
lazy_static = "1"
leptos = { path = "../../leptos", features = ["serde"] }
leptos_meta = { path = "../../meta" }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_router = { path = "../../router" }
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_meta = { path = "../../meta", default-features = false }
leptos_actix = { path = "../../integrations/actix", default-features = false, optional = true }
leptos_router = { path = "../../router", default-features = false }
log = "0.4"
serde = { version = "1", features = ["derive"] }
simple_logger = "4"

13
examples/ssr_modes_axum/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Generated by Cargo
# will have compiled files and executables
/target/
pkg
# These are backup files generated by rustfmt
**/*.rs.bk
# node e2e test tools and outputs
node_modules/
test-results/
end2end/playwright-report/
playwright/.cache/

View File

@@ -0,0 +1,91 @@
[package]
name = "ssr_modes_axum"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1"
console_log = "0.2"
cfg-if = "1"
lazy_static = "1"
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_meta = { path = "../../meta", default-features = false }
leptos_axum = { path = "../../integrations/axum", default-features = false, optional = true }
leptos_router = { path = "../../router", default-features = false }
log = "0.4"
serde = { version = "1", features = ["derive"] }
simple_logger = "4"
thiserror = "1"
axum = { version = "0.6.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.3.4", features = ["fs"], optional = true }
tokio = { version = "1", features = ["time"], optional = true}
wasm-bindgen = "0.2"
[features]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_axum",
]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "ssr_modes"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "assets"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 henrik
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,54 @@
# Server-Side Rendering Modes
This example shows the different "rendering modes" that can be used while server-side
rendering an application:
1. **Synchronous**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the client, replacing `fallback` once they're loaded.
- *Pros*: App shell appears very quickly: great TTFB (time to first byte).
- *Cons*: Resources load relatively slowly; you need to wait for JS + Wasm to load before even making a request.
2. **Out-of-order streaming**: Serve an HTML shell that includes `fallback` for any `Suspense`. Load data on the **server**, streaming it down to the client as it resolves, and streaming down HTML for `Suspense` nodes.
- *Pros*: Combines the best of **synchronous** and **`async`**, with a very fast shell and resources that begin loading on the server.
- *Cons*: Requires JS for suspended fragments to appear in correct order. Weaker meta tag support when it depends on data that's under suspense (has already streamed down `<head>`)
3. **In-order streaming**: Walk through the tree, returning HTML synchronously as in synchronous rendering and out-of-order streaming until you hit a `Suspense`. At that point, wait for all its data to load, then render it, then the rest of the tree.
- *Pros*: Does not require JS for HTML to appear in correct order.
- *Cons*: Loads the shell more slowly than out-of-order streaming or synchronous rendering because it needs to pause at every `Suspense`. Cannot begin hydration until the entire page has loaded, so earlier pieces
of the page will not be interactive until the suspended chunks have loaded.
4. **`async`**: Load all resources on the server. Wait until all data are loaded, and render HTML in one sweep.
- *Pros*: Better handling for meta tags (because you know async data even before you render the `<head>`). Faster complete load than **synchronous** because async resources begin loading on server.
- *Cons*: Slower load time/TTFB: you need to wait for all async resources to load before displaying anything on the client.
## Server Side Rendering with `cargo-leptos`
`cargo-leptos` is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
1. Install cargo-leptos
```bash
cargo install --locked cargo-leptos
```
2. Build the site in watch mode, recompiling on file changes
```bash
cargo leptos watch
```
Open browser on [http://localhost:3000/](http://localhost:3000/)
3. When ready to deploy, run
```bash
cargo leptos build --release
```
## Server Side Rendering without cargo-leptos
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
1. Install wasm-pack
```bash
cargo install wasm-pack
```
2. Build the Webassembly used to hydrate the HTML from the server
```bash
wasm-pack build --target=web --debug --no-default-features --features=hydrate
```
3. Run the server to serve the Webassembly, JS, and HTML
```bash
cargo run --no-default-features --features=ssr
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,189 @@
use lazy_static::lazy_static;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[component]
pub fn App(cx: Scope) -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context(cx);
view! { cx,
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
<Title text="Welcome to Leptos"/>
<Router>
<main>
<Routes>
// Well load the home page with out-of-order streaming and <Suspense/>
<Route path="" view=|cx| view! { cx, <HomePage/> }/>
// We'll load the posts with async rendering, so they can set
// the title and metadata *after* loading the data
<Route
path="/post/:id"
view=|cx| view! { cx, <Post/> }
ssr=SsrMode::Async
/>
<Route
path="/post_in_order/:id"
view=|cx| view! { cx, <Post/> }
ssr=SsrMode::InOrder
/>
</Routes>
</main>
</Router>
}
}
#[component]
fn HomePage(cx: Scope) -> impl IntoView {
// load the posts
let posts =
create_resource(cx, || (), |_| async { list_post_metadata().await });
let posts_view = move || {
posts.with(cx, |posts| posts
.clone()
.map(|posts| {
posts.iter()
.map(|post| view! { cx, <li><a href=format!("/post/{}", post.id)>{&post.title}</a> "|" <a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a></li>})
.collect::<Vec<_>>()
})
)
};
view! { cx,
<h1>"My Great Blog"</h1>
<Suspense fallback=move || view! { cx, <p>"Loading posts..."</p> }>
<ul>{posts_view}</ul>
</Suspense>
}
}
#[derive(Params, Copy, Clone, Debug, PartialEq, Eq)]
pub struct PostParams {
id: usize,
}
#[component]
fn Post(cx: Scope) -> impl IntoView {
let query = use_params::<PostParams>(cx);
let id = move || {
query.with(|q| {
q.as_ref().map(|q| q.id).map_err(|_| PostError::InvalidId)
})
};
let post = create_resource(cx, id, |id| async move {
match id {
Err(e) => Err(e),
Ok(id) => get_post(id)
.await
.map(|data| data.ok_or(PostError::PostNotFound))
.map_err(|_| PostError::ServerError)
.flatten(),
}
});
let post_view = move || {
post.with(cx, |post| {
post.clone().map(|post| {
view! { cx,
// render content
<h1>{&post.title}</h1>
<p>{&post.content}</p>
// since we're using async rendering for this page,
// this metadata should be included in the actual HTML <head>
// when it's first served
<Title text=post.title/>
<Meta name="description" content=post.content/>
}
})
})
};
view! { cx,
<Suspense fallback=move || view! { cx, <p>"Loading post..."</p> }>
<ErrorBoundary fallback=|cx, errors| {
view! { cx,
<div class="error">
<h1>"Something went wrong."</h1>
<ul>
{move || errors.get()
.into_iter()
.map(|(_, error)| view! { cx, <li>{error.to_string()} </li> })
.collect::<Vec<_>>()
}
</ul>
</div>
}
}>
{post_view}
</ErrorBoundary>
</Suspense>
}
}
// Dummy API
lazy_static! {
static ref POSTS: Vec<Post> = vec![
Post {
id: 0,
title: "My first post".to_string(),
content: "This is my first post".to_string(),
},
Post {
id: 1,
title: "My second post".to_string(),
content: "This is my second post".to_string(),
},
Post {
id: 2,
title: "My third post".to_string(),
content: "This is my third post".to_string(),
},
];
}
#[derive(Error, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PostError {
#[error("Invalid post ID.")]
InvalidId,
#[error("Post not found.")]
PostNotFound,
#[error("Server error.")]
ServerError,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Post {
id: usize,
title: String,
content: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostMetadata {
id: usize,
title: String,
}
#[server(ListPostMetadata, "/api")]
pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(POSTS
.iter()
.map(|data| PostMetadata {
id: data.id,
title: data.title.clone(),
})
.collect())
}
#[server(GetPost, "/api")]
pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(POSTS.iter().find(|post| post.id == id).cloned())
}

View File

@@ -0,0 +1,45 @@
use cfg_if::cfg_if;
cfg_if! { if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
extract::Extension,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use std::sync::Arc;
use leptos::{LeptosOptions, Errors, view};
use crate::app::{App, AppProps};
pub async fn file_and_error_handler(uri: Uri, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<Body>) -> AxumResponse {
let options = &*options;
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else{
let handler = leptos_axum::render_app_to_stream(
options.to_owned(),
move |cx| view!{ cx, <App/> }
);
handler(req).await.into_response()
}
}
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.map(boxed)),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),
)),
}
}
}}

View File

@@ -0,0 +1,26 @@
#![feature(result_flattening)]
pub mod app;
pub mod fallback;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "hydrate")] {
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
pub fn hydrate() {
use app::*;
use leptos::*;
// initializes logging using the `log` crate
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(move |cx| {
view! { cx, <App/> }
});
}
}
}

View File

@@ -0,0 +1,40 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main(){
use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use axum::{extract::{Extension, Path}, Router, routing::{get, post}};
use std::sync::Arc;
use ssr_modes_axum::fallback::file_and_error_handler;
use ssr_modes_axum::app::*;
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
let leptos_options = conf.leptos_options;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(|cx| view! { cx, <App/> }).await;
GetPost::register();
ListPostMetadata::register();
let app = Router::new()
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
.leptos_routes(leptos_options.clone(), routes, |cx| view! { cx, <App/> })
.fallback(file_and_error_handler)
.layer(Extension(Arc::new(leptos_options)));
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
log!("listening on http://{}", &addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for pure client-side testing
// see lib.rs for hydration function instead
}

View File

@@ -0,0 +1,3 @@
body {
font-family: sans-serif;
}

View File

@@ -10,10 +10,12 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { path = "../../leptos", features = ["serde"] }
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
leptos_meta = { path = "../../meta", default-features = false }
leptos_router = { path = "../../router", default-features = false }
gloo-net = { version = "0.2", features = ["http"] }
log = "0.4"
cfg-if = "1.0"

View File

@@ -16,10 +16,12 @@ console_error_panic_hook = "0.1.7"
serde = { version = "1.0.152", features = ["derive"] }
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../leptos", features = ["serde"] }
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
leptos_meta = { path = "../../meta", default-features = false }
leptos_router = { path = "../../router", default-features = false }
log = "0.4.17"
simple_logger = "4.0.0"
gloo = { git = "https://github.com/rustwasm/gloo" }

View File

@@ -12,11 +12,13 @@ console_log = "0.2.0"
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../leptos", features = ["serde"] }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
leptos_reactive = { path = "../../leptos_reactive" }
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_axum = { path = "../../integrations/axum", default-features = false, optional = true }
leptos_meta = { path = "../../meta", default-features = false }
leptos_router = { path = "../../router", default-features = false }
leptos_reactive = { path = "../../leptos_reactive", default-features = false }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }

View File

@@ -12,11 +12,13 @@ console_log = "0.2.0"
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../leptos", features = ["serde"] }
leptos_viz = { path = "../../integrations/viz", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
leptos_reactive = { path = "../../leptos_reactive" }
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_viz = { path = "../../integrations/viz", default-features = false, optional = true }
leptos_meta = { path = "../../meta", default-features = false }
leptos_router = { path = "../../router", default-features = false }
leptos_reactive = { path = "../../leptos_reactive", default-features = false }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
@@ -27,8 +29,8 @@ viz = { version = "0.4.8", features = ["serve"], optional = true }
tokio = { version = "1.25.0", features = ["full"], optional = true }
http = { version = "0.2.8" }
sqlx = { version = "0.6.2", features = [
"runtime-tokio-rustls",
"sqlite",
"runtime-tokio-rustls",
"sqlite",
], optional = true }
thiserror = "1.0.38"
tracing = "0.1.37"
@@ -45,7 +47,7 @@ ssr = [
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_viz",
"dep:leptos_viz"
]
[package.metadata.cargo-all-features]

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
leptos = { path = "../../leptos", default-features = false }
log = "0.4"
console_log = "0.2"
console_error_panic_hook = "0.1.7"

View File

@@ -912,7 +912,7 @@ where
};
let (stream, runtime, scope) =
render_to_stream_with_prefix_undisposed_with_context(
render_to_stream_in_order_with_prefix_undisposed_with_context(
app,
|_| "".into(),
add_context,

View File

@@ -12,7 +12,7 @@ readme = "../README.md"
cfg-if = "1"
leptos_dom = { workspace = true }
leptos_macro = { workspace = true }
leptos_reactive = { workspace = true, default-features = false }
leptos_reactive = { workspace = true }
leptos_server = { workspace = true }
leptos_config = { workspace = true }
tracing = "0.1"
@@ -22,7 +22,7 @@ typed-builder = "0.12"
leptos = { path = ".", default-features = false }
[features]
default = ["serde", "nightly"]
default = ["csr", "serde"]
csr = [
"leptos_dom/web",
"leptos_macro/csr",
@@ -41,11 +41,11 @@ ssr = [
"leptos_reactive/ssr",
"leptos_server/ssr",
]
nightly = [
"leptos_dom/nightly",
"leptos_macro/nightly",
"leptos_reactive/nightly",
"leptos_server/nightly",
stable = [
"leptos_dom/stable",
"leptos_macro/stable",
"leptos_reactive/stable",
"leptos_server/stable",
]
serde = ["leptos_reactive/serde"]
serde-lite = ["leptos_reactive/serde-lite"]

View File

@@ -1,7 +1,10 @@
use leptos_dom::{Fragment, IntoView, View};
use leptos_macro::component;
use leptos_reactive::{Scope, SignalSetter};
use std::{cell::RefCell, rc::Rc};
use leptos_reactive::{use_context, Scope, SignalSetter, SuspenseContext};
use std::{
cell::{Cell, RefCell},
rc::Rc,
};
/// If any [Resource](leptos_reactive::Resource)s are read in the `children` of this
/// component, it will show the `fallback` while they are loading. Once all are resolved,
@@ -74,26 +77,34 @@ where
F: Fn() -> E + 'static,
E: IntoView,
{
let prev_children = std::rc::Rc::new(RefCell::new(None::<Vec<View>>));
let prev_children = Rc::new(RefCell::new(None::<Vec<View>>));
#[cfg(not(feature = "hydrate"))]
let first_run = std::cell::Cell::new(true);
// in hydration mode, "first" run is on the server
#[cfg(feature = "hydrate")]
let first_run = std::cell::Cell::new(false);
let first_run = Rc::new(std::cell::Cell::new(true));
let child_runs = Cell::new(0);
crate::Suspense(
cx,
crate::SuspenseProps::builder()
.fallback({
let prev_child = Rc::clone(&prev_children);
let first_run = Rc::clone(&first_run);
move || {
let suspense_context = use_context::<SuspenseContext>(cx)
.expect("there to be a SuspenseContext");
let is_first_run =
is_first_run(&first_run, &suspense_context);
first_run.set(is_first_run);
if let Some(set_pending) = &set_pending {
set_pending.set(true);
}
if let Some(prev_children) = &*prev_child.borrow() {
prev_children.clone().into_view(cx)
if is_first_run {
fallback().into_view(cx)
} else {
prev_children.clone().into_view(cx)
}
} else {
fallback().into_view(cx)
}
@@ -102,10 +113,19 @@ where
.children(Box::new(move |cx| {
let frag = children(cx);
if !first_run.get() {
let suspense_context = use_context::<SuspenseContext>(cx)
.expect("there to be a SuspenseContext");
if is_first_run(&first_run, &suspense_context) {
let has_local_only = suspense_context.has_local_only();
*prev_children.borrow_mut() = Some(frag.nodes.clone());
if (has_local_only && child_runs.get() > 0)
|| !has_local_only
{
first_run.set(false);
}
}
first_run.set(false);
child_runs.set(child_runs.get() + 1);
if let Some(set_pending) = &set_pending {
set_pending.set(false);
@@ -115,3 +135,22 @@ where
.build(),
)
}
fn is_first_run(
first_run: &Rc<Cell<bool>>,
suspense_context: &SuspenseContext,
) -> bool {
match (
first_run.get(),
cfg!(feature = "hydrate"),
suspense_context.has_local_only(),
) {
(false, _, _) => false,
// is in hydrate mode, and has non-local resources (so, has streamed)
(_, false, false) => false,
// is in hydrate mode, but with only local resources (so, has not streamed)
(_, false, true) => true,
// either SSR or client mode: it's the first run
(_, true, _) => true,
}
}

View File

@@ -148,7 +148,8 @@ features = [
default = []
web = ["leptos_reactive/csr"]
ssr = ["leptos_reactive/ssr"]
nightly = ["leptos_reactive/nightly"]
stable = ["leptos_reactive/stable"]
[package.metadata.cargo-all-features]
denylist = ["stable"]
skip_feature_sets = [["web", "ssr"]]

View File

@@ -1,7 +1,7 @@
#![deny(missing_docs)]
#![forbid(unsafe_code)]
#![cfg_attr(feature = "nightly", feature(fn_traits))]
#![cfg_attr(feature = "nightly", feature(unboxed_closures))]
#![cfg_attr(not(feature = "stable"), feature(fn_traits))]
#![cfg_attr(not(feature = "stable"), feature(unboxed_closures))]
//! The DOM implementation for `leptos`.
@@ -974,7 +974,7 @@ viewable_primitive![
];
cfg_if! {
if #[cfg(feature = "nightly")] {
if #[cfg(not(feature = "stable"))] {
viewable_primitive! {
std::backtrace::Backtrace
}

View File

@@ -144,7 +144,7 @@ impl<T: ElementDescriptor> Clone for NodeRef<T> {
impl<T: ElementDescriptor + 'static> Copy for NodeRef<T> {}
cfg_if::cfg_if! {
if #[cfg(feature = "nightly")] {
if #[cfg(not(feature = "stable"))] {
impl<T: Clone + ElementDescriptor + 'static> FnOnce<()> for NodeRef<T> {
type Output = Option<HtmlElement<T>>;

View File

@@ -37,7 +37,7 @@ default = ["ssr"]
csr = ["leptos_dom/web", "leptos_reactive/csr"]
hydrate = ["leptos_dom/web", "leptos_reactive/hydrate"]
ssr = ["leptos_dom/ssr", "leptos_reactive/ssr"]
nightly = ["leptos_dom/nightly", "leptos_reactive/nightly"]
stable = ["leptos_dom/stable", "leptos_reactive/stable"]
tracing = []
[package.metadata.cargo-all-features]

View File

@@ -348,31 +348,30 @@ impl Docs {
.iter()
.enumerate()
.map(|(idx, attr)| {
if let Meta::NameValue(MetaNameValue { lit: doc, .. }) =
attr.parse_meta().unwrap()
{
let doc_str = quote!(#doc);
match attr.parse_meta() {
Ok(Meta::NameValue(MetaNameValue { lit: doc, .. })) => {
let doc_str = quote!(#doc);
// We need to remove the leading and trailing `"`"
let mut doc_str = doc_str.to_string();
doc_str.pop();
doc_str.remove(0);
// We need to remove the leading and trailing `"`"
let mut doc_str = doc_str.to_string();
doc_str.pop();
doc_str.remove(0);
let doc_str = if idx == 0 {
format!(" - {doc_str}")
} else {
format!(" {doc_str}")
};
let doc_str = if idx == 0 {
format!(" - {doc_str}")
} else {
format!(" {doc_str}")
};
let docs = LitStr::new(&doc_str, doc.span());
let docs = LitStr::new(&doc_str, doc.span());
if !doc_str.is_empty() {
quote! { #[doc = #docs] }
} else {
quote! {}
if !doc_str.is_empty() {
quote! { #[doc = #docs] }
} else {
quote! {}
}
}
} else {
unreachable!()
_ => abort!(attr, "could not parse attributes"),
}
})
.collect()
@@ -384,18 +383,17 @@ impl Docs {
.0
.iter()
.map(|attr| {
if let Meta::NameValue(MetaNameValue { lit: doc, .. }) =
attr.parse_meta().unwrap()
{
let mut doc_str = quote!(#doc).to_string();
match attr.parse_meta() {
Ok(Meta::NameValue(MetaNameValue { lit: doc, .. })) => {
let mut doc_str = quote!(#doc).to_string();
// Remove the leading and trailing `"`
doc_str.pop();
doc_str.remove(0);
// Remove the leading and trailing `"`
doc_str.pop();
doc_str.remove(0);
doc_str
} else {
unreachable!()
doc_str
}
_ => abort!(attr, "could not parse attributes"),
}
})
.intersperse("\n".to_string())
@@ -633,7 +631,7 @@ fn is_option(ty: &Type) -> bool {
}
}
fn unwrap_option(ty: &Type) -> Option<Type> {
fn unwrap_option(ty: &Type) -> Type {
const STD_OPTION_MSG: &str =
"make sure you're not shadowing the `std::option::Option` type that \
is automatically imported from the standard prelude";
@@ -651,37 +649,19 @@ fn unwrap_option(ty: &Type) -> Option<Type> {
{
if let [first] = &args.iter().collect::<Vec<_>>()[..] {
if let GenericArgument::Type(ty) = first {
Some(ty.clone())
} else {
abort!(
first,
"`Option` must be `std::option::Option`";
help = STD_OPTION_MSG
);
return ty.clone();
}
} else {
abort!(
first,
"`Option` must be `std::option::Option`";
help = STD_OPTION_MSG
);
}
} else {
abort!(
first,
"`Option` must be `std::option::Option`";
help = STD_OPTION_MSG
);
}
} else {
None
}
} else {
None
}
} else {
None
}
abort!(
ty,
"`Option` must be `std::option::Option`";
help = STD_OPTION_MSG
);
}
#[derive(Clone, Copy)]
@@ -703,7 +683,7 @@ fn prop_to_doc(
|| prop_opts.contains(&PropOpt::StripOption))
&& is_option(ty)
{
unwrap_option(ty).unwrap()
unwrap_option(ty)
} else {
ty.to_owned()
};

View File

@@ -1,4 +1,4 @@
#![cfg_attr(feature = "nightly", feature(proc_macro_span))]
#![cfg_attr(not(feature = "stable"), feature(proc_macro_span))]
#![forbid(unsafe_code)]
#[macro_use]
@@ -9,7 +9,7 @@ use proc_macro2::TokenTree;
use quote::ToTokens;
use server::server_macro_impl;
use syn::parse_macro_input;
use syn_rsx::{parse, NodeElement};
use syn_rsx::{parse, NodeAttribute, NodeElement};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum Mode {
@@ -304,13 +304,10 @@ pub fn view(tokens: TokenStream) -> TokenStream {
third.clone()
}
_ => {
let error_msg = concat!(
"To create a scope class with the view! macro \
you must put a comma `,` after the value.\n",
"e.g., view!{cx, class=\"my-class\", \
<div>...</div>}"
);
panic!("{error_msg}")
abort!(
punct, "To create a scope class with the view! macro you must put a comma `,` after the value";
help = r#"e.g., view!{cx, class="my-class", <div>...</div>}"#
)
}
}
}
@@ -338,7 +335,7 @@ pub fn view(tokens: TokenStream) -> TokenStream {
.into()
}
_ => {
panic!(
abort_call_site!(
"view! macro needs a context and RSX: e.g., view! {{ cx, \
<div>...</div> }}"
)
@@ -372,7 +369,7 @@ pub fn template(tokens: TokenStream) -> TokenStream {
.into()
}
_ => {
panic!(
abort_call_site!(
"view! macro needs a context and RSX: e.g., view! {{ cx, \
<div>...</div> }}"
)
@@ -693,8 +690,10 @@ pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
pub fn params_derive(
input: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
let ast = syn::parse(input).unwrap();
params::impl_params(&ast)
match syn::parse(input) {
Ok(ast) => params::impl_params(&ast),
Err(err) => err.to_compile_error().into(),
}
}
pub(crate) fn is_component_node(node: &NodeElement) -> bool {
@@ -702,3 +701,10 @@ pub(crate) fn is_component_node(node: &NodeElement) -> bool {
.to_string()
.starts_with(|c: char| c.is_ascii_uppercase())
}
pub(crate) fn attribute_value(attr: &NodeAttribute) -> &syn::Expr {
match &attr.value {
Some(value) => value.as_ref(),
None => abort!(attr.key, "attribute should have value"),
}
}

View File

@@ -13,10 +13,10 @@ pub fn impl_params(ast: &syn::DeriveInput) -> proc_macro::TokenStream {
.named
.iter()
.map(|field| {
let field_name_string = &field.ident.as_ref().unwrap().to_string();
let field_name_string = &field.ident.as_ref().expect("expected named struct fields").to_string();
let ident = &field.ident;
let ty = &field.ty;
let span = field.span().unwrap();
let span = field.span();
quote_spanned! {
span.into() => #ident: <#ty>::into_param(map.get(#field_name_string).map(|n| n.as_str()), #field_name_string)?

View File

@@ -46,7 +46,7 @@ pub fn server_macro_impl(
let block = body.block;
cfg_if! {
if #[cfg(all(feature = "nightly", debug_assertions))] {
if #[cfg(all(not(feature = "stable"), debug_assertions))] {
use proc_macro::Span;
let span = Span::call_site();
#[cfg(not(target_os = "windows"))]
@@ -61,7 +61,7 @@ pub fn server_macro_impl(
let fields = body.inputs.iter().filter(|f| !fn_arg_is_cx(f)).map(|f| {
let typed_arg = match f {
FnArg::Receiver(_) => {
panic!("cannot use receiver types in server function macro")
abort!(f, "cannot use receiver types in server function macro")
}
FnArg::Typed(t) => t,
};
@@ -96,7 +96,7 @@ pub fn server_macro_impl(
let fn_args = body.inputs.iter().map(|f| {
let typed_arg = match f {
FnArg::Receiver(_) => {
panic!("cannot use receiver types in server function macro")
abort!(f, "cannot use receiver types in server function macro")
}
FnArg::Typed(t) => t,
};
@@ -131,22 +131,21 @@ pub fn server_macro_impl(
let output_arrow = body.output_arrow;
let return_ty = body.return_ty;
let output_ty = if let syn::Type::Path(pat) = &return_ty {
if pat.path.segments[0].ident == "Result" {
if let PathArguments::AngleBracketed(args) =
&pat.path.segments[0].arguments
{
&args.args[0]
} else {
panic!(
"server functions should return Result<T, ServerFnError>"
);
let output_ty = 'output_ty: {
if let syn::Type::Path(pat) = &return_ty {
if pat.path.segments[0].ident == "Result" {
if let PathArguments::AngleBracketed(args) =
&pat.path.segments[0].arguments
{
break 'output_ty &args.args[0];
}
}
} else {
panic!("server functions should return Result<T, ServerFnError>");
}
} else {
panic!("server functions should return Result<T, ServerFnError>");
abort!(
return_ty,
"server functions should return Result<T, ServerFnError>"
);
};
Ok(quote::quote! {

View File

@@ -1,4 +1,4 @@
use crate::is_component_node;
use crate::{attribute_value, is_component_node};
use proc_macro2::{Ident, Span, TokenStream};
use quote::{quote, quote_spanned};
use syn::spanned::Spanned;
@@ -11,21 +11,11 @@ pub(crate) fn render_template(cx: &Ident, nodes: &[Node]) -> TokenStream {
Span::call_site(),
);
if nodes.len() == 1 {
first_node_to_tokens(cx, &template_uid, &nodes[0])
} else {
panic!("template! takes a single root element.")
}
}
fn first_node_to_tokens(
cx: &Ident,
template_uid: &Ident,
node: &Node,
) -> TokenStream {
match node {
Node::Element(node) => root_element_to_tokens(cx, template_uid, node),
_ => panic!("template! takes a single root element."),
match nodes.first() {
Some(Node::Element(node)) => {
root_element_to_tokens(cx, &template_uid, node)
}
_ => abort!(cx, "template! takes a single root element."),
}
}
@@ -205,7 +195,11 @@ fn element_to_tokens(
let mut prev_sib = prev_sib;
for (idx, child) in node.children.iter().enumerate() {
// set next sib (for any insertions)
let next_sib = next_sibling_node(&node.children, idx + 1, next_el_id);
let next_sib =
match next_sibling_node(&node.children, idx + 1, next_el_id) {
Ok(next_sib) => next_sib,
Err(err) => abort!(span, "{}", err),
};
let curr_id = child_to_tokens(
cx,
@@ -239,9 +233,9 @@ fn next_sibling_node(
children: &[Node],
idx: usize,
next_el_id: &mut usize,
) -> Option<Ident> {
) -> Result<Option<Ident>, String> {
if children.len() <= idx {
None
Ok(None)
} else {
let sibling = &children[idx];
@@ -250,16 +244,16 @@ fn next_sibling_node(
if is_component_node(sibling) {
next_sibling_node(children, idx + 1, next_el_id)
} else {
Some(child_ident(*next_el_id + 1, sibling.name.span()))
Ok(Some(child_ident(*next_el_id + 1, sibling.name.span())))
}
}
Node::Block(sibling) => {
Some(child_ident(*next_el_id + 1, sibling.value.span()))
Ok(Some(child_ident(*next_el_id + 1, sibling.value.span())))
}
Node::Text(sibling) => {
Some(child_ident(*next_el_id + 1, sibling.value.span()))
Ok(Some(child_ident(*next_el_id + 1, sibling.value.span())))
}
_ => panic!("expected either an element or a block"),
_ => Err("expected either an element or a block".to_string()),
}
}
}
@@ -272,16 +266,9 @@ fn attr_to_tokens(
expressions: &mut Vec<TokenStream>,
) {
let name = node.key.to_string();
let name = if name.starts_with('_') {
name.replacen('_', "", 1)
} else {
name
};
let name = if name.starts_with("attr:") {
name.replacen("attr:", "", 1)
} else {
name
};
let name = name.strip_prefix("_").unwrap_or(&name);
let name = name.strip_prefix("attr:").unwrap_or(&name);
let value = match &node.value {
Some(expr) => match expr.as_ref() {
syn::Expr::Lit(expr_lit) => {
@@ -300,7 +287,7 @@ fn attr_to_tokens(
// refs
if name == "ref" {
panic!("node_ref not yet supported in template! macro")
abort!(span, "node_ref not yet supported in template! macro")
}
// Event Handlers
else if name.starts_with("on:") {
@@ -311,28 +298,20 @@ fn attr_to_tokens(
})
}
// Properties
else if name.starts_with("prop:") {
let name = name.replacen("prop:", "", 1);
let value = node
.value
.as_ref()
.expect("prop: blocks need values")
.as_ref();
else if let Some(name) = name.strip_prefix("prop:") {
let value = attribute_value(&node);
expressions.push(quote_spanned! {
span => leptos_dom::property(#cx, #el_id.unchecked_ref(), #name, #value.into_property(#cx))
});
span => leptos_dom::property(#cx, #el_id.unchecked_ref(), #name, #value.into_property(#cx))
});
}
// Classes
else if name.starts_with("class:") {
let name = name.replacen("class:", "", 1);
let value = node
.value
.as_ref()
.expect("class: attributes need values")
.as_ref();
else if let Some(name) = name.strip_prefix("class:") {
let value = attribute_value(&node);
expressions.push(quote_spanned! {
span => leptos::leptos_dom::class_helper(#el_id.unchecked_ref(), #name.into(), #value.into_class(#cx))
});
span => leptos::leptos_dom::class_helper(#el_id.unchecked_ref(), #name.into(), #value.into_class(#cx))
});
}
// Attributes
else {
@@ -429,7 +408,7 @@ fn child_to_tokens(
expressions,
navigations,
),
_ => panic!("unexpected child node type"),
_ => abort!(cx, "unexpected child node type"),
}
}

View File

@@ -1,4 +1,4 @@
use crate::{is_component_node, Mode};
use crate::{attribute_value, is_component_node, Mode};
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use quote::{format_ident, quote, quote_spanned};
use syn::{spanned::Spanned, Expr, ExprLit, ExprPath, Lit};
@@ -576,11 +576,7 @@ fn set_class_attribute_ssr(
} else {
name
};
let value = node
.value
.as_ref()
.expect("class: attributes need values")
.as_ref();
let value = attribute_value(node);
let span = node.key.span();
Some((span, name, value))
} else {
@@ -802,23 +798,14 @@ fn attribute_to_tokens(cx: &Ident, node: &NodeAttribute) -> TokenStream {
let span = node.key.span();
let name = node.key.to_string();
if name == "ref" || name == "_ref" || name == "ref_" || name == "node_ref" {
let value = node
.value
.as_ref()
.and_then(|expr| expr_to_ident(expr))
.expect("'_ref' needs to be passed a variable name");
let value = expr_to_ident(attribute_value(node));
let node_ref = quote_spanned! { span => node_ref };
quote! {
.#node_ref(#value)
}
} else if let Some(name) = name.strip_prefix("on:") {
let handler = node
.value
.as_ref()
.expect("event listener attributes need a value")
.as_ref();
let handler = attribute_value(node);
let (name, is_force_undelegated) = parse_event(name);
let event_type = TYPED_EVENTS
@@ -827,9 +814,10 @@ fn attribute_to_tokens(cx: &Ident, node: &NodeAttribute) -> TokenStream {
.copied()
.unwrap_or("Custom");
let is_custom = event_type == "Custom";
let event_type = event_type
.parse::<TokenStream>()
.expect("couldn't parse event name");
let Ok(event_type) = event_type.parse::<TokenStream>() else {
abort!(event_type, "couldn't parse event name");
};
let event_type = if is_custom {
quote! { leptos::ev::Custom::new(#name) }
@@ -896,11 +884,7 @@ fn attribute_to_tokens(cx: &Ident, node: &NodeAttribute) -> TokenStream {
#on(#event_type, #handler)
}
} else if let Some(name) = name.strip_prefix("prop:") {
let value = node
.value
.as_ref()
.expect("prop: attributes need a value")
.as_ref();
let value = attribute_value(node);
let prop = match &node.key {
NodeName::Punctuated(parts) => &parts[0],
_ => unreachable!(),
@@ -915,11 +899,7 @@ fn attribute_to_tokens(cx: &Ident, node: &NodeAttribute) -> TokenStream {
#prop(#name, (#cx, #[allow(unused_braces)] #value))
}
} else if let Some(name) = name.strip_prefix("class:") {
let value = node
.value
.as_ref()
.expect("class: attributes need a value")
.as_ref();
let value = attribute_value(node);
let class = match &node.key {
NodeName::Punctuated(parts) => &parts[0],
_ => unreachable!(),
@@ -1012,16 +992,11 @@ pub(crate) fn component_to_tokens(
let items_to_clone = attrs
.clone()
.filter(|attr| attr.key.to_string().starts_with("clone:"))
.map(|attr| {
let ident = attr
.key
.filter_map(|attr| {
attr.key
.to_string()
.strip_prefix("clone:")
.unwrap()
.to_owned();
format_ident!("{ident}", span = attr.key.span())
.map(|ident| format_ident!("{ident}", span = attr.key.span()))
})
.collect::<Vec<_>>();
@@ -1085,14 +1060,14 @@ pub(crate) fn event_from_attribute_node(
attr: &NodeAttribute,
force_undelegated: bool,
) -> (TokenStream, &Expr) {
let event_name =
attr.key.to_string().strip_prefix("on:").unwrap().to_owned();
let event_name = attr
.key
.to_string()
.strip_prefix("on:")
.expect("expected `on:` directive")
.to_owned();
let handler = attr
.value
.as_ref()
.expect("event listener attributes need a value")
.as_ref();
let handler = attribute_value(attr);
#[allow(unused_variables)]
let (name, name_undelegated) = parse_event(&event_name);
@@ -1102,9 +1077,10 @@ pub(crate) fn event_from_attribute_node(
.find(|e| **e == name)
.copied()
.unwrap_or("Custom");
let event_type = event_type
.parse::<TokenStream>()
.expect("couldn't parse event name");
let Ok(event_type) = event_type.parse::<TokenStream>() else {
abort!(attr.key, "couldn't parse event name");
};
let event_type = if force_undelegated || name_undelegated {
quote! { ::leptos::leptos_dom::ev::undelegated(::leptos::leptos_dom::ev::#event_type) }

View File

@@ -37,16 +37,17 @@ tokio-test = "0.4"
leptos = { path = "../leptos" }
[features]
default = ["nightly"]
default = []
csr = []
hydrate = []
ssr = ["dep:tokio"]
nightly = []
stable = []
serde = []
serde-lite = ["dep:serde-lite"]
miniserde = ["dep:miniserde"]
[package.metadata.cargo-all-features]
denylist = ["stable"]
skip_feature_sets = [
[
"csr",

View File

@@ -1,7 +1,7 @@
#![deny(missing_docs)]
#![cfg_attr(feature = "nightly", feature(fn_traits))]
#![cfg_attr(feature = "nightly", feature(unboxed_closures))]
#![cfg_attr(feature = "nightly", feature(type_name_of_val))]
#![cfg_attr(not(feature = "stable"), feature(fn_traits))]
#![cfg_attr(not(feature = "stable"), feature(unboxed_closures))]
#![cfg_attr(not(feature = "stable"), feature(type_name_of_val))]
//! The reactive system for the [Leptos](https://docs.rs/leptos/latest/leptos/) Web framework.
//!

View File

@@ -578,6 +578,12 @@ where
let has_value = v.is_some();
let serializable = self.serializable;
if let Some(suspense_cx) = &suspense_cx {
if serializable {
suspense_cx.has_local_only.set_value(false);
}
}
let increment = move |_: Option<()>| {
if let Some(s) = &suspense_cx {
if let Ok(ref mut contexts) = suspense_contexts.try_borrow_mut()

View File

@@ -14,7 +14,7 @@ use thiserror::Error;
macro_rules! impl_get_fn_traits {
($($ty:ident $(($method_name:ident))?),*) => {
$(
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
impl<T: Clone> FnOnce<()> for $ty<T> {
type Output = T;
@@ -23,14 +23,14 @@ macro_rules! impl_get_fn_traits {
}
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
impl<T: Clone> FnMut<()> for $ty<T> {
extern "rust-call" fn call_mut(&mut self, _args: ()) -> Self::Output {
impl_get_fn_traits!(@method_name self $($method_name)?)
}
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
impl<T: Clone> Fn<()> for $ty<T> {
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
impl_get_fn_traits!(@method_name self $($method_name)?)
@@ -49,7 +49,7 @@ macro_rules! impl_get_fn_traits {
macro_rules! impl_set_fn_traits {
($($ty:ident $($method_name:ident)?),*) => {
$(
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
impl<T> FnOnce<(T,)> for $ty<T> {
type Output = ();
@@ -58,14 +58,14 @@ macro_rules! impl_set_fn_traits {
}
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
impl<T> FnMut<(T,)> for $ty<T> {
extern "rust-call" fn call_mut(&mut self, args: (T,)) -> Self::Output {
impl_set_fn_traits!(@method_name self $($method_name)? args)
}
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
impl<T> Fn<(T,)> for $ty<T> {
extern "rust-call" fn call(&self, args: (T,)) -> Self::Output {
impl_set_fn_traits!(@method_name self $($method_name)? args)

View File

@@ -2,8 +2,8 @@
#![forbid(unsafe_code)]
use crate::{
create_rw_signal, create_signal, queue_microtask, ReadSignal, RwSignal,
Scope, SignalUpdate, WriteSignal,
create_rw_signal, create_signal, queue_microtask, store_value, ReadSignal,
RwSignal, Scope, SignalUpdate, StoredValue, WriteSignal,
};
use futures::Future;
use std::{borrow::Cow, pin::Pin};
@@ -16,6 +16,14 @@ pub struct SuspenseContext {
pub pending_resources: ReadSignal<usize>,
set_pending_resources: WriteSignal<usize>,
pub(crate) pending_serializable_resources: RwSignal<usize>,
pub(crate) has_local_only: StoredValue<bool>,
}
impl SuspenseContext {
/// Whether the suspense contains local resources at this moment, and therefore can't be
pub fn has_local_only(&self) -> bool {
self.has_local_only.get_value()
}
}
impl std::hash::Hash for SuspenseContext {
@@ -37,10 +45,12 @@ impl SuspenseContext {
pub fn new(cx: Scope) -> Self {
let (pending_resources, set_pending_resources) = create_signal(cx, 0);
let pending_serializable_resources = create_rw_signal(cx, 0);
let has_local_only = store_value(cx, true);
Self {
pending_resources,
set_pending_resources,
pending_serializable_resources,
has_local_only,
}
}
@@ -48,10 +58,12 @@ impl SuspenseContext {
pub fn increment(&self, serializable: bool) {
let setter = self.set_pending_resources;
let serializable_resources = self.pending_serializable_resources;
let has_local_only = self.has_local_only;
queue_microtask(move || {
setter.update(|n| *n += 1);
if serializable {
serializable_resources.update(|n| *n += 1);
has_local_only.set_value(false);
}
});
}

View File

@@ -1,10 +1,10 @@
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
use leptos_reactive::{
create_isomorphic_effect, create_memo, create_runtime, create_scope,
create_signal,
};
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn effect_runs() {
use std::{cell::RefCell, rc::Rc};
@@ -32,7 +32,7 @@ fn effect_runs() {
.dispose()
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn effect_tracks_memo() {
use std::{cell::RefCell, rc::Rc};
@@ -62,7 +62,7 @@ fn effect_tracks_memo() {
.dispose()
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn untrack_mutes_effect() {
use std::{cell::RefCell, rc::Rc};

View File

@@ -1,9 +1,9 @@
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
use leptos_reactive::{
create_memo, create_runtime, create_scope, create_signal,
};
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn basic_memo() {
create_scope(create_runtime(), |cx| {
@@ -13,7 +13,7 @@ fn basic_memo() {
.dispose()
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn memo_with_computed_value() {
create_scope(create_runtime(), |cx| {
@@ -29,7 +29,7 @@ fn memo_with_computed_value() {
.dispose()
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn nested_memos() {
create_scope(create_runtime(), |cx| {
@@ -51,7 +51,7 @@ fn nested_memos() {
.dispose()
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn memo_runs_only_when_inputs_change() {
use std::{cell::Cell, rc::Rc};

View File

@@ -1,7 +1,7 @@
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
use leptos_reactive::{create_runtime, create_scope, create_signal};
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn basic_signal() {
create_scope(create_runtime(), |cx| {
@@ -13,7 +13,7 @@ fn basic_signal() {
.dispose()
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn derived_signals() {
create_scope(create_runtime(), |cx| {

View File

@@ -1,10 +1,10 @@
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
use leptos_reactive::{
create_isomorphic_effect, create_runtime, create_scope, create_signal,
signal_prelude::*, SignalGetUntracked, SignalSetUntracked,
};
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn untracked_set_doesnt_trigger_effect() {
use std::{cell::RefCell, rc::Rc};
@@ -36,7 +36,7 @@ fn untracked_set_doesnt_trigger_effect() {
.dispose()
}
#[cfg(feature = "nightly")]
#[cfg(not(feature = "stable"))]
#[test]
fn untracked_get_doesnt_trigger_effect() {
use std::{cell::RefCell, rc::Rc};

View File

@@ -42,10 +42,11 @@ ssr = [
#"leptos/ssr",
"leptos_reactive/ssr",
]
nightly = [
stable = [
#"leptos/stable",
"leptos_dom/nightly",
"leptos_reactive/nightly",
"leptos_dom/stable",
"leptos_reactive/stable",
]
[package.metadata.cargo-all-features]
denylist = ["stable"]

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_meta"
version = "0.2.0-beta"
version = "0.2.0"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
@@ -19,11 +19,12 @@ version = "0.3"
features = ["HtmlLinkElement", "HtmlMetaElement", "HtmlTitleElement"]
[features]
default = ["nightly"]
default = []
csr = ["leptos/csr", "leptos/tracing"]
hydrate = ["leptos/hydrate", "leptos/tracing"]
ssr = ["leptos/ssr", "leptos/tracing"]
nightly = ["leptos/nightly", "leptos/tracing"]
stable = ["leptos/stable", "leptos/tracing"]
[package.metadata.cargo-all-features]
denylist = ["stable"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]

View File

@@ -87,7 +87,7 @@ pub fn Link(
let builder_el = leptos::leptos_dom::html::link(cx)
.attr("id", &id)
.attr("as_", as_)
.attr("as", as_)
.attr("crossorigin", crossorigin)
.attr("disabled", disabled.unwrap_or(false))
.attr("fetchpriority", fetchpriority)

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_router"
version = "0.2.0-beta"
version = "0.2.0"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
@@ -53,11 +53,11 @@ features = [
]
[features]
default = ["nightly"]
default = []
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
ssr = ["leptos/ssr", "dep:url", "dep:regex"]
nightly = ["leptos/nightly"]
stable = ["leptos/stable"]
[package.metadata.cargo-all-features]
# No need to test optional dependencies as they are enabled by the ssr feature

View File

@@ -131,7 +131,7 @@ where
}
cfg_if::cfg_if! {
if #[cfg(feature = "nightly")] {
if #[cfg(not(feature = "stable"))] {
auto trait NotOption {}
impl<T> !NotOption for Option<T> {}

View File

@@ -183,9 +183,9 @@
//! **Important Note:** You must enable one of `csr`, `hydrate`, or `ssr` to tell Leptos
//! which mode your app is operating in.
#![cfg_attr(feature = "nightly", feature(auto_traits))]
#![cfg_attr(feature = "nightly", feature(negative_impls))]
#![cfg_attr(feature = "nightly", feature(type_name_of_val))]
#![cfg_attr(not(feature = "stable"), feature(auto_traits))]
#![cfg_attr(not(feature = "stable"), feature(negative_impls))]
#![cfg_attr(not(feature = "stable"), feature(type_name_of_val))]
mod components;
#[cfg(any(feature = "ssr", doc))]