Compare commits

...

16 Commits

Author SHA1 Message Date
Greg Johnston
abc1c07053 Work on fixing tests 2022-11-03 08:08:03 -04:00
Greg Johnston
8a7ff0414a Fixing a few more build/test issues 2022-11-02 23:10:12 -04:00
Greg Johnston
b3c7de8460 Cargo, go home. You're drunk. (Optional dependencies are not features. Cargo bug we work around.) 2022-11-02 22:25:19 -04:00
Greg Johnston
03821b8edb Clear warning when no features enabled 2022-11-02 22:16:23 -04:00
Greg Johnston
db69145fd9 Sorting out all sorts of feature flags etc. so everything will build and test 2022-11-02 21:46:47 -04:00
Greg Johnston
51142ad894 Makefile for cargo make build and cargo make test 2022-11-02 21:46:32 -04:00
Greg Johnston
8ea73565de Shift from mutually-exclusive features to a more-gracefully-degrading system of features ordered by preference, clean up some warnings, and use cfg_if for improved readability 2022-11-02 20:41:00 -04:00
Greg Johnston
19db83c933 Rename ServerForm to ActionForm 2022-11-02 07:49:40 -04:00
Greg Johnston
c034e84b1d Direct server fn => ServerForm interface per #43 2022-11-01 22:57:55 -04:00
Greg Johnston
292c3d8bb1 Support for POST in client-side forms 2022-10-30 20:37:50 -04:00
Greg Johnston
ae0fad5465 Abstract out ActixIntegration into a broader ServerIntegration and make it easier to use 2022-10-30 19:41:59 -04:00
Greg Johnston
15f3d66ef0 Merge branch 'main' of https://github.com/gbj/leptos 2022-10-30 18:55:21 -04:00
Greg Johnston
1041d04d9e Input and URL on actions to allow forms with optimistic UI 2022-10-30 18:55:16 -04:00
Greg Johnston
beaeb769d6 Update TODO.md 2022-10-29 20:35:57 -04:00
Greg Johnston
7168f24dcb Clean up some version mismatches from merging 2022-10-29 20:34:32 -04:00
Greg Johnston
b3217c6523 Merge pull request #40 from gbj/server-rpc
Merge work on server functions
2022-10-29 20:31:46 -04:00
47 changed files with 900 additions and 801 deletions

View File

