mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-28 11:21:55 -05:00
Compare commits
58 Commits
remove-mut
...
todomvc-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f093a24d1a | ||
|
|
07e6b361e1 | ||
|
|
75354517bf | ||
|
|
01807ea514 | ||
|
|
e3c1291942 | ||
|
|
3d0a8f574e | ||
|
|
3730427789 | ||
|
|
f94be99246 | ||
|
|
7b7ff492fc | ||
|
|
ba158bbd0f | ||
|
|
7352151744 | ||
|
|
3ad2129a4c | ||
|
|
4e4b513c1b | ||
|
|
21d73463b0 | ||
|
|
d5554082f9 | ||
|
|
92f4ea5888 | ||
|
|
6b82a37dea | ||
|
|
9edd8a3c74 | ||
|
|
33fdc3eae1 | ||
|
|
10e01bf989 | ||
|
|
49820ccba6 | ||
|
|
36be004ef2 | ||
|
|
b9ca0b11a2 | ||
|
|
296e27cd4a | ||
|
|
fd3443b129 | ||
|
|
aa3dd356c1 | ||
|
|
35ca30fbab | ||
|
|
132f0839c6 | ||
|
|
e9c1799a11 | ||
|
|
4577313cca | ||
|
|
f75d49fe4c | ||
|
|
6a38375c66 | ||
|
|
f9f4fb0fef | ||
|
|
42cd3f1d69 | ||
|
|
ade2eda26d | ||
|
|
680b6ecc20 | ||
|
|
4e9d0354c6 | ||
|
|
cccd7068f9 | ||
|
|
8f56a52615 | ||
|
|
6c04e91088 | ||
|
|
9ef350c2d6 | ||
|
|
f559d47714 | ||
|
|
2483616d0d | ||
|
|
2595ffe10e | ||
|
|
221cdf2685 | ||
|
|
1cb278f520 | ||
|
|
5cfd44474d | ||
|
|
bd652ec542 | ||
|
|
d8852f909e | ||
|
|
e16cc4fc4a | ||
|
|
d5e3661bcf | ||
|
|
8873ddc40a | ||
|
|
b7e2e983f0 | ||
|
|
3701f65693 | ||
|
|
a5712d3e17 | ||
|
|
4fba035f19 | ||
|
|
47fad9a042 | ||
|
|
c8545f47cb |
51
.github/workflows/test.yml
vendored
Normal file
51
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test on ${{ matrix.os }} (using rustc ${{ matrix.rust }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
rust:
|
||||
- nightly
|
||||
os:
|
||||
- ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
override: true
|
||||
|
||||
- name: Setup cargo-make
|
||||
uses: davidB/rust-cargo-make@v1
|
||||
|
||||
- name: Cargo generate-lockfile
|
||||
run: cargo generate-lockfile
|
||||
|
||||
- name: Cargo cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
target/
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Run tests with all features
|
||||
run: cargo make ci
|
||||
|
||||
@@ -26,8 +26,6 @@ members = [
|
||||
"examples/parent-child",
|
||||
"examples/router",
|
||||
"examples/todomvc",
|
||||
"examples/todomvc-ssr/todomvc-ssr-client",
|
||||
"examples/todomvc-ssr/todomvc-ssr-server",
|
||||
]
|
||||
exclude = [
|
||||
"benchmarks",
|
||||
|
||||
@@ -48,8 +48,8 @@ Leptos is a full-stack, isomorphic Rust web framework leveraging fine-grained re
|
||||
## What does that mean?
|
||||
|
||||
- **Full-stack**: Leptos can be used to build apps that run in the browser (_client-side rendering_), on the server (_server-side rendering_), or by rendering HTML on the server and then adding interactivity in the browser (_hydration_). This includes support for _HTTP streaming_ of both data (`Resource`s) and HTML (out-of-order streaming of `<Suspense/>` components.)
|
||||
- **Isomorphic**: The same application code and business logic are compiled to run on the client and server, with seamless integration. You can write your server-only logic (database requests, authentication etc.) alongside the client-side components that will consume it, and let Leptos manage the data loading without the need to manually create APIs to consume.
|
||||
- **Web**: Leptos is built on the Web platform and Web standards. Whenever possible, we use Web essentials (like links and forms) and build on top of them rather than trying to replace them.
|
||||
- **Isomorphic**: Leptos provides primitives to write isomorphic server functions, i.e., functions that can be called with the “same shape” on the client or server, but only run on the server. This means you can write your server-only logic (database requests, authentication etc.) alongside the client-side components that will consume it, and call server functions as if they were running in the browser.
|
||||
- **Web**: Leptos is built on the Web platform and Web standards. The router is designed to use Web fundamentals (like links and forms) and build on top of them rather than trying to replace them.
|
||||
- **Framework**: Leptos provides most of what you need to build a modern web app: a reactive system, templating library, and a router that works on both the server and client side.
|
||||
- **Fine-grained reactivity**: The entire framework is build from reactive primitives. This allows for extremely performant code with minimal overhead: when a reactive signal’s value changes, it can update a single text node, toggle a single class, or remove an element from the DOM without any other code running. (_So, no virtual DOM!_)
|
||||
- **Declarative**: Tell Leptos how you want the page to look, and let the framework tell the browser how to do it.
|
||||
@@ -131,7 +131,7 @@ There are some practical differences that make a significant difference:
|
||||
- **Maturity:** Sycamore is obviously a much more mature and stable library with a larger ecosystem.
|
||||
- **Templating:** Leptos uses a JSX-like template format (built on [syn-rsx](https://github.com/stoically/syn-rsx)) for its `view` macro. Sycamore offers the choice of its own templating DSL or a builder syntax.
|
||||
- **Template node cloning:** Leptos's `view` macro compiles to a static HTML string and a set of instructions of how to assign its reactive values. This means that at runtime, Leptos can clone a `<template>` node rather than calling `document.createElement()` to create DOM nodes. This is a _significantly_ faster way of rendering components.
|
||||
- **Read-write segregation:** Leptos, like Solid, enforces read-write segregation between signal getters and setters, so you end up accessing signals with tuples like `let (count, set_count) = create_signal(cx, 0);`
|
||||
- **Read-write segregation:** Leptos, like Solid, encourages read-write segregation between signal getters and setters, so you end up accessing signals with tuples like `let (count, set_count) = create_signal(cx, 0);` *(If you prefer or if it's more convenient for your API, you can use `create_rw_signal` to give a unified read/write signal.)*
|
||||
- **Signals are functions:** In Leptos, you can call a signal to access it rather than calling a specific method (so, `count()` instead of `count.get()`) This creates a more consistent mental model: accessing a reactive value is always a matter of calling a function. For example:
|
||||
|
||||
```rust
|
||||
|
||||
@@ -5,6 +5,7 @@ pub fn simple_counter(cx: Scope) -> web_sys::Element {
|
||||
|
||||
view! { cx,
|
||||
<div>
|
||||
<MyComponent><p>"Here's the child"</p></MyComponent>
|
||||
<button on:click=move |_| set_value(0)>"Clear"</button>
|
||||
<button on:click=move |_| set_value.update(|value| *value -= 1)>"-1"</button>
|
||||
<span>"Value: " {move || value().to_string()} "!"</span>
|
||||
@@ -12,3 +13,14 @@ pub fn simple_counter(cx: Scope) -> web_sys::Element {
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MyComponent(cx: Scope, children: Option<Box<dyn Fn() -> Vec<Element>>>) -> Element {
|
||||
view! {
|
||||
cx,
|
||||
<my-component>
|
||||
<p>"Here's the child you passed in: "</p>
|
||||
<slot></slot>
|
||||
</my-component>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,46 +31,26 @@ async fn render_app(req: HttpRequest) -> impl Responder {
|
||||
view! { cx, <App/> }
|
||||
};
|
||||
|
||||
let accepts_type = req.headers().get("Accept").map(|h| h.to_str());
|
||||
match accepts_type {
|
||||
// if asks for JSON, send the loader function JSON or 404
|
||||
Some(Ok("application/json")) => {
|
||||
let json = loader_to_json(app).await;
|
||||
let head = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<script type="module">import init, { main } from '/pkg/hackernews_client.js'; init().then(main);</script>"#;
|
||||
let tail = "</body></html>";
|
||||
|
||||
let res = if let Some(json) = json {
|
||||
HttpResponse::Ok()
|
||||
.content_type("application/json")
|
||||
.body(json)
|
||||
} else {
|
||||
HttpResponse::NotFound().body(())
|
||||
};
|
||||
|
||||
res
|
||||
}
|
||||
// otherwise, send HTML
|
||||
_ => {
|
||||
let head = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<script type="module">import init, { main } from '/pkg/hackernews_client.js'; init().then(main);</script>"#;
|
||||
let tail = "</body></html>";
|
||||
|
||||
HttpResponse::Ok().content_type("text/html").streaming(
|
||||
futures::stream::once(async { head.to_string() })
|
||||
.chain(render_to_stream(move |cx| {
|
||||
let app = app(cx);
|
||||
let head = use_context::<MetaContext>(cx)
|
||||
.map(|meta| meta.dehydrate())
|
||||
.unwrap_or_default();
|
||||
format!("{head}</head><body>{app}")
|
||||
}))
|
||||
.chain(futures::stream::once(async { tail.to_string() }))
|
||||
.map(|html| Ok(web::Bytes::from(html)) as Result<web::Bytes>),
|
||||
)
|
||||
}
|
||||
}
|
||||
HttpResponse::Ok().content_type("text/html").streaming(
|
||||
futures::stream::once(async { head.to_string() })
|
||||
.chain(render_to_stream(move |cx| {
|
||||
let app = app(cx);
|
||||
let head = use_context::<MetaContext>(cx)
|
||||
.map(|meta| meta.dehydrate())
|
||||
.unwrap_or_default();
|
||||
format!("{head}</head><body>{app}")
|
||||
}))
|
||||
.chain(futures::stream::once(async { tail.to_string() }))
|
||||
.map(|html| Ok(web::Bytes::from(html)) as Result<web::Bytes>),
|
||||
)
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "todomvc-ssr-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
console_log = "0.2"
|
||||
leptos = { path = "../../../leptos", default-features = false, features = ["hydrate"] }
|
||||
todomvc = { path = "../../todomvc", default-features = false, features = ["hydrate"] }
|
||||
log = "0.4"
|
||||
wasm-bindgen = "0.2"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
wasm-pack build --target=web --release
|
||||
@@ -1,22 +0,0 @@
|
||||
use leptos::*;
|
||||
use todomvc::*;
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
log::debug!("initialized logging");
|
||||
|
||||
leptos::hydrate(body().unwrap(), |cx| {
|
||||
// initial state — identical to server
|
||||
let todos = Todos(vec![
|
||||
Todo::new(cx, 0, "Buy milk".to_string()),
|
||||
Todo::new(cx, 1, "???".to_string()),
|
||||
Todo::new(cx, 2, "Profit!".to_string()),
|
||||
]);
|
||||
|
||||
view! { cx, <TodoMVC todos=todos/> }
|
||||
});
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "todomvc-ssr-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
actix-files = "0.6"
|
||||
actix-web = "4"
|
||||
leptos = { path = "../../../leptos", default-features = false, features = ["ssr"] }
|
||||
todomvc = { path = "../../todomvc", default-features = false, features = ["ssr"] }
|
||||
@@ -1,51 +0,0 @@
|
||||
use actix_files::Files;
|
||||
use actix_web::*;
|
||||
use leptos::*;
|
||||
use todomvc::*;
|
||||
|
||||
#[get("/")]
|
||||
async fn render_todomvc() -> impl Responder {
|
||||
HttpResponse::Ok().content_type("text/html").body(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="stylesheet" href="/static/todomvc-common/base.css"/>
|
||||
<link rel="stylesheet" href="/static/todomvc-app-css/index.css"/>
|
||||
<title>"Leptos • TodoMVC"</title>
|
||||
</head>
|
||||
<body>
|
||||
{}
|
||||
</body>
|
||||
<script type="module">import init, {{ main }} from './pkg/todomvc_ssr_client.js'; init().then(main);</script>
|
||||
</html>"#,
|
||||
run_scope({
|
||||
|cx| {
|
||||
let todos = Todos(vec![
|
||||
Todo::new(cx, 0, "Buy milk".to_string()),
|
||||
Todo::new(cx, 1, "???".to_string()),
|
||||
Todo::new(cx, 2, "Profit!".to_string()),
|
||||
]);
|
||||
|
||||
view! { cx,
|
||||
<TodoMVC todos=todos/>
|
||||
}
|
||||
}
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
HttpServer::new(|| {
|
||||
App::new()
|
||||
.service(render_todomvc)
|
||||
.service(Files::new("/static", "../../todomvc/node_modules"))
|
||||
.service(Files::new("/pkg", "../todomvc-ssr-client/pkg"))
|
||||
.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
@@ -5,15 +5,16 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
leptos = { path = "../../leptos", default-features = false }
|
||||
miniserde = "0.1"
|
||||
log = "0.4"
|
||||
console_log = "0.2"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
uuid = { version = "1", features = ["v4", "js", "serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.0"
|
||||
|
||||
|
||||
[features]
|
||||
default = ["csr"]
|
||||
csr = ["leptos/csr"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use leptos::{web_sys::HtmlInputElement, *};
|
||||
use miniserde::json;
|
||||
use storage::TodoSerialized;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod storage;
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct Todos(pub Vec<Todo>);
|
||||
|
||||
const STORAGE_KEY: &str = "todos-leptos";
|
||||
|
||||
// Basic operations to manipulate the todo list: nothing really interesting here
|
||||
impl Todos {
|
||||
pub fn new(cx: Scope) -> Self {
|
||||
let starting_todos = if is_server!() {
|
||||
@@ -18,7 +19,7 @@ impl Todos {
|
||||
.get_item(STORAGE_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| json::from_str::<Vec<TodoSerialized>>(&value).ok())
|
||||
.and_then(|value| serde_json::from_str::<Vec<TodoSerialized>>(&value).ok())
|
||||
.map(|values| {
|
||||
values
|
||||
.into_iter()
|
||||
@@ -40,31 +41,35 @@ impl Todos {
|
||||
self.0.push(todo);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: usize) {
|
||||
pub fn remove(&mut self, id: Uuid) {
|
||||
self.0.retain(|todo| todo.id != id);
|
||||
}
|
||||
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.0.iter().filter(|todo| !(todo.completed)()).count()
|
||||
// `todo.completed` is a signal, so we call .get() to access its value
|
||||
self.0.iter().filter(|todo| !todo.completed.get()).count()
|
||||
}
|
||||
|
||||
pub fn completed(&self) -> usize {
|
||||
self.0.iter().filter(|todo| (todo.completed)()).count()
|
||||
// `todo.completed` is a signal, so we call .get() to access its value
|
||||
self.0.iter().filter(|todo| todo.completed.get()).count()
|
||||
}
|
||||
|
||||
pub fn toggle_all(&self) {
|
||||
// if all are complete, mark them all active instead
|
||||
// if all are complete, mark them all active
|
||||
if self.remaining() == 0 {
|
||||
for todo in &self.0 {
|
||||
if todo.completed.get() {
|
||||
(todo.set_completed)(false);
|
||||
}
|
||||
todo.completed.update(|completed| {
|
||||
if *completed {
|
||||
*completed = false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// otherwise, mark them all complete
|
||||
else {
|
||||
for todo in &self.0 {
|
||||
(todo.set_completed)(true);
|
||||
todo.completed.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,33 +81,35 @@ impl Todos {
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct Todo {
|
||||
pub id: usize,
|
||||
pub title: ReadSignal<String>,
|
||||
pub set_title: WriteSignal<String>,
|
||||
pub completed: ReadSignal<bool>,
|
||||
pub set_completed: WriteSignal<bool>,
|
||||
pub id: Uuid,
|
||||
pub title: RwSignal<String>,
|
||||
pub completed: RwSignal<bool>,
|
||||
}
|
||||
|
||||
impl Todo {
|
||||
pub fn new(cx: Scope, id: usize, title: String) -> Self {
|
||||
pub fn new(cx: Scope, id: Uuid, title: String) -> Self {
|
||||
Self::new_with_completed(cx, id, title, false)
|
||||
}
|
||||
|
||||
pub fn new_with_completed(cx: Scope, id: usize, title: String, completed: bool) -> Self {
|
||||
let (title, set_title) = create_signal(cx, title);
|
||||
let (completed, set_completed) = create_signal(cx, completed);
|
||||
pub fn new_with_completed(cx: Scope, id: Uuid, title: String, completed: bool) -> Self {
|
||||
// RwSignal combines the getter and setter in one struct, rather than separating
|
||||
// the getter from the setter. This makes it more convenient in some cases, such
|
||||
// as when we're putting the signals into a struct and passing it around. There's
|
||||
// no real difference: you could use `create_signal` here, or use `create_rw_signal`
|
||||
// everywhere.
|
||||
let title = create_rw_signal(cx, title);
|
||||
let completed = create_rw_signal(cx, completed);
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
set_title,
|
||||
completed,
|
||||
set_completed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&self) {
|
||||
self.set_completed
|
||||
.update(|completed| *completed = !*completed);
|
||||
// A signal's `update()` function gives you a mutable reference to the current value
|
||||
// You can use that to modify the value in place, which will notify any subscribers.
|
||||
self.completed.update(|completed| *completed = !*completed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,24 +117,25 @@ const ESCAPE_KEY: u32 = 27;
|
||||
const ENTER_KEY: u32 = 13;
|
||||
|
||||
#[component]
|
||||
pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
let mut next_id = todos
|
||||
.0
|
||||
.iter()
|
||||
.map(|todo| todo.id)
|
||||
.max()
|
||||
.map(|last| last + 1)
|
||||
.unwrap_or(0);
|
||||
pub fn TodoMVC(cx: Scope) -> Element {
|
||||
// The `todos` are a signal, since we need to reactively update the list
|
||||
let (todos, set_todos) = create_signal(cx, Todos::new(cx));
|
||||
|
||||
let (todos, set_todos) = create_signal(cx, todos);
|
||||
// We provide a context that each <Todo/> component can use to update the list
|
||||
// Here, I'm just passing the `WriteSignal`; a <Todo/> doesn't need to read the whole list
|
||||
// (and shouldn't try to, as that would cause each individual <Todo/> to re-render when
|
||||
// a new todo is added! This kind of hygiene is why `create_signal` defaults to read-write
|
||||
// segregation.)
|
||||
provide_context(cx, set_todos);
|
||||
|
||||
// Handle the three filter modes: All, Active, and Completed
|
||||
let (mode, set_mode) = create_signal(cx, Mode::All);
|
||||
window_event_listener("hashchange", move |_| {
|
||||
let new_mode = location_hash().map(|hash| route(&hash)).unwrap_or_default();
|
||||
set_mode(new_mode);
|
||||
});
|
||||
|
||||
// Callback to add a todo on pressing the `Enter` key, if the field isn't empty
|
||||
let add_todo = move |ev: web_sys::Event| {
|
||||
let target = event_target::<HtmlInputElement>(&ev);
|
||||
ev.stop_propagation();
|
||||
@@ -136,15 +144,16 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
let title = event_target_value(&ev);
|
||||
let title = title.trim();
|
||||
if !title.is_empty() {
|
||||
let new = Todo::new(cx, next_id, title.to_string());
|
||||
let new = Todo::new(cx, Uuid::new_v4(), title.to_string());
|
||||
set_todos.update(|t| t.add(new));
|
||||
next_id += 1;
|
||||
target.set_value("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let filtered_todos = create_memo::<Vec<Todo>>(cx, move |_| {
|
||||
// A derived signal that filters the list of the todos depending on the filter mode
|
||||
// This doesn't need to be a `Memo`, because we're only reading it in one place
|
||||
let filtered_todos = move || {
|
||||
todos.with(|todos| match mode.get() {
|
||||
Mode::All => todos.0.to_vec(),
|
||||
Mode::Active => todos
|
||||
@@ -160,10 +169,15 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
.cloned()
|
||||
.collect(),
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
// effect to serialize to JSON
|
||||
// this does reactive reads, so it will automatically serialize on any relevant change
|
||||
// Serialization
|
||||
//
|
||||
// the effect reads the `todos` signal, and each `Todo`'s title and completed
|
||||
// status, so it will automatically re-run on any change to the list of tasks
|
||||
//
|
||||
// this is the main point of `create_effect`: to synchronize reactive state
|
||||
// with something outside the reactive system (like localStorage)
|
||||
create_effect(cx, move |_| {
|
||||
if let Ok(Some(storage)) = window().local_storage() {
|
||||
let objs = todos
|
||||
@@ -172,7 +186,7 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
.iter()
|
||||
.map(TodoSerialized::from)
|
||||
.collect::<Vec<_>>();
|
||||
let json = json::to_string(&objs);
|
||||
let json = serde_json::to_string(&objs).expect("couldn't serialize Todos");
|
||||
if storage.set_item(STORAGE_KEY, &json).is_err() {
|
||||
log::error!("error while trying to set item in localStorage");
|
||||
}
|
||||
@@ -184,12 +198,20 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
<section class="todoapp">
|
||||
<header class="header">
|
||||
<h1>"todos"</h1>
|
||||
<input class="new-todo" placeholder="What needs to be done?" autofocus on:keydown=add_todo />
|
||||
<input
|
||||
class="new-todo"
|
||||
placeholder="What needs to be done?"
|
||||
autofocus
|
||||
on:keydown=add_todo
|
||||
/>
|
||||
</header>
|
||||
<section class="main" class:hidden={move || todos.with(|t| t.is_empty())}>
|
||||
<section
|
||||
class="main"
|
||||
class:hidden={move || todos.with(|t| t.is_empty())}
|
||||
>
|
||||
<input id="toggle-all" class="toggle-all" type="checkbox"
|
||||
prop:checked={move || todos.with(|t| t.remaining() > 0)}
|
||||
on:input=move |_| set_todos.update(|t| t.toggle_all())
|
||||
on:input=move |_| todos.with(|t| t.toggle_all())
|
||||
/>
|
||||
<label for="toggle-all">"Mark all as complete"</label>
|
||||
<ul class="todo-list">
|
||||
@@ -198,7 +220,10 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
</For>
|
||||
</ul>
|
||||
</section>
|
||||
<footer class="footer" class:hidden={move || todos.with(|t| t.is_empty())}>
|
||||
<footer
|
||||
class="footer"
|
||||
class:hidden={move || todos.with(|t| t.is_empty())}
|
||||
>
|
||||
<span class="todo-count">
|
||||
<strong>{move || todos.with(|t| t.remaining().to_string())}</strong>
|
||||
{move || if todos.with(|t| t.remaining()) == 1 {
|
||||
@@ -235,36 +260,43 @@ pub fn TodoMVC(cx: Scope, todos: Todos) -> Element {
|
||||
pub fn Todo(cx: Scope, todo: Todo) -> Element {
|
||||
let (editing, set_editing) = create_signal(cx, false);
|
||||
let set_todos = use_context::<WriteSignal<Todos>>(cx).unwrap();
|
||||
let input: Element;
|
||||
|
||||
// this will be filled by _ref=input below
|
||||
let input: web_sys::Element;
|
||||
|
||||
let save = move |value: &str| {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
set_todos.update(|t| t.remove(todo.id));
|
||||
} else {
|
||||
(todo.set_title)(value.to_string());
|
||||
todo.title.set(value.to_string());
|
||||
}
|
||||
set_editing(false);
|
||||
};
|
||||
|
||||
let tpl = view! { cx,
|
||||
view! { cx,
|
||||
<li
|
||||
class="todo"
|
||||
class:editing={editing}
|
||||
class:completed={move || (todo.completed)()}
|
||||
_ref=input
|
||||
class:completed={move || todo.completed.get()}
|
||||
>
|
||||
<div class="view">
|
||||
<input
|
||||
_ref=input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
prop:checked={move || (todo.completed)()}
|
||||
on:input={move |ev| {
|
||||
let checked = event_target_checked(&ev);
|
||||
(todo.set_completed)(checked);
|
||||
todo.completed.set(checked);
|
||||
}}
|
||||
/>
|
||||
<label on:dblclick=move |_| set_editing(true)>
|
||||
<label on:dblclick=move |_| {
|
||||
set_editing(true);
|
||||
if let Some(input) = input.dyn_ref::<HtmlInputElement>() {
|
||||
input.focus();
|
||||
}
|
||||
}>
|
||||
{move || todo.title.get()}
|
||||
</label>
|
||||
<button class="destroy" on:click=move |_| set_todos.update(|t| t.remove(todo.id))/>
|
||||
@@ -287,16 +319,7 @@ pub fn Todo(cx: Scope, todo: Todo) -> Element {
|
||||
})
|
||||
}
|
||||
</li>
|
||||
};
|
||||
|
||||
#[cfg(any(feature = "csr", feature = "hydrate"))]
|
||||
create_effect(cx, move |_| {
|
||||
if editing() {
|
||||
_ = input.unchecked_ref::<HtmlInputElement>().focus();
|
||||
}
|
||||
});
|
||||
|
||||
tpl
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -4,5 +4,5 @@ fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(|cx| view! { cx, <TodoMVC todos=Todos::new(cx)/> })
|
||||
mount_to_body(|cx| view! { cx, <TodoMVC/> })
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::Todo;
|
||||
use leptos::Scope;
|
||||
use miniserde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TodoSerialized {
|
||||
pub id: usize,
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
@@ -20,7 +21,7 @@ impl From<&Todo> for TodoSerialized {
|
||||
Self {
|
||||
id: todo.id,
|
||||
title: todo.title.get(),
|
||||
completed: (todo.completed)(),
|
||||
completed: todo.completed.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos"
|
||||
version = "0.0.11"
|
||||
version = "0.0.15"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -9,11 +9,11 @@ description = "Leptos is a full-stack, isomorphic Rust web framework leveraging
|
||||
readme = "../README.md"
|
||||
|
||||
[dependencies]
|
||||
leptos_core = { path = "../leptos_core", default-features = false, version = "0.0.11" }
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
|
||||
leptos_server = { path = "../leptos_server", default-features = false, version = "0.0.11" }
|
||||
leptos_core = { path = "../leptos_core", default-features = false, version = "0.0.13" }
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.12" }
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.13" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
|
||||
leptos_server = { path = "../leptos_server", default-features = false, version = "0.0.15" }
|
||||
|
||||
[features]
|
||||
default = ["csr", "serde"]
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//! for examples of the correct API.
|
||||
//!
|
||||
//! # Learning by Example
|
||||
//!
|
||||
//!
|
||||
//! These docs are a work in progress. If you want to see what Leptos is capable of, check out
|
||||
//! the [examples](https://github.com/gbj/leptos/tree/main/examples):
|
||||
//! - [`counter`](https://github.com/gbj/leptos/tree/main/examples/counter) is the classic
|
||||
@@ -53,10 +53,11 @@
|
||||
//!
|
||||
//! Here are links to the most important sections of the docs:
|
||||
//! - **Reactivity**: the [leptos_reactive] overview, and more details in
|
||||
//! - [create_signal], [ReadSignal], and [WriteSignal] (and [create_rw_signal] and [RwSignal])
|
||||
//! - [create_memo] and [Memo]
|
||||
//! - [create_resource] and [Resource]
|
||||
//! - [create_effect]
|
||||
//! - signals: [create_signal], [ReadSignal], and [WriteSignal] (and [create_rw_signal] and [RwSignal])
|
||||
//! - computations: [create_memo] and [Memo]
|
||||
//! - `async` interop: [create_resource] and [Resource] for loading data using `async` functions,
|
||||
//! and [create_action] and [Action] to mutate data or imperatively call `async` functions.
|
||||
//! - reactions: [create_effect]
|
||||
//! - **Templating/Views**: the [view] macro
|
||||
//! - **Routing**: the [leptos_router](https://docs.rs/leptos_router/latest/leptos_router/) crate
|
||||
//!
|
||||
@@ -69,9 +70,9 @@
|
||||
//! and `.set()` manually.
|
||||
//! - `serde` (*Default*) In SSR/hydrate mode, uses [serde] to serialize resources and send them
|
||||
//! from the server to the client.
|
||||
//! - `serde-lite` (*Default*) In SSR/hydrate mode, uses [serde-lite] to serialize resources and send them
|
||||
//! - `serde-lite` In SSR/hydrate mode, uses [serde-lite] to serialize resources and send them
|
||||
//! from the server to the client.
|
||||
//! - `serde` (*Default*) In SSR/hydrate mode, uses [miniserde] to serialize resources and send them
|
||||
//! - `miniserde` In SSR/hydrate mode, uses [miniserde] to serialize resources and send them
|
||||
//! from the server to the client.
|
||||
//!
|
||||
//! **Important Note:** You must enable one of `csr`, `hydrate`, or `ssr` to tell Leptos
|
||||
|
||||
@@ -81,3 +81,33 @@ fn test_classes() {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
|
||||
#[test]
|
||||
fn test_dash_prefixes() {
|
||||
use leptos_dom::*;
|
||||
use leptos_macro::view;
|
||||
use leptos_reactive::{create_signal, run_scope};
|
||||
|
||||
let colons = run_scope(|cx| {
|
||||
let (value, set_value) = create_signal(cx, 5);
|
||||
view! {
|
||||
cx,
|
||||
<div class="my big" class:a={move || value() > 10} class:red=true class:car={move || value() > 1} attr:id="id"></div>
|
||||
}
|
||||
});
|
||||
|
||||
let dashes = run_scope(|cx| {
|
||||
let (value, set_value) = create_signal(cx, 5);
|
||||
view! {
|
||||
cx,
|
||||
<div class="my big" class-a={move || value() > 10} class-red=true class-car={move || value() > 1} attr-id="id"></div>
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(colons, dashes);
|
||||
assert_eq!(
|
||||
dashes,
|
||||
"<div data-hk=\"0-0\" class=\"my big red car\" id=\"id\"></div>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_core"
|
||||
version = "0.0.11"
|
||||
version = "0.0.13"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -8,9 +8,9 @@ repository = "https://github.com/gbj/leptos"
|
||||
description = "Core functionality for the Leptos web framework."
|
||||
|
||||
[dependencies]
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.12" }
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0.13" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
|
||||
log = "0.4"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_dom"
|
||||
version = "0.0.11"
|
||||
version = "0.0.12"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -12,7 +12,7 @@ cfg-if = "1"
|
||||
futures = "0.3"
|
||||
html-escape = "0.2"
|
||||
js-sys = "0.3"
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
|
||||
serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4.31"
|
||||
|
||||
@@ -75,17 +75,13 @@ pub fn insert_before(
|
||||
debug_warn!("insert_before: trying to insert on a parent node that is not an element");
|
||||
new.clone()
|
||||
} else if let Some(existing) = existing {
|
||||
if existing.parent_node().as_ref() == Some(parent.unchecked_ref()) {
|
||||
match parent.insert_before(new, Some(existing)) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug_warn!("{:?}", e.as_string());
|
||||
new.clone()
|
||||
}
|
||||
let parent = existing.parent_node().unwrap_throw();
|
||||
match parent.insert_before(new, Some(existing)) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
debug_warn!("{:?}", e.as_string());
|
||||
new.clone()
|
||||
}
|
||||
} else {
|
||||
debug_warn!("insert_before: existing node is not a child of parent node");
|
||||
parent.append_child(new).unwrap_throw()
|
||||
}
|
||||
} else {
|
||||
parent.append_child(new).unwrap_throw()
|
||||
@@ -238,6 +234,15 @@ pub fn add_event_listener(
|
||||
event_delegation::add_event_listener(event_name);
|
||||
}
|
||||
|
||||
pub fn add_event_listener_undelegated(
|
||||
target: &web_sys::Element,
|
||||
event_name: &'static str,
|
||||
cb: impl FnMut(web_sys::Event) + 'static,
|
||||
) {
|
||||
let cb = Closure::wrap(Box::new(cb) as Box<dyn FnMut(web_sys::Event)>).into_js_value();
|
||||
_ = target.add_event_listener_with_callback(event_name, cb.unchecked_ref());
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn ssr_event_listener(_cb: impl FnMut(web_sys::Event) + 'static) {
|
||||
// this function exists only for type inference in templates for SSR
|
||||
|
||||
@@ -233,11 +233,7 @@ pub fn insert_expression(
|
||||
}
|
||||
Child::Null => match before {
|
||||
Marker::BeforeChild(before) => {
|
||||
if before.is_connected() {
|
||||
Child::Node(insert_before(&parent, node, Some(before)))
|
||||
} else {
|
||||
Child::Node(append_child(&parent, node))
|
||||
}
|
||||
Child::Node(insert_before(&parent, node, Some(before)))
|
||||
}
|
||||
_ => Child::Node(append_child(&parent, node)),
|
||||
},
|
||||
@@ -277,7 +273,7 @@ pub fn insert_expression(
|
||||
Child::Nodes(new_nodes.to_vec())
|
||||
}
|
||||
} else {
|
||||
clean_children(&parent, Child::Null, &Marker::NoChildren, None);
|
||||
clean_children(&parent, Child::Null, before, None);
|
||||
append_nodes(parent, new_nodes.to_vec(), before.as_some_node().cloned());
|
||||
Child::Nodes(new_nodes.to_vec())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_macro"
|
||||
version = "0.0.11"
|
||||
version = "0.0.13"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -15,14 +15,15 @@ proc-macro-error = "1"
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "1", features = ["full", "parsing", "extra-traits"] }
|
||||
syn-rsx = "0.8.1"
|
||||
syn-rsx = "0.8"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
leptos_dom = { path = "../leptos_dom", version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", version = "0.0.11" }
|
||||
leptos_dom = { path = "../leptos_dom", version = "0.0.12" }
|
||||
leptos_reactive = { path = "../leptos_reactive", version = "0.0.12" }
|
||||
|
||||
[dev-dependencies]
|
||||
log = "0.4"
|
||||
typed-builder = "0.10"
|
||||
leptos = { path = "../leptos", version = "0.0.15" }
|
||||
|
||||
[features]
|
||||
default = ["ssr"]
|
||||
|
||||
@@ -83,9 +83,13 @@ mod server;
|
||||
/// ```
|
||||
///
|
||||
/// 4. Dynamic content can be wrapped in curly braces (`{ }`) to insert text nodes, elements, or set attributes.
|
||||
/// If you insert signal here, Leptos will create an effect to update the DOM whenever the value changes.
|
||||
/// If you insert a signal here, Leptos will create an effect to update the DOM whenever the value changes.
|
||||
/// *(“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.)*
|
||||
///
|
||||
/// Attributes can take a wide variety of primitive types that can be converted to strings. They can also
|
||||
/// take an `Option`, in which case `Some` sets the attribute and `None` removes the attribute.
|
||||
///
|
||||
/// ```rust
|
||||
/// # 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| {
|
||||
@@ -105,7 +109,7 @@ mod server;
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// 5. Event handlers can be added with `on:` attributes
|
||||
/// 5. Event handlers can be added with `on:` attributes. If the event name contains a dash, you should use `on-` as the prefix instead.
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
|
||||
/// # run_scope(|cx| {
|
||||
@@ -124,7 +128,10 @@ mod server;
|
||||
/// ```
|
||||
///
|
||||
/// 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).
|
||||
/// that returns a primitive or JsValue). They can also take an `Option`, in which case `Some` sets the property
|
||||
/// and `None` deletes the property.
|
||||
///
|
||||
/// If your property name contains a dash, you should use `prop-` as the prefix instead.
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
|
||||
/// # run_scope(|cx| {
|
||||
@@ -147,6 +154,7 @@ mod server;
|
||||
/// ```
|
||||
///
|
||||
/// 7. Classes can be toggled with `class:` attributes, which take a `bool` (or a signal that returns a `bool`).
|
||||
/// If your class name contains a dash, you should use `class-` as the prefix instead.
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::*; use leptos_dom::*; use leptos_macro::view; use leptos_dom::wasm_bindgen::JsCast;
|
||||
/// # run_scope(|cx| {
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
|
||||
}
|
||||
|
||||
fn from_form_data(data: &[u8]) -> Result<Self, ServerFnError> {
|
||||
let data = ::leptos::leptos_server::form_urlencoded::parse(data).collect::<Vec<_>>();
|
||||
let data = ::leptos::form_urlencoded::parse(data).collect::<Vec<_>>();
|
||||
Ok(Self {
|
||||
#(#from_form_data_fields),*
|
||||
})
|
||||
@@ -129,8 +129,8 @@ pub fn server_macro_impl(args: proc_macro::TokenStream, s: TokenStream2) -> Resu
|
||||
|
||||
#[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 })
|
||||
let #struct_name { #(#field_names_3),* } = self;
|
||||
Box::pin(async move { #fn_name( #(#field_names_4),*).await })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,97 +207,4 @@ impl Parse for ServerFnBody {
|
||||
attrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
/*
|
||||
/// Serialize the same way, regardless of flavor
|
||||
impl ToTokens for ServerFnBody {
|
||||
fn to_tokens(&self, out_tokens: &mut TokenStream2) {
|
||||
let Self {
|
||||
vis,
|
||||
ident,
|
||||
generics,
|
||||
inputs,
|
||||
output,
|
||||
where_clause,
|
||||
block,
|
||||
attrs,
|
||||
..
|
||||
} = self;
|
||||
|
||||
let fields = inputs.iter().map(|f| {
|
||||
let typed_arg = match f {
|
||||
FnArg::Receiver(_) => todo!(),
|
||||
FnArg::Typed(t) => t,
|
||||
};
|
||||
if let Type::Path(pat) = &*typed_arg.ty {
|
||||
if pat.path.segments[0].ident == "Option" {
|
||||
quote! {
|
||||
#[builder(default, setter(strip_option))]
|
||||
#vis #f
|
||||
}
|
||||
} else {
|
||||
quote! { #vis #f }
|
||||
}
|
||||
} else {
|
||||
quote! { #vis #f }
|
||||
}
|
||||
});
|
||||
|
||||
let struct_name = Ident::new(&format!("{}Props", ident), Span::call_site());
|
||||
|
||||
let field_names = inputs.iter().filter_map(|f| match f {
|
||||
FnArg::Receiver(_) => todo!(),
|
||||
FnArg::Typed(t) => Some(&t.pat),
|
||||
});
|
||||
|
||||
let first_lifetime = if let Some(GenericParam::Lifetime(lt)) = generics.params.first() {
|
||||
Some(lt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
out_tokens.append_all(quote! {
|
||||
#[derive(Copy, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Debug)]
|
||||
pub struct #struct_name {}
|
||||
|
||||
#[async_trait]
|
||||
impl ServerFn for #struct_name {
|
||||
type Output = i32;
|
||||
|
||||
fn url() -> &'static str {
|
||||
"get_server_count"
|
||||
}
|
||||
|
||||
fn as_form_data(&self) -> Vec<(&'static str, String)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn call_fn(self) -> Result<Self::Output, ServerFnError> {
|
||||
get_server_count().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_server_count() -> Result<i32, ServerFnError> {
|
||||
Ok(COUNT.load(Ordering::Relaxed))
|
||||
}
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub async fn get_server_count() -> Result<i32, ServerFnError> {
|
||||
call_server_fn(#struct_name::url(), #struct_name {}).await
|
||||
}
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub async fn get_server_count_helper(args: #struct_name) -> Result<i32, ServerFnError> {
|
||||
call_server_fn(#struct_name::url(), args).await
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
||||
*/
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{is_component_node, Mode};
|
||||
|
||||
const NON_BUBBLING_EVENTS: [&str; 11] = ["load", "unload", "scroll", "focus", "blur", "loadstart", "progress", "error", "abort", "load", "loadend"];
|
||||
|
||||
pub(crate) fn render_view(cx: &Ident, nodes: &[Node], mode: Mode) -> TokenStream {
|
||||
let template_uid = Ident::new(
|
||||
&format!("TEMPLATE_{}", Uuid::new_v4().simple()),
|
||||
@@ -152,7 +154,7 @@ fn root_element_to_tokens(
|
||||
#[derive(Clone, Debug)]
|
||||
enum PrevSibChange {
|
||||
Sib(Ident),
|
||||
//Parent,
|
||||
Parent,
|
||||
Skip,
|
||||
}
|
||||
|
||||
@@ -214,8 +216,14 @@ fn element_to_tokens(
|
||||
.iter()
|
||||
.filter_map(|node| {
|
||||
node.name_as_string().and_then(|name| {
|
||||
if name.starts_with("class:") {
|
||||
let name = name.replacen("class:", "", 1);
|
||||
if name.starts_with("class:") || name.starts_with("class-") {
|
||||
let name = if name.starts_with("class:") {
|
||||
name.replacen("class:", "", 1)
|
||||
} else if name.starts_with("class-") {
|
||||
name.replacen("class-", "", 1)
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let value = node.value.as_ref().expect("class: attributes need values");
|
||||
let span = node.name_span().expect("missing span for class name");
|
||||
Some(quote_spanned! {
|
||||
@@ -270,7 +278,7 @@ fn element_to_tokens(
|
||||
quote_spanned! {
|
||||
span => let #this_el_ident = #debug_name;
|
||||
let #this_el_ident = #parent.clone().unchecked_into::<web_sys::Node>();
|
||||
//log::debug!("=> got {}", #this_el_ident.node_name());
|
||||
//debug!("=> got {}", #this_el_ident.node_name());
|
||||
}
|
||||
} else if let Some(prev_sib) = &prev_sib {
|
||||
quote_spanned! {
|
||||
@@ -345,11 +353,12 @@ fn element_to_tokens(
|
||||
expressions,
|
||||
multi,
|
||||
mode,
|
||||
idx == 0
|
||||
);
|
||||
|
||||
prev_sib = match curr_id {
|
||||
PrevSibChange::Sib(id) => Some(id),
|
||||
//PrevSibChange::Parent => None,
|
||||
PrevSibChange::Parent => None,
|
||||
PrevSibChange::Skip => prev_sib,
|
||||
};
|
||||
}
|
||||
@@ -400,6 +409,13 @@ fn attr_to_tokens(
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let name = if name.starts_with("attr:") {
|
||||
name.replacen("attr:", "", 1)
|
||||
} else if name.starts_with("attr-") {
|
||||
name.replacen("attr-", "", 1)
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let value = match &node.value {
|
||||
Some(expr) => match expr {
|
||||
syn::Expr::Lit(expr_lit) => {
|
||||
@@ -432,11 +448,8 @@ fn attr_to_tokens(
|
||||
};
|
||||
|
||||
if mode == Mode::Ssr {
|
||||
// fake the initialization; should only be used in effects or event handlers, which will never run on the server
|
||||
// but if we don't initialize it, the compiler will complain
|
||||
navigations.push(quote_spanned! {
|
||||
span => #ident = String::new();
|
||||
});
|
||||
// used to fake the initialization; but if we do this, we can't do normal things like .dyn_ref() on an Element
|
||||
// this will cause some warnings instead about unused setters, while doing SSR
|
||||
} else {
|
||||
expressions.push(match &node.value {
|
||||
Some(expr) => {
|
||||
@@ -456,30 +469,41 @@ fn attr_to_tokens(
|
||||
}
|
||||
}
|
||||
// Event Handlers
|
||||
else if name.starts_with("on:") {
|
||||
else if name.starts_with("on:") || name.starts_with("on-") {
|
||||
let handler = node
|
||||
.value
|
||||
.as_ref()
|
||||
.expect("event listener attributes need a value");
|
||||
|
||||
if mode != Mode::Ssr {
|
||||
let event_name = name.replacen("on:", "", 1);
|
||||
expressions.push(quote_spanned! {
|
||||
span => add_event_listener(#el_id.unchecked_ref(), #event_name, #handler);
|
||||
});
|
||||
let name = if name.starts_with("on:") {
|
||||
name.replacen("on:", "", 1)
|
||||
} else {
|
||||
name.replacen("on-", "", 1)
|
||||
};
|
||||
if NON_BUBBLING_EVENTS.contains(&name.as_str()) {
|
||||
expressions.push(quote_spanned! {
|
||||
span => ::leptos::add_event_listener_undelegated(#el_id.unchecked_ref(), #name, #handler);
|
||||
});
|
||||
} else {
|
||||
expressions.push(quote_spanned! {
|
||||
span => ::leptos::add_event_listener(#el_id.unchecked_ref(), #name, #handler);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// this is here to avoid warnings about unused signals
|
||||
// that are used in event listeners. I'm open to better solutions.
|
||||
expressions.push(quote_spanned! {
|
||||
span => let _ = ssr_event_listener(#handler);
|
||||
});
|
||||
// we used to fake the event listener here for SSR to avoid warnings about unused errors
|
||||
// but that causes problems if you try to use JsCast on a leptos::Element but it's a string
|
||||
}
|
||||
}
|
||||
// Properties
|
||||
else if name.starts_with("prop:") {
|
||||
else if name.starts_with("prop:") || name.starts_with("prop-") {
|
||||
let name = if name.starts_with("prop:") {
|
||||
name.replacen("prop:", "", 1)
|
||||
} else {
|
||||
name.replacen("prop-", "", 1)
|
||||
};
|
||||
// can't set properties in SSR
|
||||
if mode != Mode::Ssr {
|
||||
let name = name.replacen("prop:", "", 1);
|
||||
if mode != Mode::Ssr {
|
||||
let value = node.value.as_ref().expect("prop: blocks need values");
|
||||
expressions.push(quote_spanned! {
|
||||
span => leptos_dom::property(#cx, #el_id.unchecked_ref(), #name, #value.into_property(#cx))
|
||||
@@ -487,11 +511,15 @@ fn attr_to_tokens(
|
||||
}
|
||||
}
|
||||
// Classes
|
||||
else if name.starts_with("class:") {
|
||||
else if name.starts_with("class:") || name.starts_with("class-") {
|
||||
let name = if name.starts_with("class:") {
|
||||
name.replacen("class:", "", 1)
|
||||
} else {
|
||||
name.replacen("class-", "", 1)
|
||||
};
|
||||
if mode == Mode::Ssr {
|
||||
// handled separately because they need to be merged
|
||||
} else {
|
||||
let name = name.replacen("class:", "", 1);
|
||||
let value = node.value.as_ref().expect("class: attributes need values");
|
||||
expressions.push(quote_spanned! {
|
||||
span => leptos_dom::class(#cx, #el_id.unchecked_ref(), #name, #value.into_class(#cx))
|
||||
@@ -571,6 +599,7 @@ fn child_to_tokens(
|
||||
expressions: &mut Vec<TokenStream>,
|
||||
multi: bool,
|
||||
mode: Mode,
|
||||
is_first_child: bool
|
||||
) -> PrevSibChange {
|
||||
match node.node_type {
|
||||
NodeType::Element => {
|
||||
@@ -588,6 +617,7 @@ fn child_to_tokens(
|
||||
next_co_id,
|
||||
multi,
|
||||
mode,
|
||||
is_first_child
|
||||
)
|
||||
} else {
|
||||
PrevSibChange::Sib(element_to_tokens(
|
||||
@@ -625,20 +655,25 @@ fn child_to_tokens(
|
||||
.map(|val| val.span())
|
||||
.unwrap_or_else(Span::call_site);
|
||||
|
||||
*next_el_id += 1;
|
||||
let name = child_ident(*next_el_id, node);
|
||||
let location = if let Some(sibling) = &prev_sib {
|
||||
quote_spanned! {
|
||||
span => //log::debug!("-> next sibling");
|
||||
let #name = #sibling.next_sibling().unwrap_throw();
|
||||
//log::debug!("\tnext sibling = {}", #name.node_name());
|
||||
}
|
||||
let (name, location) = if is_first_child && mode == Mode::Client {
|
||||
(None, quote! { })
|
||||
} else {
|
||||
quote_spanned! {
|
||||
span => //log::debug!("\\|/ first child on {}", #parent.node_name());
|
||||
let #name = #parent.first_child().unwrap_throw();
|
||||
//log::debug!("\tfirst child = {}", #name.node_name());
|
||||
}
|
||||
*next_el_id += 1;
|
||||
let name = child_ident(*next_el_id, node);
|
||||
let location = if let Some(sibling) = &prev_sib {
|
||||
quote_spanned! {
|
||||
span => //log::debug!("-> next sibling");
|
||||
let #name = #sibling.next_sibling().unwrap_throw();
|
||||
//log::debug!("\tnext sibling = {}", #name.node_name());
|
||||
}
|
||||
} else {
|
||||
quote_spanned! {
|
||||
span => //log::debug!("\\|/ first child on {}", #parent.node_name());
|
||||
let #name = #parent.first_child().unwrap_throw();
|
||||
//log::debug!("\tfirst child = {}", #name.node_name());
|
||||
}
|
||||
};
|
||||
(Some(name), location)
|
||||
};
|
||||
|
||||
let before = match &next_sib {
|
||||
@@ -664,13 +699,19 @@ fn child_to_tokens(
|
||||
template.push_str(&v);
|
||||
}
|
||||
|
||||
PrevSibChange::Sib(name)
|
||||
if let Some(name) = name {
|
||||
PrevSibChange::Sib(name)
|
||||
} else {
|
||||
PrevSibChange::Parent
|
||||
}
|
||||
} else {
|
||||
// these markers are one of the primary templating differences across modes
|
||||
match mode {
|
||||
// in CSR, simply insert a comment node: it will be picked up and replaced with the value
|
||||
Mode::Client => {
|
||||
template.push_str("<!>");
|
||||
if !is_first_child {
|
||||
template.push_str("<!>");
|
||||
}
|
||||
navigations.push(location);
|
||||
|
||||
let current = match current {
|
||||
@@ -723,7 +764,11 @@ fn child_to_tokens(
|
||||
}),
|
||||
}
|
||||
|
||||
PrevSibChange::Sib(name)
|
||||
if let Some(name) = name {
|
||||
PrevSibChange::Sib(name)
|
||||
} else {
|
||||
PrevSibChange::Parent
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => panic!("unexpected child node type"),
|
||||
@@ -744,6 +789,7 @@ fn component_to_tokens(
|
||||
next_co_id: &mut usize,
|
||||
multi: bool,
|
||||
mode: Mode,
|
||||
is_first_child: bool
|
||||
) -> PrevSibChange {
|
||||
let create_component = create_component(cx, node, mode);
|
||||
let span = node.name_span().unwrap();
|
||||
@@ -831,7 +877,11 @@ fn component_to_tokens(
|
||||
|
||||
match current {
|
||||
Some(el) => PrevSibChange::Sib(el),
|
||||
None => PrevSibChange::Skip,
|
||||
None => if is_first_child {
|
||||
PrevSibChange::Parent
|
||||
} else {
|
||||
PrevSibChange::Skip
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,9 +925,13 @@ fn create_component(cx: &Ident, node: &Node, mode: Mode) -> TokenStream {
|
||||
let props = node.attributes.iter().filter_map(|attr| {
|
||||
let attr_name = attr.name_as_string().unwrap_or_default();
|
||||
if attr_name.starts_with("on:")
|
||||
|| attr_name.starts_with("on-")
|
||||
|| attr_name.starts_with("prop:")
|
||||
|| attr_name.starts_with("prop-")
|
||||
|| attr_name.starts_with("class:")
|
||||
|| attr_name.starts_with("class-")
|
||||
|| attr_name.starts_with("attr:")
|
||||
|| attr_name.starts_with("attr-")
|
||||
{
|
||||
None
|
||||
} else {
|
||||
@@ -896,15 +950,38 @@ fn create_component(cx: &Ident, node: &Node, mode: Mode) -> TokenStream {
|
||||
|
||||
let mut other_attrs = node.attributes.iter().filter_map(|attr| {
|
||||
let attr_name = attr.name_as_string().unwrap_or_default();
|
||||
// Event Listeners
|
||||
if let Some(event_name) = attr_name.strip_prefix("on:") {
|
||||
let span = attr.name_span().unwrap();
|
||||
let handler = attr
|
||||
.value
|
||||
.as_ref()
|
||||
.expect("event listener attributes need a value");
|
||||
Some(quote_spanned! {
|
||||
span => add_event_listener(#component_name.unchecked_ref(), #event_name, #handler)
|
||||
})
|
||||
.expect("on: event listener attributes need a value");
|
||||
if NON_BUBBLING_EVENTS.contains(&event_name) {
|
||||
Some(quote_spanned! {
|
||||
span => ::leptos::add_event_listener_undelegated(#component_name.unchecked_ref(), #event_name, #handler);
|
||||
})
|
||||
} else {
|
||||
Some(quote_spanned! {
|
||||
span => ::leptos::add_event_listener(#component_name.unchecked_ref(), #event_name, #handler)
|
||||
})
|
||||
}
|
||||
}
|
||||
else if let Some(event_name) = attr_name.strip_prefix("on-") {
|
||||
let span = attr.name_span().unwrap();
|
||||
let handler = attr
|
||||
.value
|
||||
.as_ref()
|
||||
.expect("on- event listener attributes need a value");
|
||||
if NON_BUBBLING_EVENTS.contains(&event_name) {
|
||||
Some(quote_spanned! {
|
||||
span => ::leptos::add_event_listener_undelegated(#component_name.unchecked_ref(), #event_name, #handler);
|
||||
})
|
||||
} else {
|
||||
Some(quote_spanned! {
|
||||
span => ::leptos::add_event_listener(#component_name.unchecked_ref(), #event_name, #handler)
|
||||
})
|
||||
}
|
||||
}
|
||||
// Properties
|
||||
else if let Some(name) = attr_name.strip_prefix("prop:") {
|
||||
@@ -913,6 +990,12 @@ fn create_component(cx: &Ident, node: &Node, mode: Mode) -> TokenStream {
|
||||
span => leptos_dom::property(#cx, #component_name.unchecked_ref(), #name, #value.into_property(#cx))
|
||||
})
|
||||
}
|
||||
else if let Some(name) = attr_name.strip_prefix("prop-") {
|
||||
let value = attr.value.as_ref().expect("prop- attributes need values");
|
||||
Some(quote_spanned! {
|
||||
span => leptos_dom::property(#cx, #component_name.unchecked_ref(), #name, #value.into_property(#cx))
|
||||
})
|
||||
}
|
||||
// Classes
|
||||
else if let Some(name) = attr_name.strip_prefix("class:") {
|
||||
let value = attr.value.as_ref().expect("class: attributes need values");
|
||||
@@ -920,10 +1003,21 @@ fn create_component(cx: &Ident, node: &Node, mode: Mode) -> TokenStream {
|
||||
span => leptos_dom::class(#cx, #component_name.unchecked_ref(), #name, #value.into_class(#cx))
|
||||
})
|
||||
}
|
||||
else if let Some(name) = attr_name.strip_prefix("class-") {
|
||||
let value = attr.value.as_ref().expect("class: attributes need values");
|
||||
Some(quote_spanned! {
|
||||
span => leptos_dom::class(#cx, #component_name.unchecked_ref(), #name, #value.into_class(#cx))
|
||||
})
|
||||
}
|
||||
// Attributes
|
||||
else if let Some(name) = attr_name.strip_prefix("attr:") {
|
||||
let value = attr.value.as_ref().expect("attr: attributes need values");
|
||||
let name = name.replace('_', "-");
|
||||
Some(quote_spanned! {
|
||||
span => leptos_dom::attribute(#cx, #component_name.unchecked_ref(), #name, #value.into_attribute(#cx))
|
||||
})
|
||||
}
|
||||
else if let Some(name) = attr_name.strip_prefix("attr-") {
|
||||
let value = attr.value.as_ref().expect("attr- attributes need values");
|
||||
Some(quote_spanned! {
|
||||
span => leptos_dom::attribute(#cx, #component_name.unchecked_ref(), #name, #value.into_attribute(#cx))
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_reactive"
|
||||
version = "0.0.11"
|
||||
version = "0.0.12"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -644,7 +644,13 @@ impl SignalId {
|
||||
}
|
||||
}
|
||||
}?;
|
||||
let value = value.borrow();
|
||||
let value = value.try_borrow().unwrap_or_else(|e| {
|
||||
debug_warn!(
|
||||
"Signal::try_with_no_subscription failed on Signal<{}>. It seems you're trying to read the value of a signal within an effect caused by updating the signal.",
|
||||
std::any::type_name::<T>()
|
||||
);
|
||||
panic!("{e}");
|
||||
});
|
||||
let value = value
|
||||
.downcast_ref::<T>()
|
||||
.ok_or_else(|| SignalError::Type(std::any::type_name::<T>()))?;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
[package]
|
||||
name = "leptos_server"
|
||||
version = "0.0.11"
|
||||
version = "0.0.15"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/gbj/leptos"
|
||||
description = "RPC for the Leptos web framework."
|
||||
|
||||
[dependencies]
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.11" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.11" }
|
||||
leptos_dom = { path = "../leptos_dom", default-features = false, version = "0.0.12" }
|
||||
leptos_reactive = { path = "../leptos_reactive", default-features = false, version = "0.0.12" }
|
||||
form_urlencoded = "1"
|
||||
gloo-net = "0.2"
|
||||
lazy_static = "1"
|
||||
@@ -13,11 +17,35 @@ linear-map = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
leptos_macro = { path = "../leptos_macro", default-features = false, version = "0.0" }
|
||||
leptos = { path = "../leptos", default-features = false, version = "0.0" }
|
||||
|
||||
[features]
|
||||
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"]
|
||||
csr = [
|
||||
"leptos_dom/csr",
|
||||
"leptos_reactive/csr",
|
||||
"leptos_macro/csr",
|
||||
"leptos/csr",
|
||||
]
|
||||
hydrate = [
|
||||
"leptos_dom/hydrate",
|
||||
"leptos_reactive/hydrate",
|
||||
"leptos_macro/hydrate",
|
||||
"leptos/hydrate",
|
||||
]
|
||||
ssr = [
|
||||
"leptos_dom/ssr",
|
||||
"leptos_reactive/ssr",
|
||||
"leptos_macro/ssr",
|
||||
"leptos/csr",
|
||||
]
|
||||
stable = [
|
||||
"leptos_dom/stable",
|
||||
"leptos_reactive/stable",
|
||||
"leptos_macro/stable",
|
||||
"leptos/stable",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["stable"]
|
||||
|
||||
@@ -56,16 +56,6 @@
|
||||
//! - **Server function arguments and return types must be [Serializable](leptos_reactive::Serializable).**
|
||||
//! This should be fairly obvious: we have to serialize arguments to send them to the server, and we
|
||||
//! need to deserialize the result to return it to the client.
|
||||
//!
|
||||
//! ### `create_action`
|
||||
//!
|
||||
//! The easiest way to call server functions from the client is the `create_action` primitive.
|
||||
//! This returns an [Action](crate::Action), with a [dispatch](crate::Action::dispatch) method
|
||||
//! that can run any `async` function, including one that contains one or more calls to server functions.
|
||||
//!
|
||||
//! Dispatching an action increments its [version](crate::Action::version) field, which is a
|
||||
//! 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 form_urlencoded;
|
||||
use leptos_reactive::*;
|
||||
@@ -73,22 +63,65 @@ use serde::{Deserialize, Serialize};
|
||||
use std::{future::Future, pin::Pin, rc::Rc};
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
type ServerFnTraitObj =
|
||||
dyn Fn(&[u8]) -> Pin<Box<dyn Future<Output = Result<String, ServerFnError>>>> + Send + Sync;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref REGISTERED_SERVER_FUNCTIONS: Arc<RwLock<HashMap<&'static str, Arc<ServerFnTraitObj>>>> = Default::default();
|
||||
static ref REGISTERED_SERVER_FUNCTIONS: Arc<RwLock<HashMap<&'static str, Arc<ServerFnTraitObj>>>> = Default::default();
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
/// Attempts to find a server function registered at the given path.
|
||||
///
|
||||
/// This can be used by a server to handle the requests, as in the following example (using `actix-web`)
|
||||
///
|
||||
/// ```rust, ignore
|
||||
/// #[post("{tail:.*}")]
|
||||
/// async fn handle_server_fns(
|
||||
/// req: HttpRequest,
|
||||
/// params: web::Path<String>,
|
||||
/// body: web::Bytes,
|
||||
/// ) -> impl Responder {
|
||||
/// let path = params.into_inner();
|
||||
/// let accept_header = req
|
||||
/// .headers()
|
||||
/// .get("Accept")
|
||||
/// .and_then(|value| value.to_str().ok());
|
||||
///
|
||||
/// if let Some(server_fn) = server_fn_by_path(path.as_str()) {
|
||||
/// let body: &[u8] = &body;
|
||||
/// match server_fn(&body).await {
|
||||
/// Ok(serialized) => {
|
||||
/// // if this is Accept: application/json then send a serialized JSON response
|
||||
/// if let Some("application/json") = accept_header {
|
||||
/// HttpResponse::Ok().body(serialized)
|
||||
/// }
|
||||
/// // otherwise, it's probably a <form> submit or something: redirect back to the referrer
|
||||
/// else {
|
||||
/// HttpResponse::SeeOther()
|
||||
/// .insert_header(("Location", "/"))
|
||||
/// .content_type("application/json")
|
||||
/// .body(serialized)
|
||||
/// }
|
||||
/// }
|
||||
/// Err(e) => {
|
||||
/// eprintln!("server function error: {e:#?}");
|
||||
/// HttpResponse::InternalServerError().body(e.to_string())
|
||||
/// }
|
||||
/// }
|
||||
/// } else {
|
||||
/// HttpResponse::BadRequest().body(format!("Could not find a server function at that route."))
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
pub fn server_fn_by_path(path: &str) -> Option<Arc<ServerFnTraitObj>> {
|
||||
REGISTERED_SERVER_FUNCTIONS
|
||||
.read()
|
||||
@@ -96,25 +129,43 @@ pub fn server_fn_by_path(path: &str) -> Option<Arc<ServerFnTraitObj>> {
|
||||
.and_then(|fns| fns.get(path).cloned())
|
||||
}
|
||||
|
||||
/// Defines a "server function." A server function can be called from the server or the client,
|
||||
/// but the body of its code will only be run on the server, i.e., if a crate feature `ssr` is enabled.
|
||||
///
|
||||
/// (This follows the same convention as the Leptos framework's distinction between `ssr` for server-side rendering,
|
||||
/// and `csr` and `hydrate` for client-side rendering and hydration, respectively.)
|
||||
///
|
||||
/// Server functions are created using the `server` macro.
|
||||
///
|
||||
/// The function should be registered by calling `ServerFn::register()`. The set of server functions
|
||||
/// can be queried on the server for routing purposes by calling [server_fn_by_path].
|
||||
///
|
||||
/// Technically, the trait is implemented on a type that describes the server function's arguments.
|
||||
pub trait ServerFn
|
||||
where
|
||||
Self: Sized + 'static,
|
||||
{
|
||||
type Output: Serializable;
|
||||
|
||||
/// The path at which the server function can be reached on the server.
|
||||
fn url() -> &'static str;
|
||||
|
||||
/// A set of `(input_name, input_value)` pairs used to serialize the arguments to the server function.
|
||||
fn as_form_data(&self) -> Vec<(&'static str, String)>;
|
||||
|
||||
/// Deserializes the arguments to the server function from form data.
|
||||
fn from_form_data(data: &[u8]) -> Result<Self, ServerFnError>;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
/// Runs the function on the server.
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
fn call_fn(self) -> Pin<Box<dyn Future<Output = Result<Self::Output, ServerFnError>> + Send>>;
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
/// Runs the function on the client by sending an HTTP request to the server.
|
||||
#[cfg(any(not(feature = "ssr"), doc))]
|
||||
fn call_fn_client(self) -> Pin<Box<dyn Future<Output = Result<Self::Output, ServerFnError>>>>;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
/// Registers the server function, allowing the server to query it by URL.
|
||||
#[cfg(any(feature = "ssr", doc))]
|
||||
fn register() -> Result<(), ServerFnError> {
|
||||
// create the handler for this server function
|
||||
// takes a String -> returns its async value
|
||||
@@ -156,6 +207,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Type for errors that can occur when using server functions.
|
||||
#[derive(Error, Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ServerFnError {
|
||||
#[error("error while trying to register the server function: {0}")]
|
||||
@@ -174,6 +226,7 @@ pub enum ServerFnError {
|
||||
MissingArg(String),
|
||||
}
|
||||
|
||||
/// Executes the HTTP call to call a server function from the client, given its URL and argument type.
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub async fn call_server_fn<T>(url: &str, args: impl ServerFn) -> Result<T, ServerFnError>
|
||||
where
|
||||
@@ -213,12 +266,87 @@ where
|
||||
T::from_json(&text).map_err(|e| ServerFnError::Deserialization(e.to_string()))
|
||||
}
|
||||
|
||||
/// An action synchronizes an imperative `async` call to the synchronous reactive system.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [Resource](leptos_reactive::Resource) instead. If you’re trying to occasionally
|
||||
/// run an `async` function in response to something like a user clicking a button, you're in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let save_data = create_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// // the argument currently running
|
||||
/// let input = save_data.input();
|
||||
/// // the most recent returned result
|
||||
/// let result_of_call = save_data.value();
|
||||
/// // whether the call is pending
|
||||
/// let pending = save_data.pending();
|
||||
/// // how many times the action has run
|
||||
/// // useful for reactively updating something else in response to a `dispatch` and response
|
||||
/// let version = save_data.version;
|
||||
///
|
||||
/// // before we do anything
|
||||
/// assert_eq!(input(), None); // no argument yet
|
||||
/// assert_eq!(pending(), false); // isn't pending a response
|
||||
/// assert_eq!(result_of_call(), None); // there's no "last value"
|
||||
/// assert_eq!(version(), 0);
|
||||
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
|
||||
/// // dispatch the action
|
||||
/// save_data.dispatch("My todo".to_string());
|
||||
///
|
||||
/// // when we're making the call
|
||||
/// // assert_eq!(input(), Some("My todo".to_string()));
|
||||
/// // assert_eq!(pending(), true); // is pending
|
||||
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
|
||||
///
|
||||
/// // after call has resolved
|
||||
/// assert_eq!(input(), None); // input clears out after resolved
|
||||
/// assert_eq!(pending(), false); // no longer pending
|
||||
/// assert_eq!(result_of_call(), Some(42));
|
||||
/// assert_eq!(version(), 1);
|
||||
/// # }
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [Action::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
/// How many times the action has successfully resolved.
|
||||
pub version: RwSignal<usize>,
|
||||
input: RwSignal<Option<I>>,
|
||||
value: RwSignal<Option<O>>,
|
||||
@@ -233,44 +361,129 @@ where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
|
||||
self.url = Some(T::url());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pending(&self) -> impl Fn() -> bool {
|
||||
let value = self.value;
|
||||
move || value.with(|val| val.is_some())
|
||||
}
|
||||
|
||||
pub fn input(&self) -> ReadSignal<Option<I>> {
|
||||
self.input.read_only()
|
||||
}
|
||||
|
||||
pub fn value(&self) -> ReadSignal<Option<O>> {
|
||||
self.value.read_only()
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Option<&str> {
|
||||
self.url
|
||||
}
|
||||
|
||||
/// Calls the server function a reference to the input type as its argument.
|
||||
pub fn dispatch(&self, input: I) {
|
||||
let fut = (self.action_fn)(&input);
|
||||
self.input.set(Some(input));
|
||||
let input = self.input;
|
||||
let version = self.version;
|
||||
let pending = self.pending;
|
||||
let value = self.value;
|
||||
pending.set(true);
|
||||
spawn_local(async move {
|
||||
let new_value = fut.await;
|
||||
value.set(Some(new_value));
|
||||
input.set(None);
|
||||
pending.set(false);
|
||||
value.set(Some(new_value));
|
||||
version.update(|n| *n += 1);
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the action has been dispatched and is currently waiting for its future to be resolved.
|
||||
pub fn pending(&self) -> ReadSignal<bool> {
|
||||
self.pending.read_only()
|
||||
}
|
||||
|
||||
/// The argument that was dispatched to the `async` function,
|
||||
/// only while we are waiting for it to resolve.
|
||||
pub fn input(&self) -> ReadSignal<Option<I>> {
|
||||
self.input.read_only()
|
||||
}
|
||||
|
||||
/// The most recent return value of the `async` function.
|
||||
pub fn value(&self) -> ReadSignal<Option<O>> {
|
||||
self.value.read_only()
|
||||
}
|
||||
|
||||
/// The URL associated with the action (typically as part of a server function.)
|
||||
/// This enables integration with the `ActionForm` component in `leptos_router`.
|
||||
pub fn url(&self) -> Option<&str> {
|
||||
self.url
|
||||
}
|
||||
|
||||
/// Associates the URL of the given server function with this action.
|
||||
/// This enables integration with the `ActionForm` component in `leptos_router`.
|
||||
pub fn using_server_fn<T: ServerFn>(mut self) -> Self {
|
||||
self.url = Some(T::url());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [Action] to synchronize an imperative `async` call to the synchronous reactive system.
|
||||
///
|
||||
/// If you’re trying to load data by running an `async` function reactively, you probably
|
||||
/// want to use a [create_resource](leptos_reactive::create_resource) instead. If you’re trying
|
||||
/// to occasionally run an `async` function in response to something like a user clicking a button,
|
||||
/// you're in the right place.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// async fn send_new_todo_to_api(task: String) -> usize {
|
||||
/// // do something...
|
||||
/// // return a task id
|
||||
/// 42
|
||||
/// }
|
||||
/// let save_data = create_action(cx, |task: &String| {
|
||||
/// // `task` is given as `&String` because its value is available in `input`
|
||||
/// send_new_todo_to_api(task.clone())
|
||||
/// });
|
||||
///
|
||||
/// // the argument currently running
|
||||
/// let input = save_data.input();
|
||||
/// // the most recent returned result
|
||||
/// let result_of_call = save_data.value();
|
||||
/// // whether the call is pending
|
||||
/// let pending = save_data.pending();
|
||||
/// // how many times the action has run
|
||||
/// // useful for reactively updating something else in response to a `dispatch` and response
|
||||
/// let version = save_data.version;
|
||||
///
|
||||
/// // before we do anything
|
||||
/// assert_eq!(input(), None); // no argument yet
|
||||
/// assert_eq!(pending(), false); // isn't pending a response
|
||||
/// assert_eq!(result_of_call(), None); // there's no "last value"
|
||||
/// assert_eq!(version(), 0);
|
||||
/// # if !cfg!(any(feature = "csr", feature = "hydrate")) {
|
||||
/// // dispatch the action
|
||||
/// save_data.dispatch("My todo".to_string());
|
||||
///
|
||||
/// // when we're making the call
|
||||
/// // assert_eq!(input(), Some("My todo".to_string()));
|
||||
/// // assert_eq!(pending(), true); // is pending
|
||||
/// // assert_eq!(result_of_call(), None); // has not yet gotten a response
|
||||
///
|
||||
/// // after call has resolved
|
||||
/// assert_eq!(input(), None); // input clears out after resolved
|
||||
/// assert_eq!(pending(), false); // no longer pending
|
||||
/// assert_eq!(result_of_call(), Some(42));
|
||||
/// assert_eq!(version(), 1);
|
||||
/// # }
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// The input to the `async` function should always be a single value,
|
||||
/// but it can be of any type. The argument is always passed by reference to the
|
||||
/// function, because it is stored in [Action::input] as well.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::create_action;
|
||||
/// # run_scope(|cx| {
|
||||
/// // if there's a single argument, just use that
|
||||
/// let action1 = create_action(cx, |input: &String| {
|
||||
/// let input = input.clone();
|
||||
/// async move { todo!() }
|
||||
/// });
|
||||
///
|
||||
/// // if there are no arguments, use the unit type `()`
|
||||
/// let action2 = create_action(cx, |input: &()| async { todo!() });
|
||||
///
|
||||
/// // if there are multiple arguments, use a tuple
|
||||
/// let action3 = create_action(cx, |input: &(usize, String)| async { todo!() });
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_action<I, O, F, Fu>(cx: Scope, action_fn: F) -> Action<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
@@ -297,6 +510,22 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an [Action] that can be used to call a server function.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use leptos_reactive::run_scope;
|
||||
/// # use leptos_server::{create_server_action, ServerFnError, ServerFn};
|
||||
/// # use leptos_macro::server;
|
||||
///
|
||||
/// #[server(MyServerFn)]
|
||||
/// async fn my_server_fn() -> Result<(), ServerFnError> {
|
||||
/// todo!()
|
||||
/// }
|
||||
///
|
||||
/// # run_scope(|cx| {
|
||||
/// let my_server_action = create_server_action::<MyServerFn>(cx);
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn create_server_action<S>(cx: Scope) -> Action<S, Result<S::Output, ServerFnError>>
|
||||
where
|
||||
S: Clone + ServerFn,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_router"
|
||||
version = "0.0.1"
|
||||
version = "0.0.2"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -6,23 +6,39 @@ use wasm_bindgen::JsCast;
|
||||
|
||||
use crate::{use_navigate, use_resolved_path, ToHref};
|
||||
|
||||
/// Properties that can be passed to the [Form] component, which is an HTML
|
||||
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct FormProps<A>
|
||||
where
|
||||
A: ToHref + 'static,
|
||||
{
|
||||
/// [`method`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-method)
|
||||
/// is the HTTP method to submit the form with (`get` or `post`).
|
||||
#[builder(default, setter(strip_option))]
|
||||
method: Option<&'static str>,
|
||||
action: A,
|
||||
pub method: Option<&'static str>,
|
||||
/// [`action`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-action)
|
||||
/// is the URL that processes the form submission. Takes a [String], [&str], or a reactive
|
||||
/// function that returns a [String].
|
||||
pub action: A,
|
||||
/// [`enctype`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-enctype)
|
||||
/// is the MIME type of the form submission if `method` is `post`.
|
||||
#[builder(default, setter(strip_option))]
|
||||
enctype: Option<String>,
|
||||
children: Box<dyn Fn() -> Vec<Element>>,
|
||||
pub enctype: Option<String>,
|
||||
/// A signal that will be incremented whenever the form is submitted with `post`. This can useful
|
||||
/// for reactively updating a [Resource] or another signal whenever the form has been submitted.
|
||||
#[builder(default, setter(strip_option))]
|
||||
version: Option<RwSignal<usize>>,
|
||||
pub version: Option<RwSignal<usize>>,
|
||||
/// A signal that will be set if the form submission ends in an error.
|
||||
#[builder(default, setter(strip_option))]
|
||||
error: Option<RwSignal<Option<Box<dyn Error>>>>,
|
||||
pub error: Option<RwSignal<Option<Box<dyn Error>>>>,
|
||||
/// Component children; should include the HTML of the form elements.
|
||||
pub children: Box<dyn Fn() -> Vec<Element>>,
|
||||
}
|
||||
|
||||
/// An HTML [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form) progressively
|
||||
/// enhanced to use client-side routing.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Form<A>(cx: Scope, props: FormProps<A>) -> Element
|
||||
where
|
||||
@@ -159,6 +175,9 @@ where
|
||||
if let Some(version) = action_version {
|
||||
version.update(|n| *n += 1);
|
||||
}
|
||||
if let Some(error) = error {
|
||||
error.set(None);
|
||||
}
|
||||
|
||||
if resp.status() == 303 {
|
||||
if let Some(redirect_url) = resp.headers().get("Location") {
|
||||
@@ -190,16 +209,27 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties that can be passed to the [ActionForm] component, which
|
||||
/// automatically turns a server [Action](leptos_server::Action) into an HTML
|
||||
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct ActionFormProps<I, O>
|
||||
where
|
||||
I: 'static,
|
||||
O: 'static,
|
||||
{
|
||||
action: Action<I, O>,
|
||||
children: Box<dyn Fn() -> Vec<Element>>,
|
||||
/// The action from which to build the form. This should include a URL, which can be generated
|
||||
/// by default using [create_server_action](leptos_server::create_server_action) or added
|
||||
/// manually using [leptos_server::Action::using_server_fn].
|
||||
pub action: Action<I, O>,
|
||||
/// Component children; should include the HTML of the form elements.
|
||||
pub children: Box<dyn Fn() -> Vec<Element>>,
|
||||
}
|
||||
|
||||
/// Automatically turns a server [Action](leptos_server::Action) into an HTML
|
||||
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn ActionForm<I, O>(cx: Scope, props: ActionFormProps<I, O>) -> Element
|
||||
where
|
||||
|
||||
@@ -3,12 +3,16 @@ use leptos::leptos_dom::IntoChild;
|
||||
use leptos::*;
|
||||
use typed_builder::TypedBuilder;
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
#[cfg(any(feature = "csr", feature = "hydrate"))]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
use crate::{use_location, use_resolved_path, State};
|
||||
|
||||
/// Describes a value that is either a static or a reactive URL, i.e.,
|
||||
/// a [String], a [&str], or a reactive `Fn() -> String`.
|
||||
pub trait ToHref {
|
||||
/// Converts the (static or reactive) URL into a function that can be called to
|
||||
/// return the URL.
|
||||
fn to_href(&self) -> Box<dyn Fn() -> String + '_>;
|
||||
}
|
||||
|
||||
@@ -35,6 +39,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties that can be passed to the [A] component, which is an HTML
|
||||
/// [`a`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct AProps<C, H>
|
||||
where
|
||||
@@ -43,21 +50,24 @@ where
|
||||
{
|
||||
/// Used to calculate the link's `href` attribute. Will be resolved relative
|
||||
/// to the current route.
|
||||
href: H,
|
||||
pub href: H,
|
||||
/// If `true`, the link is marked active when the location matches exactly;
|
||||
/// if false, link is marked active if the current route starts with it.
|
||||
#[builder(default)]
|
||||
exact: bool,
|
||||
pub exact: bool,
|
||||
/// An object of any type that will be pushed to router state
|
||||
#[builder(default, setter(strip_option))]
|
||||
state: Option<State>,
|
||||
pub state: Option<State>,
|
||||
/// If `true`, the link will not add to the browser's history (so, pressing `Back`
|
||||
/// will skip this page.)
|
||||
#[builder(default)]
|
||||
replace: bool,
|
||||
children: Box<dyn Fn() -> Vec<C>>,
|
||||
pub replace: bool,
|
||||
/// The nodes or elements to be shown inside the link.
|
||||
pub children: Box<dyn Fn() -> Vec<C>>,
|
||||
}
|
||||
|
||||
/// An HTML [`a`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a)
|
||||
/// progressively enhanced to use client-side routing.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn A<C, H>(cx: Scope, props: AProps<C, H>) -> Element
|
||||
where
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::use_route;
|
||||
use leptos::*;
|
||||
|
||||
/// Displays the child route nested in a parent route, allowing you to control exactly where
|
||||
/// that child route is displayed. Renders nothing if there is no nested child.
|
||||
#[component]
|
||||
pub fn Outlet(cx: Scope) -> Child {
|
||||
let route = use_route(cx);
|
||||
|
||||
@@ -5,27 +5,33 @@ use typed_builder::TypedBuilder;
|
||||
|
||||
use crate::{
|
||||
matching::{resolve_path, PathMatch, RouteDefinition, RouteMatch},
|
||||
Action, Loader, ParamsMap, RouterContext,
|
||||
ParamsMap, RouterContext,
|
||||
};
|
||||
|
||||
pub struct ChildlessRoute {}
|
||||
|
||||
/// Properties that can be passed to a [Route] component, which describes
|
||||
/// a portion of the nested layout of the app, specifying the route it should match,
|
||||
/// the element it should display, and data that should be loaded alongside the route.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct RouteProps<E, F>
|
||||
where
|
||||
E: IntoChild,
|
||||
F: Fn(Scope) -> E + 'static,
|
||||
{
|
||||
path: &'static str,
|
||||
element: F,
|
||||
/// The path fragment that this route should match. This can be static (`users`),
|
||||
/// include a parameter (`:id`) or an optional parameter (`:id?`), or match a
|
||||
/// wildcard (`user/*any`).
|
||||
pub path: &'static str,
|
||||
/// The view that should be shown when this route is matched. This can be any function
|
||||
/// that takes a [Scope] and returns an [Element] (like `|cx| view! { cx, <p>"Show this"</p> })`
|
||||
/// or `|cx| view! { cx, <MyComponent/>` } or even, for a component with no props, `MyComponent`).
|
||||
pub element: F,
|
||||
/// `children` may be empty or include nested routes.
|
||||
#[builder(default, setter(strip_option))]
|
||||
loader: Option<Loader>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
action: Option<Action>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
children: Option<Box<dyn Fn() -> Vec<RouteDefinition>>>,
|
||||
pub children: Option<Box<dyn Fn() -> Vec<RouteDefinition>>>,
|
||||
}
|
||||
|
||||
/// Describes a portion of the nested layout of the app, specifying the route it should match,
|
||||
/// the element it should display, and data that should be loaded alongside the route.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Route<E, F>(_cx: Scope, props: RouteProps<E, F>) -> RouteDefinition
|
||||
where
|
||||
@@ -34,13 +40,12 @@ where
|
||||
{
|
||||
RouteDefinition {
|
||||
path: props.path,
|
||||
loader: props.loader,
|
||||
action: props.action,
|
||||
children: props.children.map(|c| c()).unwrap_or_default(),
|
||||
element: Rc::new(move |cx| (props.element)(cx).into_child(cx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Context type that contains information about the current, matched route.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RouteContext {
|
||||
inner: Rc<RouteContextInner>,
|
||||
@@ -57,12 +62,7 @@ impl RouteContext {
|
||||
let base = base.path();
|
||||
let RouteMatch { path_match, route } = matcher()?;
|
||||
let PathMatch { path, .. } = path_match;
|
||||
let RouteDefinition {
|
||||
element,
|
||||
loader,
|
||||
action,
|
||||
..
|
||||
} = route.key;
|
||||
let RouteDefinition { element, .. } = route.key;
|
||||
let params = create_memo(cx, move |_| {
|
||||
matcher()
|
||||
.map(|matched| matched.path_match.params)
|
||||
@@ -74,8 +74,6 @@ impl RouteContext {
|
||||
cx,
|
||||
base_path: base.to_string(),
|
||||
child: Box::new(child),
|
||||
loader,
|
||||
action,
|
||||
path,
|
||||
original_path: route.original_path.to_string(),
|
||||
params,
|
||||
@@ -84,30 +82,27 @@ impl RouteContext {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the reactive scope of the current route.
|
||||
pub fn cx(&self) -> Scope {
|
||||
self.inner.cx
|
||||
}
|
||||
|
||||
/// Returns the URL path of the current route.
|
||||
pub fn path(&self) -> &str {
|
||||
&self.inner.path
|
||||
}
|
||||
|
||||
/// A reactive wrapper for the route parameters that are currently matched.
|
||||
pub fn params(&self) -> Memo<ParamsMap> {
|
||||
self.inner.params
|
||||
}
|
||||
|
||||
pub fn loader(&self) -> &Option<Loader> {
|
||||
&self.inner.loader
|
||||
}
|
||||
|
||||
pub fn base(cx: Scope, path: &str, fallback: Option<fn() -> Element>) -> Self {
|
||||
pub(crate) fn base(cx: Scope, path: &str, fallback: Option<fn() -> Element>) -> Self {
|
||||
Self {
|
||||
inner: Rc::new(RouteContextInner {
|
||||
cx,
|
||||
base_path: path.to_string(),
|
||||
child: Box::new(|| None),
|
||||
loader: None,
|
||||
action: None,
|
||||
path: path.to_string(),
|
||||
original_path: path.to_string(),
|
||||
params: create_memo(cx, |_| ParamsMap::new()),
|
||||
@@ -116,14 +111,17 @@ impl RouteContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a relative route, relative to the current route's path.
|
||||
pub fn resolve_path<'a>(&'a self, to: &'a str) -> Option<Cow<'a, str>> {
|
||||
resolve_path(&self.inner.base_path, to, Some(&self.inner.path))
|
||||
}
|
||||
|
||||
/// The nested child route, if any.
|
||||
pub fn child(&self) -> Option<RouteContext> {
|
||||
(self.inner.child)()
|
||||
}
|
||||
|
||||
/// The view associated with the current route.
|
||||
pub fn outlet(&self) -> impl IntoChild {
|
||||
(self.inner.outlet)()
|
||||
}
|
||||
@@ -133,8 +131,6 @@ pub(crate) struct RouteContextInner {
|
||||
cx: Scope,
|
||||
base_path: String,
|
||||
pub(crate) child: Box<dyn Fn() -> Option<RouteContext>>,
|
||||
pub(crate) loader: Option<Loader>,
|
||||
pub(crate) action: Option<Action>,
|
||||
pub(crate) path: String,
|
||||
pub(crate) original_path: String,
|
||||
pub(crate) params: Memo<ParamsMap>,
|
||||
@@ -160,31 +156,3 @@ impl std::fmt::Debug for RouteContextInner {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IntoChildRoutes {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition>;
|
||||
}
|
||||
|
||||
impl IntoChildRoutes for () {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoChildRoutes for RouteDefinition {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition> {
|
||||
vec![self]
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoChildRoutes for Option<RouteDefinition> {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition> {
|
||||
self.map(|c| vec![c]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoChildRoutes for Vec<RouteDefinition> {
|
||||
fn into_child_routes(self) -> Vec<RouteDefinition> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use cfg_if::cfg_if;
|
||||
use std::ops::IndexMut;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use leptos::*;
|
||||
@@ -20,15 +19,23 @@ use crate::{
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
use crate::{unescape, Url};
|
||||
|
||||
/// Props for the [Router] component, which sets up client-side and server-side routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct RouterProps {
|
||||
/// The base URL for the router. Defaults to "".
|
||||
#[builder(default, setter(strip_option))]
|
||||
base: Option<&'static str>,
|
||||
pub base: Option<&'static str>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
fallback: Option<fn() -> Element>,
|
||||
children: Box<dyn Fn() -> Vec<Element>>,
|
||||
/// A fallback that should be shown if no route is matched.
|
||||
pub fallback: Option<fn() -> Element>,
|
||||
/// The `<Router/>` should usually wrap your whole page. It can contain
|
||||
/// any elements, and should include a [Routes](crate::Routes) component somewhere
|
||||
/// to define and display [Route](crate::Route)s.
|
||||
pub children: Box<dyn Fn() -> Vec<Element>>,
|
||||
}
|
||||
|
||||
/// Provides for client-side and server-side routing. This should usually be somewhere near
|
||||
/// the root of the application.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Router(cx: Scope, props: RouterProps) -> impl IntoChild {
|
||||
// create a new RouterContext and provide it to every component beneath the router
|
||||
@@ -38,6 +45,7 @@ pub fn Router(cx: Scope, props: RouterProps) -> impl IntoChild {
|
||||
props.children
|
||||
}
|
||||
|
||||
/// Context type that contains information about the current router state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouterContext {
|
||||
pub(crate) inner: Rc<RouterContextInner>,
|
||||
@@ -72,7 +80,11 @@ impl std::fmt::Debug for RouterContextInner {
|
||||
}
|
||||
|
||||
impl RouterContext {
|
||||
pub fn new(cx: Scope, base: Option<&'static str>, fallback: Option<fn() -> Element>) -> Self {
|
||||
pub(crate) fn new(
|
||||
cx: Scope,
|
||||
base: Option<&'static str>,
|
||||
fallback: Option<fn() -> Element>,
|
||||
) -> Self {
|
||||
cfg_if! {
|
||||
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
|
||||
let history = use_context::<RouterIntegrationContext>(cx)
|
||||
@@ -157,10 +169,12 @@ impl RouterContext {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
/// The current [`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/Location/pathname).
|
||||
pub fn pathname(&self) -> Memo<String> {
|
||||
self.inner.location.pathname
|
||||
}
|
||||
|
||||
/// The [RouteContext] of the base route.
|
||||
pub fn base(&self) -> RouteContext {
|
||||
self.inner.base.clone()
|
||||
}
|
||||
@@ -175,7 +189,6 @@ impl RouterContextInner {
|
||||
let cx = self.cx;
|
||||
let this = Rc::clone(&self);
|
||||
|
||||
// TODO untrack causes an error here
|
||||
cx.untrack(move || {
|
||||
let resolved_to = if options.resolve {
|
||||
this.base.resolve_path(to)
|
||||
@@ -186,15 +199,16 @@ impl RouterContextInner {
|
||||
match resolved_to {
|
||||
None => Err(NavigationError::NotRoutable(to.to_string())),
|
||||
Some(resolved_to) => {
|
||||
let resolved_to = resolved_to.to_string();
|
||||
if self.referrers.borrow().len() > 32 {
|
||||
return Err(NavigationError::MaxRedirects);
|
||||
}
|
||||
|
||||
if resolved_to != (this.reference)() || options.state != (this.state).get() {
|
||||
if resolved_to != this.reference.get() || options.state != (this.state).get() {
|
||||
if cfg!(feature = "server") {
|
||||
// TODO server out
|
||||
self.history.navigate(&LocationChange {
|
||||
value: resolved_to.to_string(),
|
||||
value: resolved_to,
|
||||
replace: options.replace,
|
||||
scroll: options.scroll,
|
||||
state: options.state.clone(),
|
||||
@@ -218,23 +232,26 @@ impl RouterContextInner {
|
||||
let referrers = self.referrers.clone();
|
||||
let this = Rc::clone(&self);
|
||||
//move || {
|
||||
set_reference.update({
|
||||
let resolved = resolved_to.to_string();
|
||||
move |r| *r = resolved
|
||||
|
||||
let resolved = resolved_to.to_string();
|
||||
let state = options.state.clone();
|
||||
queue_microtask(move || {
|
||||
set_reference.update(move |r| *r = resolved);
|
||||
|
||||
set_state.update({
|
||||
let next_state = state.clone();
|
||||
move |state| *state = next_state
|
||||
});
|
||||
if referrers.borrow().len() == len {
|
||||
this.navigate_end(LocationChange {
|
||||
value: resolved_to.to_string(),
|
||||
replace: false,
|
||||
scroll: true,
|
||||
state,
|
||||
})
|
||||
//}
|
||||
}
|
||||
});
|
||||
set_state.update({
|
||||
let next_state = options.state.clone();
|
||||
move |state| *state = next_state
|
||||
});
|
||||
if referrers.borrow().len() == len {
|
||||
this.navigate_end(LocationChange {
|
||||
value: resolved_to.to_string(),
|
||||
replace: false,
|
||||
scroll: true,
|
||||
state: options.state.clone(),
|
||||
})
|
||||
//}
|
||||
}
|
||||
//});
|
||||
}
|
||||
}
|
||||
@@ -336,18 +353,30 @@ impl RouterContextInner {
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that occurs during navigation.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NavigationError {
|
||||
/// The given path is not routable.
|
||||
#[error("Path {0:?} is not routable")]
|
||||
NotRoutable(String),
|
||||
/// Too many redirects occurred during routing (prevents and infinite loop.)
|
||||
#[error("Too many redirects")]
|
||||
MaxRedirects,
|
||||
}
|
||||
|
||||
/// Options that can be used to configure a navigation. Used with [use_navigate](crate::use_navigate).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NavigateOptions {
|
||||
/// Whether the URL being navigated to should be resolved relative to the current route.
|
||||
pub resolve: bool,
|
||||
/// If `true` the new location will replace the current route in the history stack, meaning
|
||||
/// the "back" button will skip over the current route. (Defaults to `false`).
|
||||
pub replace: bool,
|
||||
/// If `true`, the router will scroll to the top of the window at the end of navigation.
|
||||
/// Defaults to `true.
|
||||
pub scroll: bool,
|
||||
/// [State](https://developer.mozilla.org/en-US/docs/Web/API/History/state) that should be pushed
|
||||
/// onto the history stack during navigation.
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use typed_builder::TypedBuilder;
|
||||
|
||||
use crate::{matching::{expand_optionals, join_paths, Branch, Matcher, RouteDefinition, get_route_matches, RouteMatch}, RouterContext, RouteContext};
|
||||
|
||||
/// Props for the [Routes] component, which contains route definitions and manages routing.
|
||||
#[derive(TypedBuilder)]
|
||||
pub struct RoutesProps {
|
||||
#[builder(default, setter(strip_option))]
|
||||
@@ -12,6 +13,9 @@ pub struct RoutesProps {
|
||||
children: Box<dyn Fn() -> Vec<RouteDefinition>>,
|
||||
}
|
||||
|
||||
/// Contains route definitions and manages the actual routing process.
|
||||
///
|
||||
/// You should locate the `<Routes/>` component wherever on the page you want the routes to appear.
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Routes(cx: Scope, props: RoutesProps) -> impl IntoChild {
|
||||
let router = use_context::<RouterContext>(cx).unwrap_or_else(|| {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
use std::{future::Future, rc::Rc};
|
||||
|
||||
use crate::{PinnedFuture, Request, Response};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Action {
|
||||
f: Rc<dyn Fn(&Request) -> PinnedFuture<Response>>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub async fn send(&self, req: &Request) -> Response {
|
||||
(self.f)(req).await
|
||||
}
|
||||
}
|
||||
|
||||
impl<F, Fu> From<F> for Action
|
||||
where
|
||||
F: Fn(&Request) -> Fu + 'static,
|
||||
Fu: Future<Output = Response> + 'static,
|
||||
{
|
||||
fn from(f: F) -> Self {
|
||||
Self {
|
||||
f: Rc::new(move |req| Box::pin(f(req))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Action {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Action").finish()
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
use std::{any::Any, fmt::Debug, future::Future, rc::Rc};
|
||||
|
||||
use leptos::*;
|
||||
|
||||
use crate::{use_location, use_params_map, use_route, ParamsMap, PinnedFuture, Url};
|
||||
|
||||
// SSR and CSR both do the work in their own environment and return it as a resource
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub fn use_loader<T>(cx: Scope) -> Resource<(ParamsMap, Url), T>
|
||||
where
|
||||
T: Clone + Debug + Serializable + 'static,
|
||||
{
|
||||
let route = use_route(cx);
|
||||
let params = use_params_map(cx);
|
||||
let loader = route.loader().clone().unwrap_or_else(|| {
|
||||
debug_warn!(
|
||||
"use_loader() called on a route without a loader: {:?}",
|
||||
route.path()
|
||||
);
|
||||
panic!()
|
||||
});
|
||||
|
||||
let location = use_location(cx);
|
||||
let route = use_route(cx);
|
||||
let url = move || Url {
|
||||
origin: String::default(), // don't care what the origin is for this purpose
|
||||
pathname: route.path().into(), // only use this route path, not all matched routes
|
||||
search: location.search.get(), // reload when any of query string changes
|
||||
hash: String::default(), // hash is only client-side, shouldn't refire
|
||||
};
|
||||
|
||||
let loader = loader.data.clone();
|
||||
|
||||
create_resource(
|
||||
cx,
|
||||
move || (params.get(), url()),
|
||||
move |(params, url)| {
|
||||
let loader = loader.clone();
|
||||
async move {
|
||||
let any_data = (loader.clone())(cx, params, url).await;
|
||||
any_data
|
||||
.as_any()
|
||||
.downcast_ref::<T>()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"use_loader() could not downcast to {:?}",
|
||||
std::any::type_name::<T>(),
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// In hydration mode, only run the loader on the server
|
||||
#[cfg(feature = "hydrate")]
|
||||
pub fn use_loader<T>(cx: Scope) -> Resource<(ParamsMap, Url), T>
|
||||
where
|
||||
T: Clone + Debug + Serializable + 'static,
|
||||
{
|
||||
use wasm_bindgen::{JsCast, UnwrapThrowExt};
|
||||
|
||||
use crate::use_query_map;
|
||||
|
||||
let route = use_route(cx);
|
||||
let params = use_params_map(cx);
|
||||
|
||||
let location = use_location(cx);
|
||||
let route = use_route(cx);
|
||||
let url = move || Url {
|
||||
origin: String::default(), // don't care what the origin is for this purpose
|
||||
pathname: route.path().into(), // only use this route path, not all matched routes
|
||||
search: location.search.get(), // reload when any of query string changes
|
||||
hash: String::default(), // hash is only client-side, shouldn't refire
|
||||
};
|
||||
|
||||
create_resource(
|
||||
cx,
|
||||
move || (params.get(), url()),
|
||||
move |(params, url)| async move {
|
||||
let route = use_route(cx);
|
||||
let query = use_query_map(cx);
|
||||
|
||||
let mut opts = web_sys::RequestInit::new();
|
||||
opts.method("GET");
|
||||
let url = format!("{}{}", route.path(), query.get().to_query_string());
|
||||
|
||||
let request = web_sys::Request::new_with_str_and_init(&url, &opts).unwrap_throw();
|
||||
request
|
||||
.headers()
|
||||
.set("Accept", "application/json")
|
||||
.unwrap_throw();
|
||||
|
||||
let window = web_sys::window().unwrap_throw();
|
||||
let resp_value =
|
||||
wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.unwrap_throw();
|
||||
let resp = resp_value.unchecked_into::<web_sys::Response>();
|
||||
let text = wasm_bindgen_futures::JsFuture::from(resp.text().unwrap_throw())
|
||||
.await
|
||||
.unwrap_throw()
|
||||
.as_string()
|
||||
.unwrap_throw();
|
||||
|
||||
T::from_json(&text).expect_throw(
|
||||
"couldn't deserialize loader data from serde-lite intermediate format",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub trait AnySerialize {
|
||||
fn serialize(&self) -> Option<String>;
|
||||
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
impl<T> AnySerialize for T
|
||||
where
|
||||
T: Any + Serializable + 'static,
|
||||
{
|
||||
fn serialize(&self) -> Option<String> {
|
||||
self.to_json().ok()
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Loader {
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub(crate) data: Rc<dyn Fn(Scope, ParamsMap, Url) -> PinnedFuture<Box<dyn AnySerialize>>>,
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub fn call_loader(&self, cx: Scope) -> PinnedFuture<Box<dyn AnySerialize>> {
|
||||
let route = use_route(cx);
|
||||
let params = use_params_map(cx).get();
|
||||
let location = use_location(cx);
|
||||
let url = Url {
|
||||
origin: String::default(), // don't care what the origin is for this purpose
|
||||
pathname: route.path().into(), // only use this route path, not all matched routes
|
||||
search: location.search.get(), // reload when any of query string changes
|
||||
hash: String::default(), // hash is only client-side, shouldn't refire
|
||||
};
|
||||
(self.data)(cx, params, url)
|
||||
}
|
||||
}
|
||||
|
||||
impl<F, Fu, T> From<F> for Loader
|
||||
where
|
||||
F: Fn(Scope, ParamsMap, Url) -> Fu + 'static,
|
||||
Fu: Future<Output = T> + 'static,
|
||||
T: Any + Serializable + 'static,
|
||||
{
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
fn from(f: F) -> Self {
|
||||
let wrapped_fn = move |cx, params, url| {
|
||||
let res = f(cx, params, url);
|
||||
Box::pin(async move { Box::new(res.await) as Box<dyn AnySerialize> })
|
||||
as PinnedFuture<Box<dyn AnySerialize>>
|
||||
};
|
||||
|
||||
Self {
|
||||
data: Rc::new(wrapped_fn),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn from(f: F) -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Loader {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Loader").finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ssr", not(feature = "hydrate")))]
|
||||
pub async fn loader_to_json(view: impl Fn(Scope) -> String + 'static) -> Option<String> {
|
||||
let (data, _, disposer) = run_scope_undisposed(move |cx| async move {
|
||||
let _shell = view(cx);
|
||||
|
||||
let mut route = use_context::<crate::RouteContext>(cx)?;
|
||||
// get the innermost route matched by this path
|
||||
while let Some(child) = route.child() {
|
||||
route = child;
|
||||
}
|
||||
let data = route
|
||||
.loader()
|
||||
.as_ref()
|
||||
.map(|loader| loader.call_loader(cx))?;
|
||||
|
||||
data.await.serialize()
|
||||
});
|
||||
let data = data.await;
|
||||
disposer.dispose();
|
||||
data
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
mod action;
|
||||
mod loader;
|
||||
|
||||
use std::{future::Future, pin::Pin};
|
||||
|
||||
pub use action::*;
|
||||
pub use loader::*;
|
||||
|
||||
pub(crate) type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;
|
||||
@@ -1,27 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum RouterError {
|
||||
#[error("loader found no data at this path")]
|
||||
NoMatch(String),
|
||||
#[error("route was matched, but loader returned None")]
|
||||
NotFound(String),
|
||||
#[error("could not find parameter {0}")]
|
||||
MissingParam(String),
|
||||
#[error("failed to deserialize parameters")]
|
||||
Params(Rc<dyn std::error::Error + Send + Sync>),
|
||||
}
|
||||
|
||||
impl PartialEq for RouterError {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::NoMatch(l0), Self::NoMatch(r0)) => l0 == r0,
|
||||
(Self::NotFound(l0), Self::NotFound(r0)) => l0 == r0,
|
||||
(Self::MissingParam(l0), Self::MissingParam(r0)) => l0 == r0,
|
||||
(Self::Params(_), Self::Params(_)) => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use crate::{State, Url};
|
||||
|
||||
use super::params::ParamsMap;
|
||||
|
||||
/// Creates a reactive location from the given path and state.
|
||||
pub fn create_location(cx: Scope, path: ReadSignal<String>, state: ReadSignal<State>) -> Location {
|
||||
let url = create_memo(cx, move |prev: Option<&Url>| {
|
||||
path.with(|path| match Url::try_from(path.as_str()) {
|
||||
@@ -29,20 +30,33 @@ pub fn create_location(cx: Scope, path: ReadSignal<String>, state: ReadSignal<St
|
||||
}
|
||||
}
|
||||
|
||||
/// A reactive description of the current URL, containing equivalents to the local parts of
|
||||
/// the browser's [`Location`](https://developer.mozilla.org/en-US/docs/Web/API/Location).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Location {
|
||||
pub query: Memo<ParamsMap>,
|
||||
/// The path of the URL, not containing the query string or hash fragment.
|
||||
pub pathname: Memo<String>,
|
||||
/// The raw query string.
|
||||
pub search: Memo<String>,
|
||||
/// The query string parsed into its key-value pairs.
|
||||
pub query: Memo<ParamsMap>,
|
||||
/// The hash fragment.
|
||||
pub hash: Memo<String>,
|
||||
/// The [`state`](https://developer.mozilla.org/en-US/docs/Web/API/History/state) at the top of the history stack.
|
||||
pub state: ReadSignal<State>,
|
||||
}
|
||||
|
||||
/// A description of a navigation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LocationChange {
|
||||
/// The new URL.
|
||||
pub value: String,
|
||||
/// If true, the new location will replace the current one in the history stack, i.e.,
|
||||
/// clicking the "back" button will not return to the current location.
|
||||
pub replace: bool,
|
||||
/// If true, the router will scroll to the top of the page at the end of the navigation.
|
||||
pub scroll: bool,
|
||||
/// The [`state`](https://developer.mozilla.org/en-US/docs/Web/API/History/state) that will be added during navigation.
|
||||
pub state: State,
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,19 @@ impl std::fmt::Debug for RouterIntegrationContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// The [Router](crate::Router) relies on a [RouterIntegrationContext], which tells the router
|
||||
/// how to find things like the current URL, and how to navigate to a new page. The [History] trait
|
||||
/// can be implemented on any type to provide this information.
|
||||
pub trait History {
|
||||
/// A signal that updates whenever the current location changes.
|
||||
fn location(&self, cx: Scope) -> ReadSignal<LocationChange>;
|
||||
|
||||
/// Called to navigate to a new location.
|
||||
fn navigate(&self, loc: &LocationChange);
|
||||
}
|
||||
|
||||
/// The default integration when you are running in the browser, which uses
|
||||
/// the [`History API`](https://developer.mozilla.org/en-US/docs/Web/API/History).
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct BrowserIntegration {}
|
||||
|
||||
@@ -66,7 +73,7 @@ impl History for BrowserIntegration {
|
||||
) {
|
||||
log::error!("{e:#?}");
|
||||
}
|
||||
set_location(Self::current());
|
||||
set_location.set(Self::current());
|
||||
} else {
|
||||
log::warn!("RouterContext not found");
|
||||
}
|
||||
@@ -105,10 +112,23 @@ impl History for BrowserIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
/// The wrapper type that the [Router](crate::Router) uses to interact with a [History].
|
||||
/// This is automatically provided in the browser. For the server, it should be provided
|
||||
/// as a context.
|
||||
///
|
||||
/// ```
|
||||
/// # use leptos_router::*;
|
||||
/// # use leptos::*;
|
||||
/// # run_scope(|cx| {
|
||||
/// let integration = ServerIntegration { path: "insert/current/path/here".to_string() };
|
||||
/// provide_context(cx, RouterIntegrationContext::new(integration));
|
||||
/// # });
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct RouterIntegrationContext(pub Rc<dyn History>);
|
||||
|
||||
impl RouterIntegrationContext {
|
||||
/// Creates a new router integration.
|
||||
pub fn new(history: impl History + 'static) -> Self {
|
||||
Self(Rc::new(history))
|
||||
}
|
||||
@@ -124,6 +144,7 @@ impl History for RouterIntegrationContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic router integration for the server side. All its need is the current path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerIntegration {
|
||||
pub path: String,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use std::{rc::Rc, str::FromStr};
|
||||
|
||||
use linear_map::LinearMap;
|
||||
use std::{rc::Rc, str::FromStr};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::RouterError;
|
||||
|
||||
/// A key-value map of the current named route params and their values.
|
||||
// For now, implemented with a `LinearMap`, as `n` is small enough
|
||||
// that O(n) iteration over a vectorized map is (*probably*) more space-
|
||||
// and time-efficient than hashing and using an actual `HashMap`
|
||||
@@ -11,27 +10,33 @@ use crate::RouterError;
|
||||
pub struct ParamsMap(pub LinearMap<String, String>);
|
||||
|
||||
impl ParamsMap {
|
||||
/// Creates an empty map.
|
||||
pub fn new() -> Self {
|
||||
Self(LinearMap::new())
|
||||
}
|
||||
|
||||
/// Creates an empty map with the given capacity.
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self(LinearMap::with_capacity(capacity))
|
||||
}
|
||||
|
||||
/// Inserts a value into the map.
|
||||
pub fn insert(&mut self, key: String, value: String) -> Option<String> {
|
||||
self.0.insert(key, value)
|
||||
}
|
||||
|
||||
/// Gets a value from the map.
|
||||
pub fn get(&self, key: &str) -> Option<&String> {
|
||||
self.0.get(key)
|
||||
}
|
||||
|
||||
/// Removes a value from the map.
|
||||
pub fn remove(&mut self, key: &str) -> Option<String> {
|
||||
self.0.remove(key)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "csr", feature = "hydrate", feature = "ssr"))]
|
||||
/// Converts the map to a query string.
|
||||
pub fn to_query_string(&self) -> String {
|
||||
use crate::history::url::escape;
|
||||
let mut buf = String::from("?");
|
||||
@@ -51,6 +56,16 @@ impl Default for ParamsMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// A declarative way of creating a [ParamsMap].
|
||||
///
|
||||
/// ```
|
||||
/// # use leptos_router::params_map;
|
||||
/// let map = params_map! {
|
||||
/// "id".to_string() => "1".to_string()
|
||||
/// };
|
||||
/// assert_eq!(map.get("id"), Some(&"1".to_string()));
|
||||
/// assert_eq!(map.get("missing"), None)
|
||||
/// ```
|
||||
// Adapted from hash_map! in common_macros crate
|
||||
// Copyright (c) 2019 Philipp Korber
|
||||
// https://github.com/rustonaut/common_macros/blob/master/src/lib.rs
|
||||
@@ -68,15 +83,19 @@ macro_rules! params_map {
|
||||
});
|
||||
}
|
||||
|
||||
/// A simple method of deserializing key-value data (like route params or URL search)
|
||||
/// into a concrete data type. `Self` should typically be a struct in which
|
||||
/// each field's type implements [FromStr].
|
||||
pub trait Params
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
fn from_map(map: &ParamsMap) -> Result<Self, RouterError>;
|
||||
/// Attempts to deserialize the map into the given type.
|
||||
fn from_map(map: &ParamsMap) -> Result<Self, ParamsError>;
|
||||
}
|
||||
|
||||
impl Params for () {
|
||||
fn from_map(_map: &ParamsMap) -> Result<Self, RouterError> {
|
||||
fn from_map(_map: &ParamsMap) -> Result<Self, ParamsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -85,22 +104,22 @@ pub trait IntoParam
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
fn into_param(value: Option<&str>, name: &str) -> Result<Self, RouterError>;
|
||||
fn into_param(value: Option<&str>, name: &str) -> Result<Self, ParamsError>;
|
||||
}
|
||||
|
||||
impl<T> IntoParam for Option<T>
|
||||
where
|
||||
T: FromStr,
|
||||
<T as FromStr>::Err: std::error::Error + Send + Sync + 'static,
|
||||
<T as FromStr>::Err: std::error::Error + 'static,
|
||||
{
|
||||
fn into_param(value: Option<&str>, _name: &str) -> Result<Self, RouterError> {
|
||||
fn into_param(value: Option<&str>, _name: &str) -> Result<Self, ParamsError> {
|
||||
match value {
|
||||
None => Ok(None),
|
||||
Some(value) => match T::from_str(value) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
Err(RouterError::Params(Rc::new(e)))
|
||||
Err(ParamsError::Params(Rc::new(e)))
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -115,8 +134,29 @@ where
|
||||
T: FromStr + NotOption,
|
||||
<T as FromStr>::Err: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
fn into_param(value: Option<&str>, name: &str) -> Result<Self, RouterError> {
|
||||
let value = value.ok_or_else(|| RouterError::MissingParam(name.to_string()))?;
|
||||
Self::from_str(value).map_err(|e| RouterError::Params(Rc::new(e)))
|
||||
fn into_param(value: Option<&str>, name: &str) -> Result<Self, ParamsError> {
|
||||
let value = value.ok_or_else(|| ParamsError::MissingParam(name.to_string()))?;
|
||||
Self::from_str(value).map_err(|e| ParamsError::Params(Rc::new(e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur while parsing params using [IntoParams].
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum ParamsError {
|
||||
/// A field was missing from the route params.
|
||||
#[error("could not find parameter {0}")]
|
||||
MissingParam(String),
|
||||
/// Something went wrong while deserializing a field.
|
||||
#[error("failed to deserialize parameters")]
|
||||
Params(Rc<dyn std::error::Error>),
|
||||
}
|
||||
|
||||
impl PartialEq for ParamsError {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Self::MissingParam(l0), Self::MissingParam(r0)) => l0 == r0,
|
||||
(Self::Params(_), Self::Params(_)) => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ use std::rc::Rc;
|
||||
use leptos::{create_memo, use_context, Memo, Scope};
|
||||
|
||||
use crate::{
|
||||
Location, NavigateOptions, NavigationError, Params, ParamsMap, RouteContext, RouterContext,
|
||||
RouterError,
|
||||
Location, NavigateOptions, NavigationError, Params, ParamsError, ParamsMap, RouteContext,
|
||||
RouterContext,
|
||||
};
|
||||
|
||||
/// Returns the current [RouterContext], containing information about the router's state.
|
||||
pub fn use_router(cx: Scope) -> RouterContext {
|
||||
if let Some(router) = use_context::<RouterContext>(cx) {
|
||||
router
|
||||
@@ -16,15 +17,24 @@ pub fn use_router(cx: Scope) -> RouterContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current [RouteContext], containing information about the matched route.
|
||||
pub fn use_route(cx: Scope) -> RouteContext {
|
||||
use_context::<RouteContext>(cx).unwrap_or_else(|| use_router(cx).base())
|
||||
}
|
||||
|
||||
/// Returns the current [Location], which contains reactive variables
|
||||
pub fn use_location(cx: Scope) -> Location {
|
||||
use_router(cx).inner.location.clone()
|
||||
}
|
||||
|
||||
pub fn use_params<T: Params>(cx: Scope) -> Memo<Result<T, RouterError>>
|
||||
/// Returns a raw key-value map of route params.
|
||||
pub fn use_params_map(cx: Scope) -> Memo<ParamsMap> {
|
||||
let route = use_route(cx);
|
||||
route.params()
|
||||
}
|
||||
|
||||
/// Returns the current route params, parsed into the given type, or an error.
|
||||
pub fn use_params<T: Params>(cx: Scope) -> Memo<Result<T, ParamsError>>
|
||||
where
|
||||
T: PartialEq + std::fmt::Debug,
|
||||
{
|
||||
@@ -32,12 +42,13 @@ where
|
||||
create_memo(cx, move |_| route.params().with(T::from_map))
|
||||
}
|
||||
|
||||
pub fn use_params_map(cx: Scope) -> Memo<ParamsMap> {
|
||||
let route = use_route(cx);
|
||||
route.params()
|
||||
/// Returns a raw key-value map of the URL search query.
|
||||
pub fn use_query_map(cx: Scope) -> Memo<ParamsMap> {
|
||||
use_router(cx).inner.location.query
|
||||
}
|
||||
|
||||
pub fn use_query<T: Params>(cx: Scope) -> Memo<Result<T, RouterError>>
|
||||
/// Returns the current URL search query, parsed into the given type, or an error.
|
||||
pub fn use_query<T: Params>(cx: Scope) -> Memo<Result<T, ParamsError>>
|
||||
where
|
||||
T: PartialEq + std::fmt::Debug,
|
||||
{
|
||||
@@ -47,16 +58,14 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
pub fn use_query_map(cx: Scope) -> Memo<ParamsMap> {
|
||||
use_router(cx).inner.location.query
|
||||
}
|
||||
|
||||
/// Resolves the given path relative to the current route.
|
||||
pub fn use_resolved_path(cx: Scope, path: impl Fn() -> String + 'static) -> Memo<Option<String>> {
|
||||
let route = use_route(cx);
|
||||
|
||||
create_memo(cx, move |_| route.resolve_path(&path()).map(String::from))
|
||||
}
|
||||
|
||||
/// Returns a function that can be used to navigate to a new route.
|
||||
pub fn use_navigate(cx: Scope) -> impl Fn(&str, NavigateOptions) -> Result<(), NavigationError> {
|
||||
let router = use_router(cx);
|
||||
move |to, options| Rc::clone(&router.inner).navigate_from_route(to, &options)
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
//! apps (SPAs), server-side rendering/multi-page apps (MPAs), or to synchronize
|
||||
//! state between the two.
|
||||
//!
|
||||
//! **Note:** This is a work in progress. Docs are still being written,
|
||||
//! and some features are only stubs, in particular
|
||||
//! - passing client-side route [State] in [History.state](https://developer.mozilla.org/en-US/docs/Web/API/History/state))
|
||||
//! - data mutations using [Action]s and [Form] `method="POST"`
|
||||
//! **Note:** This is a work in progress. Docs are still being written, in particular
|
||||
//! passing client-side route [State] in [History.state](https://developer.mozilla.org/en-US/docs/Web/API/History/state))
|
||||
//!
|
||||
//! ## Philosophy
|
||||
//!
|
||||
@@ -62,20 +60,13 @@
|
||||
//! <Route
|
||||
//! path=""
|
||||
//! element=move |cx| view! { cx, <ContactList/> }
|
||||
//! // <ContactList/> needs all the contacts, so we provide the loader here
|
||||
//! // this will only be reloaded if we navigate away to /about and back to / or /:id
|
||||
//! loader=contact_list_data.into()
|
||||
//! >
|
||||
//! // users like /gbj or /bob
|
||||
//! <Route
|
||||
//! path=":id"
|
||||
//! // <Contact/> needs contact data, so we provide the loader here
|
||||
//! // this will be reloaded when the :id changes
|
||||
//! loader=contact_data.into()
|
||||
//! element=move |cx| view! { cx, <Contact/> }
|
||||
//! />
|
||||
//! // a fallback if the /:id segment is missing from the URL
|
||||
//! // doesn't need any data, so no loader is provided
|
||||
//! <Route
|
||||
//! path=""
|
||||
//! element=move |_| view! { cx, <p class="contact">"Select a contact."</p> }
|
||||
@@ -93,33 +84,46 @@
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! // Loaders are async functions that have access to the reactive scope,
|
||||
//! // map of matched URL params for that route, and the URL
|
||||
//! // They are reloaded whenever the params or URL change
|
||||
//!
|
||||
//! type ContactSummary = (); // TODO!
|
||||
//! type Contact = (); // TODO!()
|
||||
//!
|
||||
//! // contact_data reruns whenever the :id param changes
|
||||
//! async fn contact_data(_cx: Scope, _params: ParamsMap, url: Url) -> Contact {
|
||||
//! async fn contact_data(id: String) -> Contact {
|
||||
//! todo!()
|
||||
//! }
|
||||
//!
|
||||
//! // contact_list_data *doesn't* rerun when the :id changes,
|
||||
//! // because that param is nested lower than the <ContactList/> route
|
||||
//! async fn contact_list_data(_cx: Scope, _params: ParamsMap, url: Url) -> Vec<ContactSummary> {
|
||||
//! async fn contact_list_data() -> Vec<ContactSummary> {
|
||||
//! todo!()
|
||||
//! }
|
||||
//!
|
||||
//! #[component]
|
||||
//! fn ContactList(cx: Scope) -> Element {
|
||||
//! let data = use_loader::<Vec<ContactSummary>>(cx);
|
||||
//! todo!()
|
||||
//! // loads the contact list data once; doesn't reload when nested routes change
|
||||
//! let contacts = create_resource(cx, || (), |_| contact_list_data());
|
||||
//! view! {
|
||||
//! cx,
|
||||
//! <div>
|
||||
//! // show the contacts
|
||||
//! <ul>
|
||||
//! {move || contacts.read().map(|contacts| view! { cx, <li>"todo contact info"</li> } )}
|
||||
//! </ul>
|
||||
//!
|
||||
//! // insert the nested child route here
|
||||
//! <Outlet/>
|
||||
//! </div>
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! #[component]
|
||||
//! fn Contact(cx: Scope) -> Element {
|
||||
//! let data = use_loader::<Contact>(cx);
|
||||
//! let params = use_params_map(cx);
|
||||
//! let data = create_resource(
|
||||
//! cx,
|
||||
//! move || params.with(|p| p.get("id").cloned().unwrap_or_default()),
|
||||
//! move |id| contact_data(id)
|
||||
//! );
|
||||
//! todo!()
|
||||
//! }
|
||||
//!
|
||||
@@ -136,16 +140,12 @@
|
||||
#![feature(type_name_of_val)]
|
||||
|
||||
mod components;
|
||||
mod data;
|
||||
mod error;
|
||||
mod fetch;
|
||||
mod history;
|
||||
mod hooks;
|
||||
mod matching;
|
||||
|
||||
pub use components::*;
|
||||
pub use data::*;
|
||||
pub use error::*;
|
||||
pub use fetch::*;
|
||||
pub use history::*;
|
||||
pub use hooks::*;
|
||||
|
||||
@@ -25,9 +25,12 @@ pub(crate) fn get_route_matches(branches: Vec<Branch>, location: String) -> Vec<
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Describes a branch of the route tree.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Branch {
|
||||
/// All the routes contained in the branch.
|
||||
pub routes: Vec<RouteData>,
|
||||
/// How closely this branch matches the current URL.
|
||||
pub score: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,9 @@ use std::rc::Rc;
|
||||
use leptos::leptos_dom::Child;
|
||||
use leptos::*;
|
||||
|
||||
use crate::{Action, Loader};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RouteDefinition {
|
||||
pub path: &'static str,
|
||||
pub loader: Option<Loader>,
|
||||
pub action: Option<Action>,
|
||||
pub children: Vec<RouteDefinition>,
|
||||
pub element: Rc<dyn Fn(Scope) -> Child>,
|
||||
}
|
||||
@@ -18,8 +14,6 @@ impl std::fmt::Debug for RouteDefinition {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RouteDefinition")
|
||||
.field("path", &self.path)
|
||||
.field("loader", &self.loader)
|
||||
.field("action", &self.action)
|
||||
.field("children", &self.children)
|
||||
.finish()
|
||||
}
|
||||
@@ -35,8 +29,6 @@ impl Default for RouteDefinition {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: Default::default(),
|
||||
loader: Default::default(),
|
||||
action: Default::default(),
|
||||
children: Default::default(),
|
||||
element: Rc::new(|_| Child::Null),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user