@@ -20,7 +20,6 @@ members = [
"examples/counters",
"examples/counters-stable",
"examples/fetch",
"examples/gtk",
"examples/hackernews/hackernews-app",
"examples/hackernews/hackernews-client",
"examples/hackernews/hackernews-server",
@@ -31,11 +30,13 @@ members = [
"examples/todomvc-ssr/todomvc-ssr-server",
]
exclude = [
"benchmarks"
"benchmarks",
# not gonna lie, this is because my arm64 mac fails when linking a GTK binary
"examples/gtk",
]
[profile.release]
codegen-units = 1
lto = true
opt-level = 'z'
opt-level = 'z'

29
Makefile.toml Normal file
View File

@@ -0,0 +1,29 @@
############
# A make file for cargo-make, please install it with:
# cargo install --force cargo-make
############
[config]
# make tasks run at the workspace root
default_to_workspace = false
[tasks.ci]
dependencies = ["build", "test"]
[tasks.build]
clear = true
dependencies = ["build-all"]
[tasks.build-all]
command = "cargo"
args = ["+nightly", "build-all-features"]
install_crate = "cargo-all-features"
[tasks.test]
clear = true
dependencies = ["test-all"]
[tasks.test-all]
command = "cargo"
args = ["+nightly", "test-all-features"]
install_crate = "cargo-all-features"

View File

@@ -1,5 +0,0 @@
- output type can't always be `i32` (parse it in macro and use)
- need to implement `from_form_data` using `form_urlencoded` and mapping each value to `.from_json()` to allow multiple serde formats (and prevent extra quotes on strings)
- test with a variety of types
- `<Form method="POST">` in `leptos_router`
- import `leptos_router` and use `<Form/>` in this example

View File

@@ -1,4 +1,5 @@
use leptos::*;
use leptos_router::*;
use std::fmt::Debug;
@@ -51,26 +52,36 @@ pub fn Counters(cx: Scope) -> Element {
view! {
cx,
<div>
<h1>"Server-Side Counters"</h1>
<p>"Each of these counters stores its data in the same variable on the server."</p>
<p>"The value is shared across connections. Try opening this is another browser tab to see what I mean."</p>
<div style="display: flex; justify-content: space-around">
<div>
<h2>"Simple Counter"</h2>
<p>"This counter sets the value on the server and automatically reloads the new value."</p>
<Counter/>
</div>
<div>
<h2>"Form Counter"</h2>
<p>"This counter uses forms to set the value on the server. When progressively enhanced, it should behave identically to the “Simple Counter.”"</p>
<FormCounter/>
</div>
<div>
<h2>"Multi-User Counter"</h2>
<p>"This one uses server-sent events (SSE) to live-update when other users make changes."</p>
<MultiuserCounter/>
</div>
</div>
<Router>
<header>
<h1>"Server-Side Counters"</h1>
<p>"Each of these counters stores its data in the same variable on the server."</p>
<p>"The value is shared across connections. Try opening this is another browser tab to see what I mean."</p>
</header>
<nav>
<ul>
<li><A href="">"Simple"</A></li>
<li><A href="form">"Form-Based"</A></li>
<li><A href="multi">"Multi-User"</A></li>
</ul>
</nav>
<main>
<Routes>
<Route path="" element=|cx| view! {
cx,
<Counter/>
}/>
<Route path="form" element=|cx| view! {
cx,
<FormCounter/>
}/>
<Route path="multi" element=|cx| view! {
cx,
<MultiuserCounter/>
}/>
</Routes>
</main>
</Router>
</div>
}
}
@@ -81,9 +92,9 @@ pub fn Counters(cx: Scope) -> Element {
// This is the typical pattern for a CRUD app
#[component]
pub fn Counter(cx: Scope) -> Element {
let dec = create_action(cx, || adjust_server_count(-1, "decing".into()));
let inc = create_action(cx, || adjust_server_count(1, "incing".into()));
let clear = create_action(cx, clear_server_count);
let dec = create_action(cx, |_| adjust_server_count(-1, "decing".into()));
let inc = create_action(cx, |_| adjust_server_count(1, "incing".into()));
let clear = create_action(cx, |_| clear_server_count());
let counter = create_resource(
cx,
move || (dec.version.get(), inc.version.get(), clear.version.get()),
@@ -104,10 +115,14 @@ pub fn Counter(cx: Scope) -> Element {
view! {
cx,
<div>
<button on:click=move |_| clear.dispatch()>"Clear"</button>
<button on:click=move |_| dec.dispatch()>"-1"</button>
<span>"Value: " {move || value().to_string()} "!"</span>
<button on:click=move |_| inc.dispatch()>"+1"</button>
<h2>"Simple Counter"</h2>
<p>"This counter sets the value on the server and automatically reloads the new value."</p>
<div>
<button on:click=move |_| clear.dispatch(())>"Clear"</button>
<button on:click=move |_| dec.dispatch(())>"-1"</button>
<span>"Value: " {move || value().to_string()} "!"</span>
<button on:click=move |_| inc.dispatch(())>"+1"</button>
</div>
</div>
}
}
@@ -117,9 +132,16 @@ pub fn Counter(cx: Scope) -> Element {
// but uses HTML forms to submit the actions
#[component]
pub fn FormCounter(cx: Scope) -> Element {
let adjust = create_server_action::<AdjustServerCount>(cx);
let clear = create_server_action::<ClearServerCount>(cx);
let counter = create_resource(
cx,
move || (),
{
let adjust = adjust.version;
let clear = clear.version;
move || (adjust.get(), clear.get())
},
|_| {
log::debug!("FormCounter running fetcher");
@@ -136,27 +158,33 @@ pub fn FormCounter(cx: Scope) -> Element {
.unwrap_or(0)
};
let adjust2 = adjust.clone();
view! {
cx,
<div>
// calling a server function is the same as POSTing to its API URL
// so we can just do that with a form and button
<form method="POST" action=ClearServerCount::url()>
<input type="submit" value="Clear"/>
</form>
// We can submit named arguments to the server functions
// by including them as input values with the same name
<form method="POST" action=AdjustServerCount::url()>
<input type="hidden" name="delta" value="-1"/>
<input type="hidden" name="msg" value="\"form value down\""/>
<input type="submit" value="-1"/>
</form>
<span>"Value: " {move || value().to_string()} "!"</span>
<form method="POST" action=AdjustServerCount::url()>
<input type="hidden" name="delta" value="1"/>
<input type="hidden" name="msg" value="\"form value up\""/>
<input type="submit" value="+1"/>
</form>
<h2>"Form Counter"</h2>
<p>"This counter uses forms to set the value on the server. When progressively enhanced, it should behave identically to the “Simple Counter.”"</p>
<div>
// calling a server function is the same as POSTing to its API URL
// so we can just do that with a form and button
<ActionForm action=clear>
<input type="submit" value="Clear"/>
</ActionForm>
// We can submit named arguments to the server functions
// by including them as input values with the same name
<ActionForm action=adjust>
<input type="hidden" name="delta" value="-1"/>
<input type="hidden" name="msg" value="\"form value down\""/>
<input type="submit" value="-1"/>
</ActionForm>
<span>"Value: " {move || value().to_string()} "!"</span>
<ActionForm action=adjust2>
<input type="hidden" name="delta" value="1"/>
<input type="hidden" name="msg" value="\"form value up\""/>
<input type="submit" value="+1"/>
</ActionForm>
</div>
</div>
}
}
@@ -167,9 +195,9 @@ pub fn FormCounter(cx: Scope) -> Element {
// This is the primitive pattern for live chat, collaborative editing, etc.
#[component]
pub fn MultiuserCounter(cx: Scope) -> Element {
let dec = create_action(cx, || adjust_server_count(-1, "dec dec goose".into()));
let inc = create_action(cx, || adjust_server_count(1, "inc inc moose".into()));
let clear = create_action(cx, clear_server_count);
let dec = create_action(cx, |_| adjust_server_count(-1, "dec dec goose".into()));
let inc = create_action(cx, |_| adjust_server_count(1, "inc inc moose".into()));
let clear = create_action(cx, |_| clear_server_count());
#[cfg(not(feature = "ssr"))]
let multiplayer_value = {
@@ -200,10 +228,14 @@ pub fn MultiuserCounter(cx: Scope) -> Element {
view! {
cx,
<div>
<button on:click=move |_| clear.dispatch()>"Clear"</button>
<button on:click=move |_| dec.dispatch()>"-1"</button>
<span>"Multiplayer Value: " {move || multiplayer_value().unwrap_or_default().to_string()}</span>
<button on:click=move |_| inc.dispatch()>"+1"</button>
<h2>"Multi-User Counter"</h2>
<p>"This one uses server-sent events (SSE) to live-update when other users make changes."</p>
<div>
<button on:click=move |_| clear.dispatch(())>"Clear"</button>
<button on:click=move |_| dec.dispatch(())>"-1"</button>
<span>"Multiplayer Value: " {move || multiplayer_value().unwrap_or_default().to_string()}</span>
<button on:click=move |_| inc.dispatch(())>"+1"</button>
</div>
</div>
}
}

View File

@@ -8,5 +8,6 @@ actix-files = "0.6"
actix-web = { version = "4" }
futures = "0.3"
leptos = { path = "../../../leptos", default-features = false, features = ["ssr", "serde"] }
leptos_router = { path = "../../../router", default-features = false, features = ["ssr"] }
counter-isomorphic = { path = "../counter", default-features = false, features = ["ssr"] }
lazy_static = "1"

View File

@@ -2,9 +2,14 @@ use actix_files::Files;
use actix_web::*;
use counter_isomorphic::*;
use leptos::*;
use leptos_router::*;
#[get("{tail:.*}")]
async fn render(req: HttpRequest) -> impl Responder {
let path = req.path();
let path = "http://leptos".to_string() + path;
println!("path = {path}");
#[get("/")]
async fn render() -> impl Responder {
HttpResponse::Ok().content_type("text/html").body(format!(
r#"<!DOCTYPE html>
<html lang="en">
@@ -19,7 +24,10 @@ async fn render() -> impl Responder {
<script type="module">import init, {{ main }} from './pkg/counter_client.js'; init().then(main);</script>
</html>"#,
run_scope({
|cx| {
move |cx| {
let integration = ServerIntegration { path: path.clone() };
provide_context(cx, RouterIntegrationContext::new(integration));
view! { cx, <Counters/>}
}
})
@@ -87,10 +95,10 @@ async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.service(render)
.service(Files::new("/pkg", "../client/pkg"))
.service(counter_events)
.service(handle_server_fns)
.service(render)
//.wrap(middleware::Compress::default())
})
.bind(("127.0.0.1", 8081))?

View File

@@ -1,111 +0,0 @@
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
use leptos::{wasm_bindgen::JsValue, *};
use web_sys::HtmlElement;
use counters::{Counters, CountersProps};
#[wasm_bindgen_test]
fn inc() {
mount_to_body(|cx| view! { cx, <Counters/> });
let document = leptos::document();
let div = document.query_selector("div").unwrap().unwrap();
let add_counter = div
.first_child()
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap();
// add 3 counters
add_counter.click();
add_counter.click();
add_counter.click();
// check HTML
assert_eq!(div.inner_html(), "<button>Add Counter</button><button>Add 1000 Counters</button><button>Clear Counters</button><p>Total: <span>0</span> from <span>3</span> counters.</p><ul><li><button>-1</button><input type=\"text\"><span>0</span><button>+1</button><button>x</button></li><li><button>-1</button><input type=\"text\"><span>0</span><button>+1</button><button>x</button></li><li><button>-1</button><input type=\"text\"><span>0</span><button>+1</button><button>x</button></li></ul>");
let counters = div
.query_selector("ul")
.unwrap()
.unwrap()
.unchecked_into::<HtmlElement>()
.children();
// click first counter once, second counter twice, etc.
// `NodeList` isn't a `Vec` so we iterate over it in this slightly awkward way
for idx in 0..counters.length() {
let counter = counters.item(idx).unwrap();
let inc_button = counter
.first_child()
.unwrap()
.next_sibling()
.unwrap()
.next_sibling()
.unwrap()
.next_sibling()
.unwrap()
.unchecked_into::<HtmlElement>();
for _ in 0..=idx {
inc_button.click();
}
}
assert_eq!(div.inner_html(), "<button>Add Counter</button><button>Add 1000 Counters</button><button>Clear Counters</button><p>Total: <span>6</span> from <span>3</span> counters.</p><ul><li><button>-1</button><input type=\"text\"><span>1</span><button>+1</button><button>x</button></li><li><button>-1</button><input type=\"text\"><span>2</span><button>+1</button><button>x</button></li><li><button>-1</button><input type=\"text\"><span>3</span><button>+1</button><button>x</button></li></ul>");
// remove the first counter
counters
.item(0)
.unwrap()
.last_child()
.unwrap()
.unchecked_into::<HtmlElement>()
.click();
assert_eq!(div.inner_html(), "<button>Add Counter</button><button>Add 1000 Counters</button><button>Clear Counters</button><p>Total: <span>5</span> from <span>2</span> counters.</p><ul><li><button>-1</button><input type=\"text\"><span>2</span><button>+1</button><button>x</button></li><li><button>-1</button><input type=\"text\"><span>3</span><button>+1</button><button>x</button></li></ul>");
// decrement all by 1
for idx in 0..counters.length() {
let counter = counters.item(idx).unwrap();
let dec_button = counter
.first_child()
.unwrap()
.unchecked_into::<HtmlElement>();
dec_button.click();
}
// we can use RSX in test comparisons!
// note that if RSX template creation is bugged, this probably won't catch it
// (because the same bug will be reproduced in both sides of the assertion)
// so I use HTML tests for most internal testing like this
// but in user-land testing, RSX comparanda are cool
assert_eq!(
div.outer_html(),
view! { cx,
<div>
<button>"Add Counter"</button>
<button>"Add 1000 Counters"</button>
<button>"Clear Counters"</button>
<p>"Total: "<span>"3"</span>" from "<span>"2"</span>" counters."</p>
<ul>
<li>
<button>"-1"</button>
<input type="text"/>
<span>"1"</span>
<button>"+1"</button>
<button>"x"</button>
</li>
<li>
<button>"-1"</button>
<input type="text"/>
<span>"2"</span>
<button>"+1"</button>
<button>"x"</button>
</li>
</ul>
</div>
}
.outer_html()
);
}

View File

@@ -6,12 +6,14 @@ edition = "2021"
[dependencies]
anyhow = "1"
console_log = "0.2"
leptos = { path = "../../../leptos", default-features = false, features = ["serde"] }
leptos = { path = "../../../leptos", default-features = false, features = [
"serde",
] }
leptos_meta = { path = "../../../meta", default-features = false }
leptos_router = { path = "../../../router", default-features = false }
log = "0.4"
gloo-net = { version = "0.2", features = ["http"] }
reqwest = { version = "0.11", features = ["json"], optional = true }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
console_error_panic_hook = "0.1.7"
@@ -21,4 +23,4 @@ console_error_panic_hook = "0.1.7"
default = ["csr"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = ["leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "dep:reqwest"]
ssr = ["leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr"]

View File

@@ -7,28 +7,6 @@ use leptos_meta::*;
use leptos_router::*;
use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod};
#[derive(Copy, Clone, Debug)]
struct ActixIntegration {
path: ReadSignal<String>,
}
impl History for ActixIntegration {
fn location(&self, cx: leptos::Scope) -> ReadSignal<LocationChange> {
create_signal(
cx,
LocationChange {
value: self.path.get(),
replace: false,
scroll: true,
state: State(None),
},
)
.0
}
fn navigate(&self, _loc: &LocationChange) {}
}
#[get("/static/style.css")]
async fn css() -> impl Responder {
NamedFile::open_async("../hackernews-app/style.css").await
@@ -47,10 +25,8 @@ async fn render_app(req: HttpRequest) -> impl Responder {
};
let app = move |cx| {
let integration = ActixIntegration {
path: create_signal(cx, path.clone()).0,
};
provide_context(cx, RouterIntegrationContext(std::rc::Rc::new(integration)));
let integration = ServerIntegration { path: path.clone() };
provide_context(cx, RouterIntegrationContext::new(integration));
view! { cx, <App/> }
};

View File

@@ -289,7 +289,7 @@ pub fn Todo(cx: Scope, todo: Todo) -> Element {
</li>
};
#[cfg(not(feature = "ssr"))]
#[cfg(any(feature = "csr", feature = "hydrate"))]
create_effect(cx, move |_| {
if editing() {
_ = input.unchecked_ref::<HtmlInputElement>().focus();

View File

@@ -17,10 +17,37 @@ leptos_server = { path = "../leptos_server", default-features = false, version =
[features]
default = ["csr", "serde"]
csr = ["leptos_core/csr", "leptos_dom/csr", "leptos_macro/csr", "leptos_reactive/csr", "leptos_server/csr"]
hydrate = ["leptos_core/hydrate", "leptos_dom/hydrate", "leptos_macro/hydrate", "leptos_reactive/hydrate", "leptos_server/hydrate"]
ssr = ["leptos_core/ssr", "leptos_dom/ssr", "leptos_macro/ssr", "leptos_reactive/ssr", "leptos_server/ssr"]
stable = ["leptos_core/stable", "leptos_dom/stable", "leptos_macro/stable", "leptos_reactive/stable", "leptos_server/stable"]
csr = [
"leptos_core/csr",
"leptos_dom/csr",
"leptos_macro/csr",
"leptos_reactive/csr",
"leptos_server/csr",
]
hydrate = [
"leptos_core/hydrate",
"leptos_dom/hydrate",
"leptos_macro/hydrate",
"leptos_reactive/hydrate",
"leptos_server/hydrate",
]
ssr = [
"leptos_core/ssr",
"leptos_dom/ssr",
"leptos_macro/ssr",
"leptos_reactive/ssr",
"leptos_server/ssr",
]
stable = [
"leptos_core/stable",
"leptos_dom/stable",
"leptos_macro/stable",
"leptos_reactive/stable",
"leptos_server/stable",
]
serde = ["leptos_reactive/serde"]
serde-lite = ["leptos_reactive/serde-lite"]
miniserde = ["leptos_reactive/miniserde"]
miniserde = ["leptos_reactive/miniserde"]
[package.metadata.cargo-all-features]
denylist = ["stable"]

View File

@@ -89,7 +89,7 @@
//!
//! // create event handlers for our buttons
//! // note that `value` and `set_value` are `Copy`, so it's super easy to move them into closures
//! let clear = move |_| set_value(0);
//! let clear = move |_| set_value.set(0);
//! let decrement = move |_| set_value.update(|value| *value -= 1);
//! let increment = move |_| set_value.update(|value| *value += 1);
//!
@@ -123,15 +123,3 @@ pub use leptos_server;
pub use leptos_server::*;
pub use leptos_reactive::debug_warn;
#[cfg(not(any(feature = "csr", feature = "ssr", feature = "hydrate")))]
compile_error!("set one of the following feature flags: 'csr', 'ssr' or 'hydrate'");
#[cfg(all(feature = "csr", feature = "ssr"))]
compile_error!("leptos features 'csr' and feature 'ssr' cannot be enabled at the same time");
#[cfg(all(feature = "csr", feature = "hydrate"))]
compile_error!("leptos features 'csr' and feature 'hydrate' cannot be enabled at the same time");
#[cfg(all(feature = "hydrate", feature = "ssr"))]
compile_error!("leptos features 'hydrate' and feature 'ssr' cannot be enabled at the same time");

View File

@@ -1,4 +1,4 @@
#[cfg(feature = "ssr")]
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
#[test]
fn simple_ssr_test() {
use leptos_dom::*;
@@ -23,7 +23,7 @@ fn simple_ssr_test() {
});
}
#[cfg(feature = "ssr")]
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
#[test]
fn ssr_test_with_components() {
use leptos_core as leptos;
@@ -61,7 +61,7 @@ fn ssr_test_with_components() {
});
}
#[cfg(feature = "ssr")]
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
#[test]
fn test_classes() {
use leptos_dom::*;

View File

@@ -15,6 +15,13 @@ log = "0.4"
[features]
csr = ["leptos_dom/csr", "leptos_macro/csr", "leptos_reactive/csr"]
hydrate = ["leptos_dom/hydrate", "leptos_macro/hydrate", "leptos_reactive/hydrate"]
hydrate = [
"leptos_dom/hydrate",
"leptos_macro/hydrate",
"leptos_reactive/hydrate",
]
ssr = ["leptos_dom/ssr", "leptos_macro/ssr", "leptos_reactive/ssr"]
stable = ["leptos_dom/stable", "leptos_macro/stable", "leptos_reactive/stable"]
stable = ["leptos_dom/stable", "leptos_macro/stable", "leptos_reactive/stable"]
[package.metadata.cargo-all-features]
denylist = ["stable"]

View File

@@ -1,15 +1,9 @@
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
mod for_component;
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
mod map;
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
mod suspense;
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
pub use for_component::*;
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
pub use map::*;
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
pub use suspense::*;
pub trait Prop {

View File

@@ -160,25 +160,28 @@ mod tests {
#[test]
fn test_map_keyed() {
create_scope(|cx| {
let (rows, set_rows) =
create_signal::<Vec<(usize, ReadSignal<i32>, WriteSignal<i32>)>>(cx, vec![]);
// we can really only run this in SSR mode, so just ignore if we're in CSR or hydrate
if !cfg!(any(feature = "csr", feature = "hydrate")) {
create_scope(|cx| {
let (rows, set_rows) =
create_signal::<Vec<(usize, ReadSignal<i32>, WriteSignal<i32>)>>(cx, vec![]);
let keyed = map_keyed(
cx,
rows,
|cx, row| {
let read = row.1;
create_effect(cx, move |_| println!("row value = {}", read.get()));
},
|row| row.0,
);
let keyed = map_keyed(
cx,
rows,
|cx, row| {
let read = row.1;
create_effect(cx, move |_| println!("row value = {}", read.get()));
},
|row| row.0,
);
create_effect(cx, move |_| println!("keyed = {:#?}", keyed.get()));
create_effect(cx, move |_| println!("keyed = {:#?}", keyed.get()));
let (r, w) = create_signal(cx, 0);
set_rows.update(|n| n.push((0, r, w)));
})
.dispose();
let (r, w) = create_signal(cx, 0);
set_rows.update(|n| n.push((0, r, w)));
})
.dispose();
}
}
}

View File

@@ -15,7 +15,6 @@ where
}
#[allow(non_snake_case)]
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
pub fn Suspense<F, E, G>(cx: Scope, props: SuspenseProps<F, E, G>) -> impl Fn() -> Child
where
F: IntoChild + Clone,
@@ -32,17 +31,7 @@ where
render_suspense(cx, context, props.fallback.clone(), child)
}
#[cfg(not(any(feature = "csr", feature = "hydrate", feature = "ssr")))]
pub fn Suspense<F, E, G>(cx: Scope, props: SuspenseProps<F, E, G>) -> impl Fn() -> Child
where
F: IntoChild + Clone,
E: IntoChild,
G: Fn() -> E + 'static,
{
compile_error!("<Suspense/> can only be used when one of the following features is set on the `leptos` package: 'csr', 'ssr', or 'hydrate'");
}
#[cfg(not(feature = "ssr"))]
#[cfg(any(feature = "csr", feature = "hydrate"))]
fn render_suspense<'a, F, E, G>(
cx: Scope,
context: SuspenseContext,
@@ -69,7 +58,7 @@ where
}
}
#[cfg(feature = "ssr")]
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
fn render_suspense<'a, F, E, G>(
cx: Scope,
context: SuspenseContext,

View File

@@ -8,11 +8,12 @@ repository = "https://github.com/gbj/leptos"
description = "DOM operations for the Leptos web framework."
[dependencies]
futures = { version = "0.3", optional = true }
html-escape = { version = "0.2", optional = true }
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" }
serde_json = { version = "1", optional = true }
serde_json = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4.31"
log = "0.4"
@@ -51,11 +52,11 @@ features = [
"Storage",
"Text",
"TreeWalker",
"Window"
"Window",
]
[features]
csr = ["leptos_reactive/csr"]
hydrate = ["leptos_reactive/hydrate"]
ssr = ["leptos_reactive/ssr", "dep:futures", "dep:html-escape", "dep:serde_json"]
stable = ["leptos_reactive/stable"]
ssr = ["leptos_reactive/ssr"]
stable = ["leptos_reactive/stable"]

View File

@@ -1,11 +1,8 @@
use cfg_if::cfg_if;
use std::{cell::RefCell, rc::Rc};
use leptos_reactive::Scope;
#[cfg(feature = "stable")]
use leptos_reactive::{Memo, ReadSignal, RwSignal};
#[cfg(not(feature = "ssr"))]
use wasm_bindgen::JsCast;
use crate::Node;
@@ -19,7 +16,7 @@ pub enum Child {
}
impl Child {
#[cfg(feature = "ssr")]
#[cfg(not(any(feature = "hydrate", feature = "csr")))]
pub fn as_child_string(&self) -> String {
match self {
Child::Null => String::new(),
@@ -83,24 +80,14 @@ impl IntoChild for String {
}
}
#[cfg(not(feature = "ssr"))]
impl IntoChild for web_sys::Node {
fn into_child(self, _cx: Scope) -> Child {
Child::Node(self)
}
}
#[cfg(not(feature = "ssr"))]
impl IntoChild for web_sys::Text {
fn into_child(self, _cx: Scope) -> Child {
Child::Node(self.unchecked_into())
}
}
#[cfg(not(feature = "ssr"))]
impl IntoChild for web_sys::Element {
fn into_child(self, _cx: Scope) -> Child {
Child::Node(self.unchecked_into())
impl<T, U> IntoChild for T
where
T: FnMut() -> U + 'static,
U: IntoChild,
{
fn into_child(mut self, cx: Scope) -> Child {
let modified_fn = Rc::new(RefCell::new(move || (self)().into_child(cx)));
Child::Fn(modified_fn)
}
}
@@ -122,49 +109,6 @@ impl IntoChild for Vec<Node> {
}
}
#[cfg(not(feature = "ssr"))]
impl IntoChild for Vec<web_sys::Element> {
fn into_child(self, _cx: Scope) -> Child {
Child::Nodes(
self.into_iter()
.map(|el| el.unchecked_into::<web_sys::Node>())
.collect(),
)
}
}
#[cfg(all(feature = "stable", not(feature = "ssr")))]
impl IntoChild for Memo<Vec<web_sys::Element>> {
fn into_child(self, cx: Scope) -> Child {
(move || self.get()).into_child(cx)
}
}
#[cfg(all(feature = "stable", not(feature = "ssr")))]
impl IntoChild for ReadSignal<Vec<web_sys::Element>> {
fn into_child(self, cx: Scope) -> Child {
(move || self.get()).into_child(cx)
}
}
#[cfg(all(feature = "stable", not(feature = "ssr")))]
impl IntoChild for RwSignal<Vec<web_sys::Element>> {
fn into_child(self, cx: Scope) -> Child {
(move || self.get()).into_child(cx)
}
}
impl<T, U> IntoChild for T
where
T: FnMut() -> U + 'static,
U: IntoChild,
{
fn into_child(mut self, cx: Scope) -> Child {
let modified_fn = Rc::new(RefCell::new(move || (self)().into_child(cx)));
Child::Fn(modified_fn)
}
}
macro_rules! child_type {
($child_type:ty) => {
impl IntoChild for $child_type {
@@ -193,3 +137,62 @@ child_type!(f32);
child_type!(f64);
child_type!(char);
child_type!(bool);
cfg_if! {
if #[cfg(any(feature = "hydrate", feature = "csr"))] {
use wasm_bindgen::JsCast;
impl IntoChild for web_sys::Node {
fn into_child(self, _cx: Scope) -> Child {
Child::Node(self)
}
}
impl IntoChild for web_sys::Text {
fn into_child(self, _cx: Scope) -> Child {
Child::Node(self.unchecked_into())
}
}
impl IntoChild for web_sys::Element {
fn into_child(self, _cx: Scope) -> Child {
Child::Node(self.unchecked_into())
}
}
impl IntoChild for Vec<web_sys::Element> {
fn into_child(self, _cx: Scope) -> Child {
Child::Nodes(
self.into_iter()
.map(|el| el.unchecked_into::<web_sys::Node>())
.collect(),
)
}
}
}
}
// `stable` feature
cfg_if! {
if #[cfg(feature = "stable")] {
use leptos_reactive::{Memo, ReadSignal, RwSignal};
impl IntoChild for Memo<Vec<crate::Element>> {
fn into_child(self, cx: Scope) -> Child {
(move || self.get()).into_child(cx)
}
}
impl IntoChild for ReadSignal<Vec<crate::Element>> {
fn into_child(self, cx: Scope) -> Child {
(move || self.get()).into_child(cx)
}
}
impl IntoChild for RwSignal<Vec<crate::Element>> {
fn into_child(self, cx: Scope) -> Child {
(move || self.get()).into_child(cx)
}
}
}
}

View File

@@ -1,63 +1,63 @@
use cfg_if::cfg_if;
pub mod attribute;
pub mod child;
pub mod class;
pub mod event_delegation;
pub mod logging;
#[cfg(not(feature = "ssr"))]
pub mod mount;
pub mod operations;
pub mod property;
#[cfg(not(feature = "ssr"))]
pub mod reconcile;
#[cfg(not(feature = "ssr"))]
pub mod render;
#[cfg(feature = "ssr")]
pub mod render_to_string;
cfg_if! {
// can only include this if we're *only* enabling SSR, as it's the lowest-priority feature
// if either `csr` or `hydrate` is enabled, `Element` is a `web_sys::Element` and can't be rendered
if #[cfg(not(any(feature = "hydrate", feature = "csr")))] {
pub type Element = String;
pub type Node = String;
pub mod render_to_string;
pub use render_to_string::*;
pub struct Marker { }
} else {
pub type Element = web_sys::Element;
pub type Node = web_sys::Node;
pub mod reconcile;
pub mod render;
pub use reconcile::*;
pub use render::*;
}
}
pub use attribute::*;
pub use child::*;
pub use class::*;
pub use logging::*;
#[cfg(not(feature = "ssr"))]
pub use mount::*;
pub use operations::*;
pub use property::*;
#[cfg(not(feature = "ssr"))]
pub use render::*;
#[cfg(feature = "ssr")]
pub use render_to_string::*;
pub use js_sys;
pub use wasm_bindgen;
pub use web_sys;
#[cfg(not(feature = "ssr"))]
pub type Element = web_sys::Element;
#[cfg(feature = "ssr")]
pub type Element = String;
#[cfg(not(feature = "ssr"))]
pub type Node = web_sys::Node;
#[cfg(feature = "ssr")]
pub type Node = String;
use leptos_reactive::Scope;
pub use wasm_bindgen::UnwrapThrowExt;
#[cfg(feature = "csr")]
pub fn create_component<F, T>(cx: Scope, f: F) -> T
where
F: FnOnce() -> T,
{
cx.untrack(f)
}
#[cfg(not(feature = "csr"))]
pub fn create_component<F, T>(cx: Scope, f: F) -> T
where
F: FnOnce() -> T,
{
cx.with_next_context(f)
cfg_if! {
if #[cfg(feature = "csr")] {
cx.untrack(f)
} else {
cx.with_next_context(f)
}
}
}
#[macro_export]

View File

@@ -1,4 +1,5 @@
use crate::{document, Element};
use cfg_if::cfg_if;
use leptos_reactive::Scope;
use wasm_bindgen::UnwrapThrowExt;
@@ -8,14 +9,24 @@ pub trait Mountable {
impl Mountable for Element {
fn mount(&self, parent: &web_sys::Element) {
parent.append_child(self).unwrap_throw();
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
parent.append_child(self).unwrap_throw();
} else {
let _ = parent;
}
}
}
}
impl Mountable for Vec<Element> {
fn mount(&self, parent: &web_sys::Element) {
for element in self {
parent.append_child(element).unwrap_throw();
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
for element in self {
parent.append_child(element).unwrap_throw();
}
}
}
}
}

View File

@@ -1,84 +1,6 @@
use cfg_if::cfg_if;
use std::borrow::Cow;
use leptos_reactive::*;
use crate::Element;
use futures::{stream::FuturesUnordered, Stream, StreamExt};
pub fn render_to_stream(view: impl Fn(Scope) -> Element + 'static) -> impl Stream<Item = String> {
let ((shell, pending_resources, pending_fragments, serializers), _, disposer) =
run_scope_undisposed({
move |cx| {
// the actual app body/template code
// this does NOT contain any of the data being loaded asynchronously in resources
let shell = view(cx);
let resources = cx.all_resources();
let pending_resources = serde_json::to_string(&resources).unwrap();
(
shell,
pending_resources,
cx.pending_fragments(),
cx.serialization_resolvers(),
)
}
});
let fragments = FuturesUnordered::new();
for (fragment_id, fut) in pending_fragments {
fragments.push(async move { (fragment_id, fut.await) })
}
// HTML for the view function and script to store resources
futures::stream::once(async move {
format!(
r#"
{shell}
<script>
__LEPTOS_PENDING_RESOURCES = {pending_resources};
__LEPTOS_RESOLVED_RESOURCES = new Map();
__LEPTOS_RESOURCE_RESOLVERS = new Map();
</script>
"#
)
})
// stream data for each Resource as it resolves
.chain(serializers.map(|(id, json)| {
let id = serde_json::to_string(&id).unwrap();
format!(
r#"<script>
if(__LEPTOS_RESOURCE_RESOLVERS.get({id})) {{
console.log("(create_resource) calling resolver");
__LEPTOS_RESOURCE_RESOLVERS.get({id})({json:?})
}} else {{
console.log("(create_resource) saving data for resource creation");
__LEPTOS_RESOLVED_RESOURCES.set({id}, {json:?});
}}
</script>"#,
)
}))
// stream HTML for each <Suspense/> as it resolves
.chain(fragments.map(|(fragment_id, html)| {
format!(
r#"
<template id="{fragment_id}">{html}</template>
<script>
var frag = document.querySelector(`[data-fragment-id="{fragment_id}"]`);
var tpl = document.getElementById("{fragment_id}");
console.log("replace", frag, "with", tpl.content.cloneNode(true));
frag.replaceWith(tpl.content.cloneNode(true));
</script>
"#
)
}))
// dispose of Scope
.chain(futures::stream::once(async {
disposer.dispose();
Default::default()
}))
}
pub fn escape_text(text: &str) -> Cow<'_, str> {
html_escape::encode_text(text)
}
@@ -86,3 +8,86 @@ pub fn escape_text(text: &str) -> Cow<'_, str> {
pub fn escape_attr(text: &str) -> Cow<'_, str> {
html_escape::encode_double_quoted_attribute(text)
}
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos_reactive::*;
use crate::Element;
use futures::{stream::FuturesUnordered, Stream, StreamExt};
pub fn render_to_stream(view: impl Fn(Scope) -> Element + 'static) -> impl Stream<Item = String> {
let ((shell, pending_resources, pending_fragments, serializers), _, disposer) =
run_scope_undisposed({
move |cx| {
// the actual app body/template code
// this does NOT contain any of the data being loaded asynchronously in resources
let shell = view(cx);
let resources = cx.all_resources();
let pending_resources = serde_json::to_string(&resources).unwrap();
(
shell,
pending_resources,
cx.pending_fragments(),
cx.serialization_resolvers(),
)
}
});
let fragments = FuturesUnordered::new();
for (fragment_id, fut) in pending_fragments {
fragments.push(async move { (fragment_id, fut.await) })
}
// HTML for the view function and script to store resources
futures::stream::once(async move {
format!(
r#"
{shell}
<script>
__LEPTOS_PENDING_RESOURCES = {pending_resources};
__LEPTOS_RESOLVED_RESOURCES = new Map();
__LEPTOS_RESOURCE_RESOLVERS = new Map();
</script>
"#
)
})
// stream data for each Resource as it resolves
.chain(serializers.map(|(id, json)| {
let id = serde_json::to_string(&id).unwrap();
format!(
r#"<script>
if(__LEPTOS_RESOURCE_RESOLVERS.get({id})) {{
console.log("(create_resource) calling resolver");
__LEPTOS_RESOURCE_RESOLVERS.get({id})({json:?})
}} else {{
console.log("(create_resource) saving data for resource creation");
__LEPTOS_RESOLVED_RESOURCES.set({id}, {json:?});
}}
</script>"#,
)
}))
// stream HTML for each <Suspense/> as it resolves
.chain(fragments.map(|(fragment_id, html)| {
format!(
r#"
<template id="{fragment_id}">{html}</template>
<script>
var frag = document.querySelector(`[data-fragment-id="{fragment_id}"]`);
var tpl = document.getElementById("{fragment_id}");
console.log("replace", frag, "with", tpl.content.cloneNode(true));
frag.replaceWith(tpl.content.cloneNode(true));
</script>
"#
)
}))
// dispose of Scope
.chain(futures::stream::once(async {
disposer.dispose();
Default::default()
}))
}
}
}

View File

@@ -29,4 +29,7 @@ default = ["ssr"]
csr = ["leptos_dom/csr", "leptos_reactive/csr"]
hydrate = ["leptos_dom/hydrate", "leptos_reactive/hydrate"]
ssr = ["leptos_dom/ssr", "leptos_reactive/ssr"]
stable = ["leptos_dom/stable", "leptos_reactive/stable"]
stable = ["leptos_dom/stable", "leptos_reactive/stable"]
[package.metadata.cargo-all-features]
denylist = ["stable"]

View File

@@ -13,14 +13,17 @@ pub(crate) enum Mode {
impl Default for Mode {
fn default() -> Self {
if cfg!(feature = "ssr") {
Mode::Ssr
} else if cfg!(feature = "hydrate") {
// what's the deal with this order of priority?
// basically, it's fine for the server to compile wasm-bindgen, but it will panic if it runs it
// for the sake of testing, we need to fall back to `ssr` if no flags are enabled
// if you have `hydrate` enabled, you definitely want that rather than `csr`
// if you have both `csr` and `ssr` we assume you want the browser
if cfg!(feature = "hydrate") {
Mode::Hydrate
} else if cfg!(feature = "csr") {
Mode::Client
} else {
panic!("one of the features leptos/ssr, leptos/hydrate, or leptos/csr needs to be set")
Mode::Ssr
}
}
}
@@ -36,35 +39,46 @@ mod server;
/// same rules as HTML, with the following differences:
/// 1. Text content should be provided as a Rust string, i.e., double-quoted:
/// ```rust
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view;
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
/// # run_scope(|cx| {
/// view! { cx, <p>"Heres some text"</p> }
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// view! { cx, <p>"Heres some text"</p> };
/// # }
/// # });
/// ```
///
/// 2. Self-closing tags need an explicit `/` as in XML/XHTML
/// ```rust,compile_fail
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view;
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
/// # run_scope(|cx| {
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// // ❌ not like this
/// view! { cx, <input type="text" name="name"> }
/// # ;
/// # }
/// # });
/// ```
/// ```rust
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view;
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
/// # run_scope(|cx| {
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// // ✅ add that slash
/// view! { cx, <input type="text" name="name" /> }
/// # ;
/// # }
/// # });
/// ```
///
/// 3. Components (functions annotated with `#[component]`) can be inserted as camel-cased tags
/// ```rust
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::*; use typed_builder::TypedBuilder;
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::*; use typed_builder::TypedBuilder; use leptos_dom::wasm_bindgen::JsCast; use leptos_dom as leptos; use leptos_dom::Marker;
/// # #[derive(TypedBuilder)] struct CounterProps { initial_value: i32 }
/// # fn Counter(cx: Scope, props: CounterProps) -> Element { view! { cx, <p></p>} }
/// # run_scope(|cx| {
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// view! { cx, <div><Counter initial_value=3 /></div> }
/// # ;
/// # }
/// # });
/// ```
///
@@ -73,8 +87,9 @@ mod server;
/// *(“Signal” here means `Fn() -> T` where `T` is the appropriate type for that node: a `String` in case
/// of text nodes, a `bool` for `class:` attributes, etc.)*
/// ```rust
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view;
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast; use leptos_dom as leptos; use leptos_dom::Marker;
/// # run_scope(|cx| {
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// let (count, set_count) = create_signal(cx, 0);
///
/// view! {
@@ -85,13 +100,16 @@ mod server;
/// "Double Count: " {move || count() % 2} // or derive a signal inline
/// </div>
/// }
/// # ;
/// # }
/// # });
/// ```
///
/// 5. Event handlers can be added with `on:` attributes
/// ```rust
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view;
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
/// # run_scope(|cx| {
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// view! {
/// cx,
/// <button on:click=|ev: web_sys::Event| {
@@ -100,14 +118,17 @@ mod server;
/// "Click me"
/// </button>
/// }
/// # ;
/// # }
/// # });
/// ```
///
/// 6. DOM properties can be set with `prop:` attributes, which take any primitive type or `JsValue` (or a signal
/// that returns a primitive or JsValue).
/// ```rust
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view;
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
/// # run_scope(|cx| {
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// let (name, set_name) = create_signal(cx, "Alice".to_string());
///
/// view! {
@@ -120,22 +141,28 @@ mod server;
/// on:click=move |ev| set_name(event_target_value(&ev)) // `event_target_value` is a useful little Leptos helper
/// />
/// }
/// # ;
/// # }
/// # });
/// ```
///
/// 7. Classes can be toggled with `class:` attributes, which take a `bool` (or a signal that returns a `bool`).
/// ```rust
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view;
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
/// # run_scope(|cx| {
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// let (count, set_count) = create_signal(cx, 2);
/// view! { cx, <div class:hidden={move || count() < 3}>"Now you see me, now you dont."</div> }
/// # ;
/// # }
/// # });
/// ```
///
/// Heres a simple example that shows off several of these features, put together
/// ```rust
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::*;
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::*; use leptos_dom as leptos; use leptos_dom::Marker; use leptos_dom::wasm_bindgen::JsCast;
///
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
/// pub fn SimpleCounter(cx: Scope) -> Element {
/// // create a reactive signal with the initial value
/// let (value, set_value) = create_signal(cx, 0);
@@ -157,6 +184,8 @@ mod server;
/// </div>
/// }
/// }
/// # ;
/// # }
/// ```
#[proc_macro]
pub fn view(tokens: TokenStream) -> TokenStream {

View File

@@ -1,11 +1,11 @@
// Credit to Dioxus: https://github.com/DioxusLabs/dioxus/blob/master/packages/core-macro/src/Server.rs
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::{quote, ToTokens, TokenStreamExt};
use proc_macro2::{TokenStream as TokenStream2};
use quote::{quote};
use syn::{
parse::{Parse, ParseStream},
punctuated::Punctuated,
*, token::Type,
*
};
pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Result<TokenStream2> {
@@ -21,7 +21,7 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
FnArg::Receiver(_) => panic!("cannot use receiver types in server function macro"),
FnArg::Typed(t) => t,
};
quote! { pub #f }
quote! { pub #typed_arg }
});
let fn_args = body.inputs.iter().map(|f| {
@@ -29,7 +29,7 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
FnArg::Receiver(_) => panic!("cannot use receiver types in server function macro"),
FnArg::Typed(t) => t,
};
quote! { #f }
quote! { #typed_arg }
});
let fn_args_2 = fn_args.clone();
@@ -75,6 +75,8 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
let field_names_2 = field_names.clone();
let field_names_3 = field_names.clone();
let field_names_4 = field_names.clone();
let field_names_5 = field_names.clone();
let output_arrow = body.output_arrow;
let return_ty = body.return_ty;
@@ -94,11 +96,11 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
};
Ok(quote::quote! {
#[derive(Clone)]
pub struct #struct_name {
#(#fields),*
}
#[async_trait]
impl ServerFn for #struct_name {
type Output = #output_ty;
@@ -120,9 +122,15 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
}
#[cfg(feature = "ssr")]
async fn call_fn(self) -> Result<Self::Output, ServerFnError> {
fn call_fn(self) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Output, ::leptos::ServerFnError>> + Send>> {
let #struct_name { #(#field_names),* } = self;
#fn_name( #(#field_names_2),*).await
Box::pin(async move { #fn_name( #(#field_names_2),*).await })
}
#[cfg(not(feature = "ssr"))]
fn call_fn_client(self) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Output, ::leptos::ServerFnError>>>> {
let #struct_name { #(#field_names_3),* } = self;
Box::pin(async move { #fn_name( #(#field_names_4),*).await })
}
}
@@ -132,10 +140,9 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
}
#[cfg(not(feature = "ssr"))]
#vis async fn #fn_name(#(#fn_args_2),*) #output_arrow #return_ty {
::leptos::call_server_fn(#struct_name::url(), #struct_name { #(#field_names_3),* }).await
::leptos::call_server_fn(#struct_name::url(), #struct_name { #(#field_names_5),* }).await
}
}
.into())
})
}
pub struct ServerFnName {

View File

@@ -10,19 +10,25 @@ description = "Reactive system for the Leptos web framework."
[dependencies]
log = "0.4"
slotmap = { version = "1", features = ["serde"] }
serde = { version = "1", optional = true, features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde-lite = { version = "0.3", optional = true }
futures = { version = "0.3" }
js-sys = { version = "0.3", optional = true }
js-sys = "0.3"
miniserde = { version = "0.1", optional = true }
serde-wasm-bindgen = { version = "0.4", optional = true }
serde_json = { version = "1" }
base64 = { version = "0.13", optional = true }
serde-wasm-bindgen = "0.4"
serde_json = "1"
base64 = "0.13"
thiserror = "1"
tokio = { version = "1", features = ["rt"], optional = true }
wasm-bindgen = { version = "0.2", optional = true }
wasm-bindgen-futures = { version = "0.4", optional = true }
web-sys = { version = "0.3", optional = true, features = ["Element"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"DocumentFragment",
"Element",
"HtmlTemplateElement",
"NodeList",
"Window",
] }
cfg-if = "1.0.0"
[dev-dependencies]
@@ -30,17 +36,13 @@ tokio-test = "0.4"
[features]
default = []
csr = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:web-sys"]
hydrate = [
"dep:base64",
"dep:js-sys",
"dep:serde-wasm-bindgen",
"dep:wasm-bindgen",
"dep:wasm-bindgen-futures",
"dep:web-sys",
]
ssr = ["dep:base64", "dep:tokio"]
csr = []
hydrate = []
ssr = ["dep:tokio"]
stable = []
serde = ["dep:serde"]
serde = []
serde-lite = ["dep:serde-lite"]
miniserde = ["dep:miniserde"]
miniserde = ["dep:miniserde"]
[package.metadata.cargo-all-features]
denylist = ["stable"]

View File

@@ -1,28 +1,18 @@
#[cfg(any(feature = "hydrate", feature = "ssr"))]
use std::collections::HashMap;
#[cfg(feature = "hydrate")]
use std::collections::HashSet;
#[cfg(feature = "ssr")]
use std::{future::Future, pin::Pin};
#[cfg(any(feature = "hydrate"))]
use crate::ResourceId;
use std::{
collections::{HashMap, HashSet},
future::Future,
pin::Pin,
};
#[derive(Default)]
pub struct SharedContext {
#[cfg(feature = "hydrate")]
pub completed: Vec<web_sys::Element>,
#[cfg(feature = "hydrate")]
pub events: Vec<()>,
pub context: Option<HydrationContext>,
#[cfg(feature = "hydrate")]
pub registry: HashMap<String, web_sys::Element>,
#[cfg(feature = "hydrate")]
pub pending_resources: HashSet<ResourceId>,
#[cfg(feature = "hydrate")]
pub resolved_resources: HashMap<ResourceId, String>,
#[cfg(feature = "ssr")]
pub pending_fragments: HashMap<String, Pin<Box<dyn Future<Output = String>>>>,
}
@@ -32,7 +22,6 @@ impl std::fmt::Debug for SharedContext {
}
}
#[cfg(all(feature = "hydrate", not(feature = "ssr")))]
impl PartialEq for SharedContext {
fn eq(&self, other: &Self) -> bool {
self.completed == other.completed
@@ -44,24 +33,10 @@ impl PartialEq for SharedContext {
}
}
#[cfg(feature = "ssr")]
impl PartialEq for SharedContext {
fn eq(&self, other: &Self) -> bool {
self.context == other.context
}
}
#[cfg(not(any(feature = "ssr", feature = "hydrate")))]
impl PartialEq for SharedContext {
fn eq(&self, other: &Self) -> bool {
self.context == other.context
}
}
impl Eq for SharedContext {}
impl SharedContext {
#[cfg(all(feature = "hydrate", not(feature = "ssr")))]
#[cfg(feature = "hydrate")]
pub fn new_with_registry(registry: HashMap<String, web_sys::Element>) -> Self {
let pending_resources = js_sys::Reflect::get(
&web_sys::window().unwrap(),
@@ -91,6 +66,7 @@ impl SharedContext {
registry,
pending_resources,
resolved_resources,
pending_fragments: Default::default(),
}
}
@@ -108,7 +84,6 @@ impl SharedContext {
}
}
#[cfg(feature = "ssr")]
pub fn current_fragment_key(&self) -> String {
if let Some(context) = &self.context {
format!("{}{}f", context.id, context.count)

View File

@@ -100,6 +100,7 @@ where
.try_with(|n| f(n.as_ref().expect("Memo is missing its initial value")))
}
#[cfg(feature = "hydrate")]
pub(crate) fn subscribe(&self) {
self.0.subscribe()
}

View File

@@ -42,6 +42,9 @@ use crate::{
/// let (how_many_cats, set_how_many_cats) = create_signal(cx, 1);
///
/// // create a resource that will refetch whenever `how_many_cats` changes
/// # // `csr`, `hydrate`, and `ssr` all have issues here
/// # // because we're not running in a browser or in Tokio. Let's just ignore it.
/// # if false {
/// let cats = create_resource(cx, how_many_cats, fetch_cat_picture_urls);
///
/// // when we read the signal, it contains either
@@ -52,6 +55,7 @@ use crate::{
/// // when the signal's value changes, the `Resource` will generate and run a new `Future`
/// set_how_many_cats(2);
/// assert_eq!(cats(), Some(vec!["2".to_string()]));
/// # }
/// # }).dispose();
/// ```
pub fn create_resource<S, T, Fu>(
@@ -160,8 +164,10 @@ where
/// ComplicatedUnserializableStruct { }
/// }
///
/// // create the resource that will
/// // create the resource; it will run but not be serialized
/// # if cfg!(not(any(feature = "csr", feature = "hydrate"))) {
/// let result = create_local_resource(cx, move || (), |_| setup_complicated_struct());
/// # }
/// # }).dispose();
/// ```
pub fn create_local_resource<S, T, Fu>(
@@ -555,7 +561,6 @@ where
});
}
#[cfg(feature = "ssr")]
pub fn resource_to_serialization_resolver(
&self,
id: ResourceId,
@@ -579,7 +584,6 @@ pub(crate) enum AnyResource {
pub(crate) trait SerializableResource {
fn as_any(&self) -> &dyn Any;
#[cfg(feature = "ssr")]
fn to_serialization_resolver(
&self,
id: ResourceId,
@@ -595,7 +599,6 @@ where
self
}
#[cfg(feature = "ssr")]
fn to_serialization_resolver(
&self,
id: ResourceId,

View File

@@ -3,16 +3,21 @@ use crate::{
EffectId, Memo, ReadSignal, ResourceId, ResourceState, RwSignal, Scope, ScopeDisposer, ScopeId,
ScopeProperty, SignalId, WriteSignal,
};
use futures::stream::FuturesUnordered;
use slotmap::{SecondaryMap, SlotMap, SparseSecondaryMap};
use std::{
any::{Any, TypeId},
cell::{Cell, RefCell},
collections::{HashMap, HashSet},
fmt::Debug,
future::Future,
marker::PhantomData,
pin::Pin,
rc::Rc,
};
pub(crate) type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;
#[derive(Default)]
pub(crate) struct Runtime {
pub shared_context: RefCell<Option<SharedContext>>,
@@ -177,7 +182,7 @@ impl Runtime {
.insert(AnyResource::Serializable(state))
}
#[cfg(all(feature = "hydrate", not(feature = "ssr")))]
#[cfg(feature = "hydrate")]
pub fn start_hydration(&self, element: &web_sys::Element) {
use wasm_bindgen::{JsCast, UnwrapThrowExt};
@@ -245,13 +250,10 @@ impl Runtime {
.collect()
}
#[cfg(all(feature = "ssr"))]
pub(crate) fn serialization_resolvers(
&self,
) -> futures::stream::futures_unordered::FuturesUnordered<
std::pin::Pin<Box<dyn futures::Future<Output = (ResourceId, String)>>>,
> {
let f = futures::stream::futures_unordered::FuturesUnordered::new();
) -> FuturesUnordered<PinnedFuture<(ResourceId, String)>> {
let f = FuturesUnordered::new();
for (id, resource) in self.resources.borrow().iter() {
if let AnyResource::Serializable(resource) = resource {
f.push(resource.to_serialization_resolver(id));

View File

@@ -1,9 +1,11 @@
#[cfg(feature = "ssr")]
use crate::SuspenseContext;
use cfg_if::cfg_if;
use crate::{hydration::SharedContext, EffectId, ResourceId, Runtime, SignalId};
use crate::{PinnedFuture, SuspenseContext};
use futures::stream::FuturesUnordered;
use std::collections::HashMap;
use std::fmt::Debug;
#[cfg(feature = "ssr")]
use std::{collections::HashMap, future::Future, pin::Pin};
use std::{future::Future, pin::Pin};
#[must_use = "Scope will leak memory if the disposer function is never called"]
/// Creates a child reactive scope and runs the function within it. This is useful for applications
@@ -184,58 +186,58 @@ impl ScopeDisposer {
}
impl Scope {
#[cfg(feature = "hydrate")]
pub fn is_hydrating(&self) -> bool {
self.runtime.shared_context.borrow().is_some()
}
// hydration-specific code
cfg_if! {
if #[cfg(feature = "hydrate")] {
pub fn is_hydrating(&self) -> bool {
self.runtime.shared_context.borrow().is_some()
}
#[cfg(all(feature = "hydrate", not(feature = "ssr")))]
pub fn start_hydration(&self, element: &web_sys::Element) {
self.runtime.start_hydration(element);
}
pub fn start_hydration(&self, element: &web_sys::Element) {
self.runtime.start_hydration(element);
}
#[cfg(feature = "hydrate")]
pub fn end_hydration(&self) {
self.runtime.end_hydration();
}
pub fn end_hydration(&self) {
self.runtime.end_hydration();
}
#[cfg(feature = "hydrate")]
pub fn get_next_element(&self, template: &web_sys::Element) -> web_sys::Element {
//log::debug!("get_next_element");
use wasm_bindgen::{JsCast, UnwrapThrowExt};
pub fn get_next_element(&self, template: &web_sys::Element) -> web_sys::Element {
use wasm_bindgen::{JsCast, UnwrapThrowExt};
let cloned_template = |t: &web_sys::Element| {
let t = t
.unchecked_ref::<web_sys::HtmlTemplateElement>()
.content()
.clone_node_with_deep(true)
.expect_throw("(get_next_element) could not clone template")
.unchecked_into::<web_sys::Element>()
.first_element_child()
.expect_throw("(get_next_element) could not get first child of template");
t
};
let cloned_template = |t: &web_sys::Element| {
let t = t
.unchecked_ref::<web_sys::HtmlTemplateElement>()
.content()
.clone_node_with_deep(true)
.expect_throw("(get_next_element) could not clone template")
.unchecked_into::<web_sys::Element>()
.first_element_child()
.expect_throw("(get_next_element) could not get first child of template");
t
};
if let Some(ref mut shared_context) = &mut *self.runtime.shared_context.borrow_mut() {
if shared_context.context.is_some() {
let key = shared_context.next_hydration_key();
let node = shared_context.registry.remove(&key.to_string());
if let Some(ref mut shared_context) = &mut *self.runtime.shared_context.borrow_mut() {
if shared_context.context.is_some() {
let key = shared_context.next_hydration_key();
let node = shared_context.registry.remove(&key);
//log::debug!("(hy) searching for {key}");
//log::debug!("(hy) searching for {key}");
if let Some(node) = node {
//log::debug!("(hy) found {key}");
shared_context.completed.push(node.clone());
node
if let Some(node) = node {
//log::debug!("(hy) found {key}");
shared_context.completed.push(node.clone());
node
} else {
//log::debug!("(hy) did NOT find {key}");
cloned_template(template)
}
} else {
cloned_template(template)
}
} else {
//log::debug!("(hy) did NOT find {key}");
cloned_template(template)
}
} else {
cloned_template(template)
}
} else {
cloned_template(template)
}
}
@@ -327,17 +329,6 @@ impl Scope {
self.runtime.all_resources()
}
/// Returns IDs for all [Resource](crate::Resource)s found on any scope.
#[cfg(feature = "ssr")]
pub fn serialization_resolvers(
&self,
) -> futures::stream::futures_unordered::FuturesUnordered<
std::pin::Pin<Box<dyn futures::Future<Output = (ResourceId, String)>>>,
> {
self.runtime.serialization_resolvers()
}
#[cfg(feature = "ssr")]
pub fn current_fragment_key(&self) -> String {
self.runtime
.shared_context
@@ -347,7 +338,11 @@ impl Scope {
.unwrap_or_else(|| String::from("0f"))
}
#[cfg(feature = "ssr")]
/// Returns IDs for all [Resource](crate::Resource)s found on any scope.
pub fn serialization_resolvers(&self) -> FuturesUnordered<PinnedFuture<(ResourceId, String)>> {
self.runtime.serialization_resolvers()
}
pub fn register_suspense(
&self,
context: SuspenseContext,
@@ -377,10 +372,9 @@ impl Scope {
}
}
#[cfg(feature = "ssr")]
pub fn pending_fragments(&self) -> HashMap<String, Pin<Box<dyn Future<Output = String>>>> {
if let Some(ref mut shared_context) = *self.runtime.shared_context.borrow_mut() {
std::mem::replace(&mut shared_context.pending_fragments, HashMap::new())
std::mem::take(&mut shared_context.pending_fragments)
} else {
HashMap::new()
}

View File

@@ -11,7 +11,7 @@ use crate::{create_isomorphic_effect, create_signal, ReadSignal, Scope, WriteSig
/// because it reduces them from `O(n)` to `O(1)`.
///
/// ```
/// # use leptos_reactive::{create_effect, create_scope, create_selector, create_signal};
/// # use leptos_reactive::{create_isomorphic_effect, create_scope, create_selector, create_signal};
/// # use std::rc::Rc;
/// # use std::cell::RefCell;
/// # create_scope(|cx| {
@@ -19,7 +19,7 @@ use crate::{create_isomorphic_effect, create_signal, ReadSignal, Scope, WriteSig
/// let is_selected = create_selector(cx, a);
/// let total_notifications = Rc::new(RefCell::new(0));
/// let not = Rc::clone(&total_notifications);
/// create_effect(cx, {let is_selected = is_selected.clone(); move |_| {
/// create_isomorphic_effect(cx, {let is_selected = is_selected.clone(); move |_| {
/// if is_selected(5) {
/// *not.borrow_mut() += 1;
/// }
@@ -91,7 +91,7 @@ where
let (read, _) = subs
.entry(key.clone())
.or_insert_with(|| create_signal(cx, false));
_ = read.try_with(|n| n.clone());
_ = read.try_with(|n| *n);
f(&key, v.borrow().as_ref().unwrap())
}
}

View File

@@ -1,3 +1,4 @@
use cfg_if::cfg_if;
use std::rc::Rc;
use thiserror::Error;
@@ -18,82 +19,63 @@ where
fn from_json(json: &str) -> Result<Self, SerializationError>;
}
#[cfg(all(
feature = "serde",
not(feature = "miniserde"),
not(feature = "serde-lite")
))]
use serde::{de::DeserializeOwned, Serialize};
cfg_if! {
// prefer miniserde if it's chosen
if #[cfg(feature = "miniserde")] {
use miniserde::{json, Deserialize, Serialize};
impl<T> Serializable for T
where
T: Serialize + Deserialize,
{
fn to_json(&self) -> Result<String, SerializationError> {
Ok(json::to_string(&self))
}
fn from_json(json: &str) -> Result<Self, SerializationError> {
json::from_str(&json).map_err(|e| SerializationError::Deserialize(Rc::new(e)))
}
}
#[cfg(all(
feature = "serde",
not(feature = "miniserde"),
not(feature = "serde-lite")
))]
impl<T> Serializable for T
where
T: DeserializeOwned + Serialize,
{
fn to_json(&self) -> Result<String, SerializationError> {
serde_json::to_string(&self).map_err(|e| SerializationError::Serialize(Rc::new(e)))
}
// use serde-lite if enabled
else if #[cfg(feature = "serde-lite")] {
use serde_lite::{Deserialize, Serialize};
fn from_json(json: &str) -> Result<Self, SerializationError> {
serde_json::from_str(&json).map_err(|e| SerializationError::Deserialize(Rc::new(e)))
}
}
#[cfg(all(
feature = "serde-lite",
not(feature = "serde"),
not(feature = "miniserde")
))]
use serde_lite::{Deserialize, Serialize};
#[cfg(all(
feature = "serde-lite",
not(feature = "serde"),
not(feature = "miniserde")
))]
impl<T> Serializable for T
where
T: Serialize + Deserialize,
{
fn to_json(&self) -> Result<String, SerializationError> {
let intermediate = self
.serialize()
.map_err(|e| SerializationError::Serialize(Rc::new(e)))?;
serde_json::to_string(&intermediate).map_err(|e| SerializationError::Serialize(Rc::new(e)))
}
fn from_json(json: &str) -> Result<Self, SerializationError> {
let intermediate =
serde_json::from_str(&json).map_err(|e| SerializationError::Deserialize(Rc::new(e)))?;
Self::deserialize(&intermediate).map_err(|e| SerializationError::Deserialize(Rc::new(e)))
}
}
#[cfg(all(
feature = "miniserde",
not(feature = "serde-lite"),
not(feature = "serde")
))]
use miniserde::{json, Deserialize, Serialize};
#[cfg(all(
feature = "miniserde",
not(feature = "serde-lite"),
not(feature = "serde")
))]
impl<T> Serializable for T
where
T: Serialize + Deserialize,
{
fn to_json(&self) -> Result<String, SerializationError> {
Ok(json::to_string(&self))
}
fn from_json(json: &str) -> Result<Self, SerializationError> {
json::from_str(&json).map_err(|e| SerializationError::Deserialize(Rc::new(e)))
impl<T> Serializable for T
where
T: Serialize + Deserialize,
{
fn to_json(&self) -> Result<String, SerializationError> {
let intermediate = self
.serialize()
.map_err(|e| SerializationError::Serialize(Rc::new(e)))?;
serde_json::to_string(&intermediate).map_err(|e| SerializationError::Serialize(Rc::new(e)))
}
fn from_json(json: &str) -> Result<Self, SerializationError> {
let intermediate =
serde_json::from_str(&json).map_err(|e| SerializationError::Deserialize(Rc::new(e)))?;
Self::deserialize(&intermediate).map_err(|e| SerializationError::Deserialize(Rc::new(e)))
}
}
}
// otherwise, or if serde is chosen, default to serde
else {
use serde::{de::DeserializeOwned, Serialize};
impl<T> Serializable for T
where
T: DeserializeOwned + Serialize,
{
fn to_json(&self) -> Result<String, SerializationError> {
serde_json::to_string(&self).map_err(|e| SerializationError::Serialize(Rc::new(e)))
}
fn from_json(json: &str) -> Result<Self, SerializationError> {
serde_json::from_str(json).map_err(|e| SerializationError::Deserialize(Rc::new(e)))
}
}
}
}

View File

@@ -150,6 +150,7 @@ where
self.id.with_no_subscription(self.runtime, f)
}
#[cfg(feature = "hydrate")]
pub(crate) fn subscribe(&self) {
self.id.subscribe(self.runtime);
}
@@ -477,14 +478,6 @@ where
self.id.with(self.runtime, f)
}
pub(crate) fn with_no_subscription<U>(&self, f: impl FnOnce(&T) -> U) -> U {
self.id.with_no_subscription(self.runtime, f)
}
pub(crate) fn subscribe(&self) {
self.id.subscribe(self.runtime);
}
/// Clones and returns the current value of the signal, and subscribes
/// the running effect to this signal.
/// ```

View File

@@ -1,25 +1,29 @@
use cfg_if::cfg_if;
use std::future::Future;
/// Exposes the [queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask) method
/// in the browser, and simply runs the given function when on the server.
#[cfg(not(target_arch = "wasm32"))]
pub fn queue_microtask(task: impl FnOnce()) {
task();
}
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
/// Exposes the [queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask) method
/// in the browser, and simply runs the given function when on the server.
pub fn queue_microtask(task: impl FnOnce() + 'static) {
microtask(wasm_bindgen::closure::Closure::once_into_js(task));
}
/// Exposes the [queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask) method
/// in the browser, and simply runs the given function when on the server.
#[cfg(target_arch = "wasm32")]
pub fn queue_microtask(task: impl FnOnce() + 'static) {
microtask(wasm_bindgen::closure::Closure::once_into_js(task));
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(
inline_js = "export function microtask(f) { queueMicrotask(f); }"
)]
extern "C" {
fn microtask(task: wasm_bindgen::JsValue);
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[wasm_bindgen::prelude::wasm_bindgen(
inline_js = "export function microtask(f) { queueMicrotask(f); }"
)]
extern "C" {
fn microtask(task: wasm_bindgen::JsValue);
}
} else {
/// Exposes the [queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask) method
/// in the browser, and simply runs the given function when on the server.
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
pub fn queue_microtask(task: impl FnOnce()) {
task();
}
}
}
pub fn spawn_local<F>(fut: F)
@@ -29,11 +33,12 @@ where
cfg_if::cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
wasm_bindgen_futures::spawn_local(fut)
}
else if #[cfg(any(test, doctest))] {
tokio_test::block_on(fut);
} else if #[cfg(feature = "ssr")] {
tokio::task::spawn_local(fut);
} else if #[cfg(any(test, doctest))] {
tokio_test::block_on(fut);
} else {
} else {
futures::executor::block_on(fut)
}
}

View File

@@ -1,21 +1,23 @@
[package]
name = "leptos_server"
version = "0.0.10"
version = "0.0.11"
edition = "2021"
[dependencies]
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.10" }
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.10" }
async-trait = "0.1"
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
form_urlencoded = "1"
gloo-net = { version = "0.2", optional = true }
gloo-net = "0.2"
lazy_static = "1"
linear-map = "1"
serde = { version = "1", features = ["derive"] }
thiserror = "1"
[features]
csr = ["leptos_dom/csr", "leptos_reactive/csr", "dep:gloo-net"]
hydrate = ["leptos_dom/hydrate", "leptos_reactive/hydrate", "dep:gloo-net"]
csr = ["leptos_dom/csr", "leptos_reactive/csr"]
hydrate = ["leptos_dom/hydrate", "leptos_reactive/hydrate"]
ssr = ["leptos_dom/ssr", "leptos_reactive/ssr"]
stable = ["leptos_dom/stable", "leptos_reactive/stable"]
stable = ["leptos_dom/stable", "leptos_reactive/stable"]
[package.metadata.cargo-all-features]
denylist = ["stable"]

View File

@@ -67,10 +67,9 @@
//! signal. This is very useful, as it can be used to invalidate a [Resource](leptos_reactive::Resource)
//! that reads from the same data.
pub use async_trait::async_trait;
pub use form_urlencoded;
use leptos_reactive::*;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde::{Deserialize, Serialize};
use std::{future::Future, pin::Pin, rc::Rc};
use thiserror::Error;
@@ -97,7 +96,6 @@ pub fn server_fn_by_path(path: &str) -> Option<Arc<ServerFnTraitObj>> {
.and_then(|fns| fns.get(path).cloned())
}
#[async_trait]
pub trait ServerFn
where
Self: Sized + 'static,
@@ -111,7 +109,10 @@ where
fn from_form_data(data: &[u8]) -> Result<Self, ServerFnError>;
#[cfg(feature = "ssr")]
async fn call_fn(self) -> Result<Self::Output, ServerFnError>;
fn call_fn(self) -> Pin<Box<dyn Future<Output = Result<Self::Output, ServerFnError>> + Send>>;
#[cfg(not(feature = "ssr"))]
fn call_fn_client(self) -> Pin<Box<dyn Future<Output = Result<Self::Output, ServerFnError>>>>;
#[cfg(feature = "ssr")]
fn register() -> Result<(), ServerFnError> {
@@ -119,7 +120,7 @@ where
// takes a String -> returns its async value
let run_server_fn = Arc::new(|data: &[u8]| {
// decode the args
let value = Self::from_form_data(&data);
let value = Self::from_form_data(data);
Box::pin(async move {
let value = match value {
Ok(v) => v,
@@ -173,15 +174,13 @@ pub enum ServerFnError {
MissingArg(String),
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
pub async fn call_server_fn<T>(url: &str, args: impl ServerFn) -> Result<T, ServerFnError>
where
T: Serializable + Sized,
{
use leptos_dom::*;
let window = window();
let args_form_data = web_sys::FormData::new().expect_throw("could not create FormData");
for (field_name, value) in args.as_form_data().into_iter() {
args_form_data
@@ -202,7 +201,7 @@ where
// check for error status
let status = resp.status();
if status >= 500 && status <= 599 {
if (500..=599).contains(&status) {
return Err(ServerFnError::ServerError(resp.status_text()));
}
@@ -215,22 +214,28 @@ where
}
#[derive(Clone)]
pub struct Action<T>
pub struct Action<I, O>
where
T: 'static,
I: 'static,
O: 'static,
{
pub version: RwSignal<usize>,
value: RwSignal<Option<T>>,
input: RwSignal<Option<I>>,
value: RwSignal<Option<O>>,
pending: RwSignal<bool>,
action_fn: Rc<dyn Fn() -> Pin<Box<dyn Future<Output = T>>>>,
url: Option<&'static str>,
#[allow(clippy::complexity)]
action_fn: Rc<dyn Fn(&I) -> Pin<Box<dyn Future<Output = O>>>>,
}
impl<T> Action<T>
impl<I, O> Action<I, O>
where
T: 'static,
I: 'static,
O: 'static,
{
pub fn invalidator(&self) {
_ = self.version.get();
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
self.url = Some(T::url());
self
}
pub fn pending(&self) -> impl Fn() -> bool {
@@ -238,12 +243,21 @@ where
move || value.with(|val| val.is_some())
}
pub fn value(&self) -> ReadSignal<Option<T>> {
pub fn input(&self) -> ReadSignal<Option<I>> {
self.input.read_only()
}
pub fn value(&self) -> ReadSignal<Option<O>> {
self.value.read_only()
}
pub fn dispatch(&self) {
let fut = (self.action_fn)();
pub fn url(&self) -> Option<&str> {
self.url
}
pub fn dispatch(&self, input: I) {
let fut = (self.action_fn)(&input);
self.input.set(Some(input));
let version = self.version;
let pending = self.pending;
let value = self.value;
@@ -257,24 +271,39 @@ where
}
}
pub fn create_action<T, F, Fu>(cx: Scope, action_fn: F) -> Action<T>
pub fn create_action<I, O, F, Fu>(cx: Scope, action_fn: F) -> Action<I, O>
where
T: 'static,
F: Fn() -> Fu + 'static,
Fu: Future<Output = T> + 'static,
I: 'static,
O: 'static,
F: Fn(&I) -> Fu + 'static,
Fu: Future<Output = O> + 'static,
{
let version = create_rw_signal(cx, 0);
let input = create_rw_signal(cx, None);
let value = create_rw_signal(cx, None);
let pending = create_rw_signal(cx, false);
let action_fn = Rc::new(move || {
let fut = action_fn();
Box::pin(async move { fut.await }) as Pin<Box<dyn Future<Output = T>>>
let action_fn = Rc::new(move |input: &I| {
let fut = action_fn(input);
Box::pin(async move { fut.await }) as Pin<Box<dyn Future<Output = O>>>
});
Action {
version,
url: None,
input,
value,
pending,
action_fn,
}
}
pub fn create_server_action<S>(cx: Scope) -> Action<S, Result<S::Output, ServerFnError>>
where
S: Clone + ServerFn,
{
#[cfg(feature = "ssr")]
let c = |args: &S| S::call_fn(args.clone());
#[cfg(not(feature = "ssr"))]
let c = |args: &S| S::call_fn_client(args.clone());
create_action(cx, c).using_server_fn::<S>()
}

View File

@@ -9,7 +9,9 @@ description = "Router for the Leptos web framework."
[dependencies]
leptos = { path = "../leptos", version = "0.0", default-features = false }
cfg-if = "1"
common_macros = "0.1"
gloo-net = "0.2"
itertools = "0.10"
lazy_static = "1"
linear-map = "1"
@@ -20,9 +22,9 @@ url = { version = "2", optional = true }
urlencoding = "2"
thiserror = "1"
typed-builder = "0.10"
js-sys = { version = "0.3", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
wasm-bindgen-futures = { version = "0.4", optional = true }
js-sys = { version = "0.3" }
wasm-bindgen = { version = "0.2" }
wasm-bindgen-futures = { version = "0.4" }
[dependencies.web-sys]
version = "0.3"
@@ -50,12 +52,11 @@ features = [
]
[features]
default = ["csr"]
csr = ["leptos/csr", "dep:js-sys", "dep:wasm-bindgen"]
hydrate = [
"leptos/hydrate",
"dep:js-sys",
"dep:wasm-bindgen",
"dep:wasm-bindgen-futures",
]
default = []
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
ssr = ["leptos/ssr", "dep:url", "dep:regex"]
[package.metadata.cargo-all-features]
# No need to test optional dependencies as they are enabled by the ssr feature
denylist = ["url", "regex"]

View File

@@ -1,3 +1,5 @@
use std::error::Error;
use leptos::*;
use typed_builder::TypedBuilder;
use wasm_bindgen::JsCast;
@@ -10,11 +12,15 @@ where
A: ToHref + 'static,
{
#[builder(default, setter(strip_option))]
method: Option<String>,
method: Option<&'static str>,
action: A,
#[builder(default, setter(strip_option))]
enctype: Option<String>,
children: Box<dyn Fn() -> Vec<Element>>,
#[builder(default, setter(strip_option))]
version: Option<RwSignal<usize>>,
#[builder(default, setter(strip_option))]
error: Option<RwSignal<Option<Box<dyn Error>>>>,
}
#[allow(non_snake_case)]
@@ -27,8 +33,11 @@ where
action,
enctype,
children,
version,
error,
} = props;
let action_version = version;
let action = use_resolved_path(cx, move || action.to_href()());
let on_submit = move |ev: web_sys::Event| {
@@ -124,22 +133,51 @@ where
},
};
if method == "get" {
let form_data = web_sys::FormData::new_with_form(&form).unwrap_throw();
let params =
web_sys::UrlSearchParams::new_with_str_sequence_sequence(&form_data).unwrap_throw();
let form_data = web_sys::FormData::new_with_form(&form).unwrap_throw();
let params =
web_sys::UrlSearchParams::new_with_str_sequence_sequence(&form_data).unwrap_throw();
let action = use_resolved_path(cx, move || action.clone())
.get()
.unwrap_or_default();
// POST
if method == "post" {
spawn_local(async move {
let res = gloo_net::http::Request::post(&action)
.header("Accept", "application/json")
.header("Content-Type", &enctype)
.body(params)
.send()
.await;
match res {
Err(e) => {
log::error!("<Form/> error while POSTing: {e:#?}");
if let Some(error) = error {
error.set(Some(Box::new(e)));
}
}
Ok(resp) => {
if let Some(version) = action_version {
version.update(|n| *n += 1);
}
if resp.status() == 303 {
if let Some(redirect_url) = resp.headers().get("Location") {
navigate(&redirect_url, Default::default());
}
}
}
}
});
}
// otherwise, GET
else {
let params = params.to_string().as_string().unwrap_or_default();
let action = use_resolved_path(cx, move || action.clone())
.get()
.unwrap_or_default();
navigate(&format!("{action}?{params}"), Default::default());
} else {
// TODO POST
leptos_dom::debug_warn!("<Form/> component: POST not yet implemented");
todo!()
}
};
let children = children();
view! { cx,
<form
method=method
@@ -151,3 +189,38 @@ where
</form>
}
}
#[derive(TypedBuilder)]
pub struct ActionFormProps<I, O>
where
I: 'static,
O: 'static,
{
action: Action<I, O>,
children: Box<dyn Fn() -> Vec<Element>>,
}
#[allow(non_snake_case)]
pub fn ActionForm<I, O>(cx: Scope, props: ActionFormProps<I, O>) -> Element
where
I: 'static,
O: 'static,
{
let action = if let Some(url) = props.action.url() {
format!("/{url}")
} else {
debug_warn!("<ActionForm/> action needs a URL. Either use create_server_action() or Action::using_server_fn().");
"".to_string()
};
let version = props.action.version;
Form(
cx,
FormProps::builder()
.action(action)
.version(version)
.method("post")
.children(props.children)
.build(),
)
}

View File

@@ -1,3 +1,4 @@
use cfg_if::cfg_if;
use leptos::leptos_dom::IntoChild;
use leptos::*;
use typed_builder::TypedBuilder;
@@ -89,14 +90,27 @@ where
}
let child = children.remove(0);
view! { cx,
<a
href=move || href().unwrap_or_default()
prop:state={props.state.map(|s| s.to_js_value())}
prop:replace={props.replace}
aria-current=move || if is_active() { Some("page") } else { None }
>
{child}
</a>
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
view! { cx,
<a
href=move || href().unwrap_or_default()
prop:state={props.state.map(|s| s.to_js_value())}
prop:replace={props.replace}
aria-current=move || if is_active() { Some("page") } else { None }
>
{child}
</a>
}
} else {
view! { cx,
<a
href=move || href().unwrap_or_default()
aria-current=move || if is_active() { Some("page") } else { None }
>
{child}
</a>
}
}
}
}

View File

@@ -1,3 +1,4 @@
use cfg_if::cfg_if;
use std::ops::IndexMut;
use std::{cell::RefCell, rc::Rc};
@@ -72,11 +73,14 @@ impl std::fmt::Debug for RouterContextInner {
impl RouterContext {
pub fn new(cx: Scope, base: Option<&'static str>, fallback: Option<fn() -> Element>) -> Self {
#[cfg(any(feature = "csr", feature = "hydrate"))]
let history = use_context::<RouterIntegrationContext>(cx)
.unwrap_or_else(|| RouterIntegrationContext(Rc::new(crate::BrowserIntegration {})));
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
let history = use_context::<RouterIntegrationContext>(cx).expect("You must call provide_context::<RouterIntegrationContext>(cx, ...) somewhere above the <Router/>.");
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
let history = use_context::<RouterIntegrationContext>(cx)
.unwrap_or_else(|| RouterIntegrationContext(Rc::new(crate::BrowserIntegration {})));
} else {
let history = use_context::<RouterIntegrationContext>(cx).expect("You must call provide_context::<RouterIntegrationContext>(cx, ...) somewhere above the <Router/>.");
}
};
// Any `History` type gives a way to get a reactive signal of the current location
// in the browser context, this is drawn from the `popstate` event
@@ -143,7 +147,7 @@ impl RouterContext {
});
// handle all click events on anchor tags
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
leptos_dom::window_event_listener("click", {
let inner = Rc::clone(&inner);
move |ev| inner.clone().handle_anchor_click(ev)
@@ -253,7 +257,7 @@ impl RouterContextInner {
}
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
pub(crate) fn handle_anchor_click(self: Rc<Self>, ev: web_sys::Event) {
let ev = ev.unchecked_into::<web_sys::MouseEvent>();
if ev.default_prevented()

View File

@@ -184,7 +184,7 @@ impl std::fmt::Debug for Loader {
}
}
#[cfg(feature = "ssr")]
#[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);

View File

@@ -1,3 +1,5 @@
use std::rc::Rc;
use leptos::*;
mod location;
@@ -22,11 +24,9 @@ pub trait History {
fn navigate(&self, loc: &LocationChange);
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct BrowserIntegration {}
#[cfg(any(feature = "csr", feature = "hydrate"))]
impl BrowserIntegration {
fn current() -> LocationChange {
let loc = leptos_dom::location();
@@ -41,7 +41,6 @@ impl BrowserIntegration {
}
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
impl History for BrowserIntegration {
fn location(&self, cx: Scope) -> ReadSignal<LocationChange> {
use crate::{NavigateOptions, RouterContext};
@@ -107,7 +106,13 @@ impl History for BrowserIntegration {
}
#[derive(Clone)]
pub struct RouterIntegrationContext(pub std::rc::Rc<dyn History>);
pub struct RouterIntegrationContext(pub Rc<dyn History>);
impl RouterIntegrationContext {
pub fn new(history: impl History + 'static) -> Self {
Self(Rc::new(history))
}
}
impl History for RouterIntegrationContext {
fn location(&self, cx: Scope) -> ReadSignal<LocationChange> {
@@ -118,3 +123,25 @@ impl History for RouterIntegrationContext {
self.0.navigate(loc)
}
}
#[derive(Clone, Debug)]
pub struct ServerIntegration {
pub path: String,
}
impl History for ServerIntegration {
fn location(&self, cx: leptos::Scope) -> ReadSignal<LocationChange> {
create_signal(
cx,
LocationChange {
value: self.path.clone(),
replace: false,
scroll: true,
state: State(None),
},
)
.0
}
fn navigate(&self, _loc: &LocationChange) {}
}

View File

@@ -1,16 +1,9 @@
#[cfg(not(feature = "ssr"))]
use leptos::wasm_bindgen::JsValue;
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg(not(feature = "ssr"))]
pub struct State(pub Option<JsValue>);
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg(feature = "ssr")]
pub struct State(pub Option<()>);
impl State {
#[cfg(not(feature = "ssr"))]
pub fn to_js_value(&self) -> JsValue {
match &self.0 {
Some(v) => v.clone(),
@@ -19,7 +12,6 @@ impl State {
}
}
#[cfg(not(feature = "ssr"))]
impl<T> From<T> for State
where
T: Into<JsValue>,

View File

@@ -36,7 +36,7 @@ pub fn unescape(s: &str) -> String {
.replace('+', " ")
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
pub fn unescape(s: &str) -> String {
js_sys::decode_uri(s).unwrap().into()
}
@@ -46,12 +46,12 @@ pub fn escape(s: &str) -> String {
urlencoding::encode(s).into()
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
pub fn escape(s: &str) -> String {
js_sys::encode_uri(s).as_string().unwrap()
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
impl TryFrom<&str> for Url {
type Error = String;

View File

@@ -1,7 +1,7 @@
use std::borrow::Cow;
#[doc(hidden)]
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
pub fn expand_optionals(pattern: &str) -> Vec<Cow<str>> {
use wasm_bindgen::JsValue;

View File

@@ -44,7 +44,7 @@ fn has_scheme(path: &str) -> bool {
HAS_SCHEME_RE.is_match(path)
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
fn has_scheme(path: &str) -> bool {
let re = js_sys::RegExp::new(HAS_SCHEME, "");
re.test(path)
@@ -75,7 +75,7 @@ const BEGINS_WITH_QUERY_OR_HASH: &str = r#"^[?#]"#;
const HAS_SCHEME: &str = r#"^(?:[a-z0-9]+:)?//"#;
const QUERY: &str = r#"/*(\*.*)?$"#;
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
fn replace_trim_path<'a>(text: &'a str, replace: &str) -> Cow<'a, str> {
let re = js_sys::RegExp::new(TRIM_PATH, "g");
js_sys::JsString::from(text)
@@ -85,13 +85,13 @@ fn replace_trim_path<'a>(text: &'a str, replace: &str) -> Cow<'a, str> {
.into()
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
fn begins_with_query_or_hash(text: &str) -> bool {
let re = js_sys::RegExp::new(BEGINS_WITH_QUERY_OR_HASH, "");
re.test(text)
}
#[cfg(any(feature = "csr", feature = "hydrate"))]
#[cfg(not(feature = "ssr"))]
fn replace_query(text: &str) -> String {
let re = js_sys::RegExp::new(QUERY, "g");
js_sys::JsString::from(text)