Compare commits

..

4 Commits
v0.2.2 ... perf

Author SHA1 Message Date
Greg Johnston
9ae3f6f7f0 fix 2023-02-26 07:11:27 -05:00
Greg Johnston
73f67d9a55 chore: warnings 2023-02-25 23:00:07 -05:00
Greg Johnston
8643126d09 perf: optimizations to event delegation 2023-02-25 22:57:25 -05:00
Greg Johnston
99d28ed045 perf: optimizations to <EachItem/> creation 2023-02-25 22:57:19 -05:00
124 changed files with 1318 additions and 5624 deletions

View File

@@ -1,48 +0,0 @@
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@v3
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.rust }}
override: true
components: rustfmt
- name: Add wasm32-unknown-unknown
run: rustup target add wasm32-unknown-unknown
- name: Setup cargo-make
uses: davidB/rust-cargo-make@v1
- name: Cargo generate-lockfile
run: cargo generate-lockfile
- name: Run Rustfmt
run: cargo fmt -- --check
- uses: Swatinem/rust-cache@v2
- name: Run cargo check on all examples
run: cargo make check-examples

View File

@@ -1,48 +0,0 @@
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@v3
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.rust }}
override: true
components: rustfmt
- name: Add wasm32-unknown-unknown
run: rustup target add wasm32-unknown-unknown
- name: Setup cargo-make
uses: davidB/rust-cargo-make@v1
- name: Cargo generate-lockfile
run: cargo generate-lockfile
- name: Run Rustfmt
run: cargo fmt -- --check
- uses: Swatinem/rust-cache@v2
- name: Run cargo check on all libraries
run: cargo make check

View File

@@ -45,4 +45,4 @@ jobs:
- uses: Swatinem/rust-cache@v2
- name: Run tests with all features
run: cargo make test
run: cargo make ci

View File

@@ -4,13 +4,9 @@ members = [
"leptos",
"leptos_dom",
"leptos_config",
"leptos_hot_reload",
"leptos_macro",
"leptos_reactive",
"leptos_server",
"server_fn",
"server_fn_macro",
"server_fn/server_fn_macro_default",
# integrations
"integrations/actix",
@@ -25,22 +21,18 @@ members = [
exclude = ["benchmarks", "examples"]
[workspace.package]
version = "0.2.2"
version = "0.2.0"
[workspace.dependencies]
leptos = { path = "./leptos", default-features = false, version = "0.2.2" }
leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.2" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.2.2" }
leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.2" }
leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.2" }
leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.2" }
server_fn = { path = "./server_fn", default-features = false, version = "0.2.2" }
server_fn_macro = { path = "./server_fn_macro", default-features = false, version = "0.2.2" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", default-features = false, version = "0.2.2" }
leptos_config = { path = "./leptos_config", default-features = false, version = "0.2.2" }
leptos_router = { path = "./router", version = "0.2.2" }
leptos_meta = { path = "./meta", default-feature = false, version = "0.2.2" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.2.2" }
leptos = { path = "./leptos", default-features = false, version = "0.2.0" }
leptos_dom = { path = "./leptos_dom", default-features = false, version = "0.2.0" }
leptos_macro = { path = "./leptos_macro", default-features = false, version = "0.2.0" }
leptos_reactive = { path = "./leptos_reactive", default-features = false, version = "0.2.0" }
leptos_server = { path = "./leptos_server", default-features = false, version = "0.2.0" }
leptos_config = { path = "./leptos_config", default-features = false, version = "0.2.0" }
leptos_router = { path = "./router", version = "0.2.0" }
leptos_meta = { path = "./meta", default-feature = false, version = "0.2.0" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.2.0" }
[profile.release]
codegen-units = 1

View File

@@ -8,20 +8,20 @@
default_to_workspace = false
[tasks.ci]
dependencies = ["check", "check-examples", "test"]
dependencies = ["build", "check-examples", "test"]
[tasks.check]
[tasks.build]
clear = true
dependencies = ["check-all", "check-wasm"]
dependencies = ["build-all", "build-wasm"]
[tasks.check-all]
[tasks.build-all]
command = "cargo"
args = ["+nightly", "check-all-features"]
args = ["+nightly", "build-all-features"]
install_crate = "cargo-all-features"
[tasks.check-wasm]
[tasks.build-wasm]
clear = true
dependencies = [{ name = "check-wasm", path = "leptos" }]
dependencies = [{ name = "build-wasm", path = "leptos" }]
[tasks.check-examples]
clear = true
@@ -31,17 +31,12 @@ dependencies = [
{ name = "check", path = "examples/counter_without_macros" },
{ name = "check", path = "examples/counters" },
{ name = "check", path = "examples/counters_stable" },
{ name = "check", path = "examples/error_boundary" },
{ name = "check", path = "examples/errors_axum" },
{ name = "check", path = "examples/fetch" },
{ name = "check", path = "examples/hackernews" },
{ name = "check", path = "examples/hackernews_axum" },
{ name = "check", path = "examples/login_with_token_csr_only" },
{ name = "check", path = "examples/parent_child" },
{ name = "check", path = "examples/router" },
{ name = "check", path = "examples/session_auth_axum" },
{ name = "check", path = "examples/ssr_modes" },
{ name = "check", path = "examples/ssr_modes_axum" },
{ name = "check", path = "examples/tailwind" },
{ name = "check", path = "examples/todo_app_sqlite" },
{ name = "check", path = "examples/todo_app_sqlite_axum" },

View File

@@ -1,13 +1,13 @@
# Getting Started
There are two basic paths to getting started with Leptos:
1. Client-side rendering with [Trunk](https://trunkrs.dev/)
2. Full-stack rendering with [`cargo-leptos`](https://github.com/leptos-rs/cargo-leptos)
For the early examples, it will be easiest to begin with Trunk. Well introduce
`cargo-leptos` a little later in this series.
If you dont already have it installed, you can install Trunk by running
```bash
@@ -20,22 +20,12 @@ Create a basic Rust binary project
cargo init leptos-tutorial
```
> We recommend using `nightly` Rust, as it enables [a few nice features](https://github.com/leptos-rs/leptos#nightly-note). To use `nightly` Rust with WebAssembly, you can run
>
> ```bash
> rustup toolchain install nightly
> rustup default nightly
> rustup target add wasm32-unknown-unknown
> ```
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
```bash
cargo add leptos
```
Create a simple `index.html` in the root of the `leptos-tutorial` directory
```html
<!DOCTYPE html>
<html>
@@ -45,26 +35,14 @@ Create a simple `index.html` in the root of the `leptos-tutorial` directory
```
And add a simple “Hello, world!” to your `main.rs`
```rust
```rust
use leptos::*;
fn main() {
mount_to_body(|cx| view! { cx, <p>"Hello, world!"</p> })
mount_to_body(|_cx| view! { cx, <p>"Hello, world!"</p> })
}
```
Your directory structure should now look something like this
```
leptos_tutorial
├── src
│ └── main.rs
├── Cargo.html
├── index.html
```
Now run `trunk serve --open` from the root of the `leptos-tutorial` directory.
Trunk should automatically compile your app and open it in your default browser.
If you make edits to `main.rs`, Trunk will recompile your source code and
live-reload the page.
Now run `trunk serve --open`. Trunk should automatically compile your app and
open it in your default browser. If you make edits to `main.rs`, Trunk will
recompile your source code and live-reload the page.

View File

@@ -1,110 +0,0 @@
# Responding to Changes with `create_effect`
Believe it or not, weve made it this far without having mentioned half of the reactive system: effects.
Leptos is built on a fine-grained reactive system, which means that individual reactive values (“signals,” sometimes known as observables) trigger rerunning the code that reacts to them (“effects,” sometimes known as observers). These two halves of the reactive system are inter-dependent. Without effects, signals can change within the reactive system but never be observed in a way that interacts with the outside world. Without signals, effects run once but never again, as theres no observable value to subscribe to.
[`create_effect`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.create_effect.html) takes a function as its argument. It immediately runs the function. If you access any reactive signal inside that function, it registers the fact that the effect depends on that signal with the reactive runtime. Whenever one of the signals that the effect depends on changes, the effect runs again.
```rust
let (a, set_a) = create_signal(cx, 0);
let (b, set_b) = create_signal(cx, 0);
create_effect(cx, move |_| {
// immediately prints "Value: 0" and subscribes to `a`
log::debug!("Value: {}", a());
});
```
The effect function is called with an argument containing whatever value it returned the last time it ran. On the initial run, this is `None`.
By default, effects **do not run on the server**. This means you can call browser-specific APIs within the effect function without causing issues. If you need an effect to run on the server, use [`create_isomorphic_effect`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.create_isomorphic_effect.html).
## Autotracking and Dynamic Dependencies
If youre familiar with a framework like React, you might notice one key difference. React and similar frameworks typically require you to pass a “dependency array,” an explicit set of variables that determine when the effect should rerun.
Because Leptos comes from the tradition of synchronous reactive programming, we dont need this explicit dependency list. Instead, we automatically track dependencies depending on which signals are accessed within the effect.
This has two effects (no pun intended). Dependencies are
1. **Automatic**: You dont need to maintain a dependency list, or worry about what should or shouldnt be included. The framework simply tracks which signals might cause the effect to rerun, and handles it for you.
2. **Dynamic**: The dependency list is cleared and updated every time the effect runs. If your effect contains a conditional (for example), only signals that are used in the current branch are tracked. This means that effects rerun the absolute minimum number of times.
> If this sounds like magic, and if you want a deep dive into how automatic dependency tracking works, [check out this video](https://www.youtube.com/watch?v=GWB3vTWeLd4). (Apologies for the low volume!)
## Effects as Zero-Cost-ish Abstraction
While theyre not a “zero-cost abstraction” in the most technical sense—they require some additional memory use, exist at runtime, etc.—at a higher level, from the perspective of whatever expensive API calls or other work youre doing within them, effects are a zero-cost abstraction. They rerun the absolute minimum number of times necessary, given how youve described them.
Imagine that Im creating some kind of chat software, and I want people to be able to display their full name, or just their first name, and to notify the server whenever their name changes:
```rust
let (first, set_first) = create_signal(cx, String::new());
let (last, set_last) = create_signal(cx, String::new());
let (use_last, set_use_last) = create_signal(cx, true);
// this will add the name to the log
// any time one of the source signals changes
create_effect(cx, move |_| {
log(
cx,
if use_last() {
format!("{} {}", first(), last())
} else {
first()
},
)
});
```
If `use_last` is `true`, effect should rerun whenever `first`, `last`, or `use_last` changes. But if I toggle `use_last` to `false`, a change in `last` will never cause the full name to change. In fact, `last` will be removed from the dependency list until `use_last` toggles again. This saves us from sending multiple unnecessary requests to the API if I change `last` multiple times while `use_last` is still `false`.
## To `create_effect`, or not to `create_effect`?
Effects are intended to run _side-effects_ of the system, not to synchronize state _within_ the system. In other words: dont write to signals within effects.
If you need to define a signal that depends on the value of other signals, use a derived signal or [`create_memo`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.create_memo.html).
If you need to synchronize some reactive value with the non-reactive world outside—like a web API, the console, the filesystem, or the DOM—create an effect.
> If youre curious for more information about when you should and shouldnt use `create_effect`, [check out this video](https://www.youtube.com/watch?v=aQOFJQ2JkvQ) for a more in-depth consideration!
## Effects and Rendering
Weve managed to get this far without mentioning effects because theyre built into the Leptos DOM renderer. Weve seen that you can create a signal and pass it into the `view` macro, and it will update the relevant DOM node whenever the signal changes:
```rust
let (count, set_count) = create_signal(cx, 0);
view! { cx,
<p>{count}</p>
}
```
This works because the framework essentially creates an effect wrapping this update. You can imagine Leptos translating this view into something like this:
```rust
let (count, set_count) = create_signal(cx, 0);
// create a DOM element
let p = create_element("p");
// create an effect to reactively update the text
create_effect(cx, move |prev_value| {
// first, access the signals value and convert it to a string
let text = count().to_string();
// if this is different from the previous value, update the node
if prev_value != Some(text) {
p.set_text_content(&text);
}
// return this value so we can memoize the next update
text
});
```
Every time `count` is updated, this effect wil rerun. This is what allows reactive, fine-grained updates to the DOM.
<iframe src="https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D" width="100%" height="1000px"></iframe>

View File

@@ -1,171 +0,0 @@
# Global State Management
So far, we've only been working with local state in components
We've only seen how to communicate between parent and child components
But there are also more general ways to manage global state
The three best approaches to global state are
1. Using the router to drive global state via the URL
2. Passing signals through context
3. Creating a global state struct and creating lenses into it with `create_slice`
## Option #1: URL as Global State
The next few sections of the tutorial will be about the router.
So for now, we'll just look at options #2 and #3.
## Option #2: Passing Signals through Context
In virtual DOM libraries like React, using the Context API to manage global
state is a bad idea: because the entire app exists in a tree, changing
some value provided high up in the tree can cause the whole app to render.
In fine-grained reactive libraries like Leptos, this is simply not the case.
You can create a signal in the root of your app and pass it down to other
components using provide_context(). Changing it will only cause rerendering
in the specific places it is actually used, not the whole app.
We start by creating a signal in the root of the app and providing it to
all its children and descendants using `provide_context`.
```rust
#[component]
fn App(cx: Scope) -> impl IntoView {
// here we create a signal in the root that can be consumed
// anywhere in the app.
let (count, set_count) = create_signal(cx, 0);
// we'll pass the setter to specific components,
// but provide the count itself to the whole app via context
provide_context(cx, count);
view! { cx,
// SetterButton is allowed to modify the count
<SetterButton set_count/>
// These consumers can only read from it
// But we could give them write access by passing `set_count` if we wanted
<FancyMath/>
<ListItems/>
}
}
```
`<SetterButton/>` is the kind of counter weve written several times now.
(See the sandbox below if you dont understand what I mean.)
`<FancyMath/>` and `<ListItems/>` both consume the signal were providing via
`use_context` and do something with it.
```rust
/// A component that does some "fancy" math with the global count
#[component]
fn FancyMath(cx: Scope) -> impl IntoView {
// here we consume the global count signal with `use_context`
let count = use_context::<ReadSignal<u32>>(cx)
// we know we just provided this in the parent component
.expect("there to be a `count` signal provided");
let is_even = move || count() & 1 == 0;
view! { cx,
<div class="consumer blue">
"The number "
<strong>{count}</strong>
{move || if is_even() {
" is"
} else {
" is not"
}}
" even."
</div>
}
}
```
This kind of “provide a signal in a parent, consume it in a child” should be familiar
from the chapter on [parent-child interactions](./view/08_parent_child.md). The same
pattern you use to communicate between parents and children works for grandparents and
grandchildren, or any ancestors and descendents: in other words, between “global” state
in the root component of your app and any other components anywhere else in the app.
Because of the fine-grained nature of updates, this is usually all you need. However,
in some cases with more complex state changes, you may want to use a slightly more
structured approach to global state.
## Option #3: Create a Global State Struct
You can use this approach to build a single global data structure
that holds the state for your whole app, and then access it by
taking fine-grained slices using
[`create_slice`](https://docs.rs/leptos/latest/leptos/fn.create_slice.html)
or [`create_memo`](https://docs.rs/leptos/latest/leptos/fn.create_memo.html),
so that changing one part of the state doesn't cause parts of your
app that depend on other parts of the state to change.
You can begin by defining a simple state struct:
```rust
#[derive(Default, Clone, Debug)]
struct GlobalState {
count: u32,
name: String,
}
```
Provide it in the root of your app so its available everywhere.
```rust
#[component]
fn App(cx: Scope) -> impl IntoView {
// we'll provide a single signal that holds the whole state
// each component will be responsible for creating its own "lens" into it
let state = create_rw_signal(cx, GlobalState::default());
provide_context(cx, state);
// ...
```
Then child components can access “slices” of that state with fine-grained
updates via `create_slice`. Each slice signal only updates when the particular
piece of the larger struct it accesses updates. This means you can create a single
root signal, and then take independent, fine-grained slices of it in different
components, each of which can update without notifying the others of changes.
```rust
/// A component that updates the count in the global state.
#[component]
fn GlobalStateCounter(cx: Scope) -> impl IntoView {
let state = use_context::<RwSignal<GlobalState>>(cx).expect("state to have been provided");
// `create_slice` lets us create a "lens" into the data
let (count, set_count) = create_slice(
cx,
// we take a slice *from* `state`
state,
// our getter returns a "slice" of the data
|state| state.count,
// our setter describes how to mutate that slice, given a new value
|state, n| state.count = n,
);
view! { cx,
<div class="consumer blue">
<button
on:click=move |_| {
set_count(count() + 1);
}
>
"Increment Global Count"
</button>
<br/>
<span>"Count is: " {count}</span>
</div>
}
}
```
Clicking this button only updates `state.count`, so if we create another slice
somewhere else that only takes `state.name`, clicking the button wont cause
that other slice to update. This allows you to combine the benefits of a top-down
data flow and of fine-grained reactive updates.
<iframe src="https://codesandbox.io/p/sandbox/1-basic-component-forked-8bte19?selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D&file=%2Fsrc%2Fmain.rs" width="100%" height="1000px">

View File

@@ -17,16 +17,15 @@
- [Async](./async/README.md)
- [Loading Data with Resources](./async/10_resources.md)
- [Suspense](./async/11_suspense.md)
- [Transition](./async/12_transition.md)
- [Actions](./async/13_actions.md)
- [Responding to Changes with `create_effect`](./14_create_effect.md)
- [Global State Management](./15_global_state.md)
- [Transition]()
- [Interlude: Styling — CSS, Tailwind, Style.rs, and more]()
- [State Management]()
- [Interlude: Advanced Reactivity]()
- [Router]()
- [Fundamentals]()
- [defining `<Routes/>`]()
- [`<A/>`]()
- [`<Form/>`]()
- [Interlude: Styling — CSS, Tailwind, Style.rs, and more]()
- [Metadata]()
- [SSR]()
- [Models of SSR]()
@@ -40,4 +39,3 @@
- [Forms]()
- [`<ActionForm/>`s]()
- [Turning off WebAssembly]()
- [Advanced Reactivity]()

View File

@@ -15,12 +15,12 @@ let (count, set_count) = create_signal(cx, 0);
// our resource
let async_data = create_resource(cx,
count,
// every time `count` changes, this will run
|value| async move {
log!("loading data from API");
load_data(value).await
},
count,
// every time `count` changes, this will run
|value| async move {
log!("loading data from API");
load_data(value).await
},
);
```
@@ -40,14 +40,14 @@ So, you can show the current state of a resource in your view:
```rust
let once = create_resource(cx, || (), |_| async move { load_data().await });
view! { cx,
<h1>"My Data"</h1>
{move || match once.read(cx) {
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
}}
<h1>"My Data"</h1>
{move || match once.read(cx) {
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
}}
}
```
Resources also provide a `refetch()` method that allows you to manually reload the data (for example, in response to a button click) and a `loading()` method that returns a `ReadSignal<bool>` indicating whether the resource is currently loading or not.
Resources also provide a `refetch()` method that allow you to manually reload the data (for example, in response to a button click) and a `loading()` method that returns a `ReadSignal<bool>` indicating whether the resource is currently loading or not.
<iframe src="https://codesandbox.io/p/sandbox/10-async-resources-4z0qt3?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D" width="100%" height="1000px"></iframe>

View File

@@ -7,11 +7,11 @@ let (count, set_count) = create_signal(cx, 0);
let a = create_resource(cx, count, |count| async move { load_a(count).await });
view! { cx,
<h1>"My Data"</h1>
{move || match once.read(cx) {
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
}}
<h1>"My Data"</h1>
{move || match once.read(cx) {
None => view! { cx, <p>"Loading..."</p> }.into_view(cx),
Some(data) => view! { cx, <ShowData data/> }.into_view(cx)
}}
}
```
@@ -24,14 +24,14 @@ let a = create_resource(cx, count, |count| async move { load_a(count).await });
let b = create_resource(cx, count2, |count| async move { load_b(count).await });
view! { cx,
<h1>"My Data"</h1>
{move || match (a.read(cx), b.read(cx)) {
_ => view! { cx, <p>"Loading..."</p> }.into_view(cx),
(Some(a), Some(b)) => view! { cx,
<ShowA a/>
<ShowA b/>
}.into_view(cx)
}}
<h1>"My Data"</h1>
{move || match (a.read(cx), b.read(cx)) {
_ => view! { cx, <p>"Loading..."</p> }.into_view(cx),
(Some(a), Some(b)) => view! { cx,
<ShowA a/>
<ShowA b/>
}.into_view(cx)
}}
}
```
@@ -46,22 +46,22 @@ let a = create_resource(cx, count, |count| async move { load_a(count).await });
let b = create_resource(cx, count2, |count| async move { load_b(count).await });
view! { cx,
<h1>"My Data"</h1>
<Suspense
fallback=move || view! { cx, <p>"Loading..."</p> }
>
<h2>"My Data"</h2>
<h3>"A"</h3>
{move || {
a.read(cx)
.map(|a| view! { cx, <ShowA a/> })
}}
<h3>"B"</h3>
{move || {
b.read(cx)
.map(|b| view! { cx, <ShowB b/> })
}}
</Suspense>
<h1>"My Data"</h1>
<Suspense
fallback=move || view! { cx, <p>"Loading..."</p> }
>
<h2>"My Data"</h2>
<h3>"A"</h3>
{move || {
a.read(cx)
.map(|a| view! { cx, <ShowA a/> })
}}
<h3>"B"</h3>
{move || {
b.read(cx)
.map(|b| view! { cx, <ShowB b/> })
}}
</Suspense>
}
```

View File

@@ -1,94 +0,0 @@
# Mutating Data with Actions
Weve talked about how to load `async` data with resources. Resources immediately load data and work closely with `<Suspense/>` and `<Transition/>` components to show whether data is loading in your app. But what if you just want to call some arbitrary `async` function and keep track of what its doing?
Well, you could always use [`spawn_local`](https://docs.rs/leptos/latest/leptos/fn.spawn_local.html). This allows you to just spawn an `async` task in a synchronous environment by handing the `Future` off to the browser (or, on the server, Tokio or whatever other runtime youre using). But how do you know if its still pending? Well, you could just set a signal to show whether its loading, and another one to show the result...
All of this is true. Or you could use the final `async` primitive: [`create_action`](https://docs.rs/leptos/latest/leptos/fn.create_action.html).
Actions and resources seem similar, but they represent fundamentally different things. If youre trying to load data by running an `async` function, either once or when some other value changes, you probably want to use `create_resource`. If youre trying to occasionally run an `async` function in response to something like a user clicking a button, you probably want to use `create_action`.
Say we have some `async` function we want to run.
```rust
async fn add_todo(new_title: &str) -> Uuid {
/* do some stuff on the server to add a new todo */
}
```
`create_action` takes a reactive `Scope` and an `async` function that takes a reference to a single argument, which you could think of as its “input type.”
> The input is always a single type. If you want to pass in multiple arguments, you can do it with a struct or tuple.
>
> ```rust
> // 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!() }
> );
> ```
>
> Because the action function takes a reference but the `Future` needs to have a `'static` lifetime, youll usually need to clone the value to pass it into the `Future`. This is admittedly awkward but it unlocks some powerful features like optimistic UI. Well see a little more about that in future chapters.
So in this case, all we need to do to create an action is
```rust
let add_todo = create_action(cx, |input: &String| {
let input = input.to_owned();
async move { add_todo(&input).await }
});
```
Rather than calling `add_todo` directly, well call it with `.dispatch()`, as in
```rust
add_todo.dispatch("Some value".to_string());
```
You can do this from an event listener, a timeout, or anywhere; because `.dispatch()` isnt an `async` function, it can be called from a synchronous context.
Actions provide access to a few signals that synchronize between the asynchronous action youre calling and the synchronous reactive system:
```rust
let submitted = add_todo.input(); // RwSignal<Option<String>>
let pending = add_todo.pending(); // ReadSignal<bool>
let todo_id = add_todo.value(); // RwSignal<Option<Uuid>>
```
This makes it easy to track the current state of your request, show a loading indicator, or do “optimistic UI” based on the assumption that the submission will succeed.
```rust
let input_ref = create_node_ref::<Input>(cx);
view! { cx,
<form
on:submit=move |ev| {
ev.prevent_default(); // don't reload the page...
let input = input_ref.get().expect("input to exist");
add_todo.dispatch(input.value());
}
>
<label>
"What do you need to do?"
<input type="text"
node_ref=input_ref
/>
</label>
<button type="submit">"Add Todo"</button>
</form>
// use our loading state
<p>{move || pending().then("Loading...")}</p>
}
```
Now, theres a chance this all seems a little over-complicated, or maybe too restricted. I wanted to include actions here, alongside resources, as the missing piece of the puzzle. In a real Leptos app, youll actually most often use actions alongside server functions, [`create_server_action`](https://docs.rs/leptos/latest/leptos/fn.create_server_action.html), and the [`<ActionForm/>`](https://docs.rs/leptos_router/latest/leptos_router/fn.ActionForm.html) component to create really powerful progressively-enhanced forms. So if this primitive seems useless to you... Dont worry! Maybe it will make sense later. (Or check out our [`todo_app_sqlite`](https://github.com/leptos-rs/leptos/blob/main/examples/todo_app_sqlite/src/todo.rs) example now.)
<iframe src="https://codesandbox.io/p/sandbox/10-async-resources-forked-hgpfp0?selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A4%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A4%7D%5D&file=%2Fsrc%2Fmain.rs" width="100%" height="1000px"></iframe>

View File

@@ -12,19 +12,19 @@ let (count, set_count) = create_signal(cx, 0);
let double_count = move || count() * 2;
let count_is_odd = move || count() & 1 == 1;
let text = move || if count_is_odd() {
"odd"
"odd"
} else {
"even"
"even"
};
// an effect automatically tracks the signals it depends on
// and reruns when they change
// and re-runs when they change
create_effect(cx, move |_| {
log!("text = {}", text());
log!("text = {}", text());
});
view! { cx,
<p>{move || text().to_uppercase()}</p>
<p>{move || text().to_uppercase()}</p>
}
```
@@ -45,7 +45,7 @@ The key phrase here is “runs some kind of code.” The natural way to “run s
1. virtual DOM (VDOM) frameworks like React, Yew, or Dioxus rerun a component or render function over and over, to generate a virtual DOM tree that can be reconciled with the previous result to patch the DOM
2. compiled frameworks like Angular and Svelte divide your component templates into “create” and “update” functions, rerunning the update function when they detect a change to the components state
3. in fine-grained reactive frameworks like SolidJS, Sycamore, or Leptos, _you_ define the functions that rerun
3. in fine-grained reactive frameworks like SolidJS, Sycamore, or Leptos, _you_ define the functions that re-run
Thats what all our components are doing.
@@ -59,16 +59,16 @@ pub fn SimpleCounter(cx: Scope) -> impl IntoView {
let increment = move |_| set_value.update(|value| *value += 1);
view! { cx,
<button on:click=increment>
{value}
</button>
<button on:click=increment>
{value}
</button>
}
}
```
The `SimpleCounter` function itself runs once. The `value` signal is created once. The framework hands off the `increment` function to the browser as an event listener. When you click the button, the browser calls `increment`, which updates `value` via `set_value`. And that updates the single text node represented in our view by `{value}`.
Closures are key to reactivity. They provide the framework with the ability to rerun the smallest possible unit of your application in responsive to a change.
Closures are key to reactivity. They provide the framework with the ability to re-run the smallest possible unit of your application in responsive to a change.
So remember two things:

View File

@@ -15,11 +15,11 @@ For example, instead of embedding logic in a component directly like this:
```rust
#[component]
pub fn TodoApp(cx: Scope) -> impl IntoView {
let (todos, set_todos) = create_signal(cx, vec![Todo { /* ... */ }]);
// ⚠️ this is hard to test because it's embedded in the component
let num_remaining = move || todos.with(|todos| {
todos.iter().filter(|todo| !todo.completed).sum()
});
let (todos, set_todos) = create_signal(cx, vec![Todo { /* ... */ }]);
// ⚠️ this is hard to test because it's embedded in the component
let maximum = move || todos.with(|todos| {
todos.iter().filter(|todo| todo.completed).sum()
});
}
```
@@ -29,24 +29,24 @@ You could pull that logic out into a separate data structure and test it:
pub struct Todos(Vec<Todo>);
impl Todos {
pub fn num_remaining(&self) -> usize {
todos.iter().filter(|todo| !todo.completed).sum()
}
pub fn remaining(&self) -> usize {
todos.iter().filter(|todo| todo.completed).sum()
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_remaining {
// ...
}
#[test]
fn test_remaining {
// ...
}
}
#[component]
pub fn TodoApp(cx: Scope) -> impl IntoView {
let (todos, set_todos) = create_signal(cx, Todos(vec![Todo { /* ... */ }]));
// ✅ this has a test associated with it
let num_remaining = move || todos.with(Todos::num_remaining);
let (todos, set_todos) = create_signal(cx, Todos(vec![Todo { /* ... */ }]));
// ✅ this has a test associated with it
let maximum = move || todos.with(Todos::remaining);
}
```

View File

@@ -31,7 +31,7 @@ fn App(cx: Scope) -> impl IntoView {
set_count.update(|n| *n += 1);
}
>
"Click me: "
"Click me"
{move || count.get()}
</button>
}
@@ -61,7 +61,7 @@ Every component is a function with the following characteristics
## The Component Body
The body of the component function is a set-up function that runs once, not a
render function that reruns multiple times. Youll typically use it to create a
render function that re-runs multiple times. Youll typically use it to create a
few reactive variables, define any side effects that run in response to those values
changing, and describe the user interface.
@@ -110,7 +110,7 @@ than theyve ever used in their lives. And fair enough. Basically, passing a f
into the view tells the framework: “Hey, this is something that might change.”
When we click the button and call `set_count`, the `count` signal is updated. This
`move || count.get()` closure, whose value depends on the value of `count`, reruns,
`move || count.get()` closure, whose value depends on the value of `count`, re-runs,
and the framework makes a targeted update to that one specific text node, touching
nothing else in your application. This is what allows for extremely efficient updates
to the DOM.

View File

@@ -18,7 +18,7 @@ let double_count = move || count() * 2;
view! {
<progress
max="50"
value=count
value=progress
/>
<progress
max="50"
@@ -212,7 +212,7 @@ fn ProgressBar<F>(
progress: F
) -> impl IntoView
where
F: Fn() -> i32 + 'static,
F: Fn() -> i32
{
view! { cx,
<progress
@@ -227,14 +227,14 @@ This is a perfectly reasonable way to write this component: `progress` now takes
any value that implements this `Fn()` trait.
> Note that generic component props _cannot_ be specified inline (as `<F: Fn() -> i32>`)
or as `progress: impl Fn() -> i32 + 'static,`, in part because theyre actually used to generate
or as `progress: impl Fn() -> i32`, in part because theyre actually used to generate
a `struct ProgressBarProps`, and struct fields cannot be `impl` types.
### `into` Props
Theres one more way we could implement this, and it would be to use `#[prop(into)]`.
This attribute automatically calls `.into()` on the values you pass as props,
which allows you to easily pass props with different values.
This attribute automatically calls `.into()` on the values you pass as proprs,
which allows you to pass props of different values easily.
In this case, its helpful to know about the
[`Signal`](https://docs.rs/leptos/latest/leptos/struct.Signal.html) type. `Signal`

View File

@@ -13,7 +13,7 @@ Leptos supports to two different patterns for iterating over items:
Sometimes you need to show an item repeatedly, but the list youre drawing from
does not often change. In this case, its important to know that you can insert
any `Vec<IV> where IV: IntoView` into your view. In other words, if you can render
any `Vec<IV> where IV: IntoView` into your view. In other views, if you can render
`T`, you can render `Vec<T>`.
```rust
@@ -85,4 +85,4 @@ it is generated, and using that as an ID for the key function.
Check out the `<DynamicList/>` component below for an example.
<iframe src="https://codesandbox.io/p/sandbox/4-iteration-sglt1o?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A6%2C%22endLineNumber%22%3A55%2C%22startColumn%22%3A5%2C%22startLineNumber%22%3A31%7D%5D" width="100%" height="1000px"></iframe>
<iframe src="https://codesandbox.io/p/sandbox/4-iteration-sglt1o?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A6%2C%22endLineNumber%22%3A55%2C%22startColumn%22%3A5%2C%22startLineNumber%22%3A31%7D%5D" width="100%" height="100px"></iframe>

View File

@@ -54,7 +54,7 @@ event.
```rust
let (name, set_name) = create_signal(cx, "Uncontrolled".to_string());
let input_element: NodeRef<Input> = create_node_ref(cx);
let input_element: NodeRef<HtmlElement<Input>> = NodeRef::new(cx);
```
`NodeRef` is a kind of reactive smart pointer: we can use it to access the
underlying DOM node. Its value will be set when the element is rendered.

View File

@@ -26,7 +26,7 @@ things:
all of which can be rendered. Spending time in the `Option` and `Result` docs in particular
is one of the best ways to level up your Rust game.
4. And always remember: to be reactive, values must be functions. Youll see me constantly
wrap things in a `move ||` closure, below. This is to ensure that they actually rerun
wrap things in a `move ||` closure, below. This is to ensure that they actually re-run
when the signal they depend on changes, keeping the UI reactive.
## So What?
@@ -55,13 +55,13 @@ if its even. Well, how about this?
```rust
view! { cx,
<p>
{move || if is_odd() {
"Odd"
} else {
"Even"
}}
</p>
<p>
{move || if is_odd() {
"Odd"
} else {
"Even"
}}
</p>
}
```
@@ -74,15 +74,15 @@ Lets say we want to render some text if its odd, and nothing if its eve
```rust
let message = move || {
if is_odd() {
Some("Ding ding ding!")
} else {
None
}
if is_odd() {
Some("Ding ding ding!")
} else {
None
}
};
view! { cx,
<p>{message}</p>
<p>{message}</p>
}
```
@@ -91,7 +91,7 @@ This works fine. We can make it a little shorter if wed like, using `bool::th
```rust
let message = move || is_odd().then(|| "Ding ding ding!");
view! { cx,
<p>{message}</p>
<p>{message}</p>
}
```
@@ -105,15 +105,15 @@ pattern matching at your disposal.
```rust
let message = move || {
match value() {
0 => "Zero",
1 => "One",
n if is_odd() => "Odd",
_ => "Even"
}
match value() {
0 => "Zero",
1 => "One",
n if is_odd() => "Odd",
_ => "Even"
}
};
view! { cx,
<p>{message}</p>
<p>{message}</p>
}
```
@@ -134,13 +134,13 @@ But consider the following example:
let (value, set_value) = create_signal(cx, 0);
let message = move || if value() > 5 {
"Big"
"Big"
} else {
"Small"
"Small"
};
view! { cx,
<p>{message}</p>
<p>{message}</p>
}
```
@@ -148,11 +148,11 @@ This _works_, for sure. But if you added a log, you might be surprised
```rust
let message = move || if value() > 5 {
log!("{}: rendering Big", value());
"Big"
log!("{}: rendering Big", value());
"Big"
} else {
log!("{}: rendering Small", value());
"Small"
log!("{}: rendering Small", value());
"Small"
};
```
@@ -177,9 +177,9 @@ like this:
```rust
let message = move || if value() > 5 {
<Big/>
<Big/>
} else {
<Small/>
<Small/>
};
```
@@ -228,20 +228,20 @@ different branches of a conditional:
```rust,compile_error
view! { cx,
<main>
{move || match is_odd() {
true if value() == 1 => {
// returns HtmlElement<Pre>
view! { cx, <pre>"One"</pre> }
},
false if value() == 2 => {
// returns HtmlElement<P>
view! { cx, <p>"Two"</p> }
}
// returns HtmlElement<Textarea>
_ => view! { cx, <textarea>{value()}</textarea> }
}}
</main>
<main>
{move || match is_odd() {
true if value() == 1 => {
// returns HtmlElement<Pre>
view! { cx, <pre>"One"</pre> }
},
false if value() == 2 => {
// returns HtmlElement<P>
view! { cx, <p>"Two"</p> }
}
// returns HtmlElement<Textarea>
_ => view! { cx, <textarea>{value()}</textarea> }
}}
</main>
}
```
@@ -265,20 +265,20 @@ Heres the same example, with the conversion added:
```rust,compile_error
view! { cx,
<main>
{move || match is_odd() {
true if value() == 1 => {
// returns HtmlElement<Pre>
view! { cx, <pre>"One"</pre> }.into_any()
},
false if value() == 2 => {
// returns HtmlElement<P>
view! { cx, <p>"Two"</p> }.into_any()
}
// returns HtmlElement<Textarea>
_ => view! { cx, <textarea>{value()}</textarea> }.into_any()
}}
</main>
<main>
{move || match is_odd() {
true if value() == 1 => {
// returns HtmlElement<Pre>
view! { cx, <pre>"One"</pre> }.into_any()
},
false if value() == 2 => {
// returns HtmlElement<P>
view! { cx, <p>"Two"</p> }.into_any()
}
// returns HtmlElement<Textarea>
_ => view! { cx, <textarea>{value()}</textarea> }.into_any()
}}
</main>
}
```

View File

@@ -63,7 +63,7 @@ Lets add an `<ErrorBoundary/>` to this example.
fn NumericInput(cx: Scope) -> impl IntoView {
let (value, set_value) = create_signal(cx, Ok(0));
let on_input = move |ev| set_value(event_target_value(&ev).parse::<i32>());
let on_input = move |ev| set_value(event_target_value(&ev).parse::<i32>());
view! { cx,
<h1>"Error Handling"</h1>
@@ -77,7 +77,9 @@ fn NumericInput(cx: Scope) -> impl IntoView {
<p>"Not a number! Errors: "</p>
// we can render a list of errors as strings, if we'd like
<ul>
{move || errors.get()
{move || errors.unwrap()
.get()
.0
.into_iter()
.map(|(_, e)| view! { cx, <li>{e.to_string()}</li>})
.collect::<Vec<_>>()

View File

@@ -30,11 +30,11 @@ it in the child. This lets you manipulate the state of the parent from the child
```rust
#[component]
pub fn App(cx: Scope) -> impl IntoView {
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
<ButtonA setter=set_toggled/>
}
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
<ButtonA setter=set_toggled/>
}
}
#[component]
@@ -63,11 +63,11 @@ Another approach would be to pass a callback to the child: say, `on_click`.
```rust
#[component]
pub fn App(cx: Scope) -> impl IntoView {
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
<ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
}
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
<ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
}
}
@@ -106,13 +106,13 @@ in your `view` macro in `<App/>`.
```rust
#[component]
pub fn App(cx: Scope) -> impl IntoView {
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
// note the on:click instead of on_click
// this is the same syntax as an HTML element event listener
<ButtonC on:click=move |_| set_toggled.update(|value| *value = !*value)/>
}
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
// note the on:click instead of on_click
// this is the same syntax as an HTML element event listener
<ButtonC on:click=move |_| set_toggled.update(|value| *value = !*value)/>
}
}
@@ -142,32 +142,31 @@ tree:
```rust
#[component]
pub fn App(cx: Scope) -> impl IntoView {
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
<Layout/>
}
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
<Layout/>
}
}
#[component]
pub fn Layout(cx: Scope) -> impl IntoView {
view! { cx,
<header>
<h1>"My Page"</h1>
</header>
<main>
<Content/>
</main>
}
view! { cx,
<header>
<h1>"My Page"</h1>
<main>
<Content/>
</main>
}
}
#[component]
pub fn Content(cx: Scope) -> impl IntoView {
view! { cx,
<div class="content">
<ButtonD/>
</div>
}
view! { cx,
<div class="content">
<ButtonD/>
</div>
}
}
#[component]
@@ -183,32 +182,31 @@ pass your `WriteSignal` to its props. You could do whats sometimes called
```rust
#[component]
pub fn App(cx: Scope) -> impl IntoView {
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
<Layout set_toggled/>
}
let (toggled, set_toggled) = create_signal(cx, false);
view! { cx,
<p>"Toggled? " {toggled}</p>
<Layout set_toggled/>
}
}
#[component]
pub fn Layout(cx: Scope, set_toggled: WriteSignal<bool>) -> impl IntoView {
view! { cx,
<header>
<h1>"My Page"</h1>
</header>
<main>
<Content set_toggled/>
</main>
}
view! { cx,
<header>
<h1>"My Page"</h1>
<main>
<Content set_toggled/>
</main>
}
}
#[component]
pub fn Content(cx: Scope, set_toggled: WriteSignal<bool>) -> impl IntoView {
view! { cx,
<div class="content">
<ButtonD set_toggled/>
</div>
}
view! { cx,
<div class="content">
<ButtonD set_toggled/>
</div>
}
}
#[component]
@@ -238,26 +236,26 @@ unnecessary prop drilling.
```rust
#[component]
pub fn App(cx: Scope) -> impl IntoView {
let (toggled, set_toggled) = create_signal(cx, false);
let (toggled, set_toggled) = create_signal(cx, false);
// share `set_toggled` with all children of this component
provide_context(cx, set_toggled);
// share `set_toggled` with all children of this component
provide_context(cx, set_toggled);
view! { cx,
<p>"Toggled? " {toggled}</p>
<Layout/>
}
view! { cx,
<p>"Toggled? " {toggled}</p>
<Layout/>
}
}
// <Layout/> and <Content/> omitted
#[component]
pub fn ButtonD(cx: Scope) -> impl IntoView {
// use_context searches up the context tree, hoping to
// find a `WriteSignal<bool>`
// in this case, I .expect() because I know I provided it
let setter = use_context::<WriteSignal<bool>>(cx)
.expect("to have found the setter provided");
// use_context searches up the context tree, hoping to
// find a `WriteSignal<bool>`
// in this case, I .expect() because I know I provided it
let setter = use_context::<WriteSignal<bool>>(cx)
.expect("to have found the setter provided");
view! { cx,
<button

View File

@@ -6,15 +6,15 @@ that enhances an HTML `<form>`. I need some way to pass all its inputs.
```rust
view! { cx,
<Form>
<fieldset>
<label>
"Some Input"
<input type="text" name="something"/>
</label>
</fieldset>
<button>"Submit"</button>
</Form>
<Form>
<fieldset>
<label>
"Some Input"
<input type="text" name="something"/>
</label>
</fieldset>
<button>"Submit"</button>
</Form>
}
```
@@ -30,13 +30,13 @@ In fact, youve already seen these both in action in the [`<Show/>`](/view/06_
```rust
view! { cx,
<Show
// `when` is a normal prop
// `when` is a normal prop
when=move || value() > 5
// `fallback` is a "render prop": a function that returns a view
// `fallback` is a "render prop": a function that returns a view
fallback=|cx| view! { cx, <Small/> }
>
// `<Big/>` (and anything else here)
// will be given to the `children` prop
// `<Big/>` (and anything else here)
// will be given to the `children` prop
<Big/>
</Show>
}
@@ -62,7 +62,7 @@ where
<h2>"Render Prop"</h2>
{render_prop()}
<h2>"Children"</h2>
<h2>"Children"</h2>
{children(cx)}
}
}
@@ -79,11 +79,11 @@ We can use the component like this:
```rust
view! { cx,
<TakesChildren render_prop=|| view! { cx, <p>"Hi, there!"</p> }>
// these get passed to `children`
"Some text"
<span>"A span"</span>
</TakesChildren>
<TakesChildren render_prop=|| view! { cx, <p>"Hi, there!"</p> }>
// these get passed to `children`
"Some text"
<span>"A span"</span>
</TakesChildren>
}
```
@@ -115,11 +115,11 @@ Calling it like this will create a list:
```rust
view! { cx,
<WrappedChildren>
"A"
"B"
"C"
</WrappedChildren>
<WrappedChildren>
"A"
"B"
"C"
</WrappedChildren>
}
```

View File

@@ -10,7 +10,5 @@ log = "0.4"
console_error_panic_hook = "0.1.7"
[dev-dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-test = "0.3.0"
web-sys ="0.3"

View File

@@ -1,9 +1,9 @@
use counter::*;
use leptos::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
use counter::*;
use leptos::*;
use web_sys::HtmlElement;
#[wasm_bindgen_test]
fn clear() {
@@ -84,22 +84,22 @@ fn inc() {
let clear = div
.first_child()
.unwrap()
.dyn_into::<web_sys::HtmlElement>()
.dyn_into::<HtmlElement>()
.unwrap();
let dec = clear
.next_sibling()
.unwrap()
.dyn_into::<web_sys::HtmlElement>()
.dyn_into::<HtmlElement>()
.unwrap();
let text = dec
.next_sibling()
.unwrap()
.dyn_into::<web_sys::HtmlElement>()
.dyn_into::<HtmlElement>()
.unwrap();
let inc = text
.next_sibling()
.unwrap()
.dyn_into::<web_sys::HtmlElement>()
.dyn_into::<HtmlElement>()
.unwrap();
inc.click();

View File

@@ -24,6 +24,7 @@ pub fn App(cx: Scope) -> impl IntoView {
// as strings, if we'd like
<ul>
{move || errors.get()
.0
.into_iter()
.map(|(_, e)| view! { cx, <li>{e.to_string()}</li>})
.collect::<Vec<_>>()

View File

@@ -13,7 +13,7 @@ console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../../leptos/leptos", default-features = false, features = [
"serde",
"serde",
] }
leptos_axum = { path = "../../../leptos/integrations/axum", default-features = false, optional = true }
leptos_meta = { path = "../../../leptos/meta", default-features = false }
@@ -27,7 +27,7 @@ gloo-net = { version = "0.2.5", features = ["http"] }
reqwest = { version = "0.11.13", features = ["json"] }
axum = { version = "0.6.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = ["fs"], optional = true }
tower-http = { version = "0.3.4", features = ["fs"], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.8" }
thiserror = "1.0.38"
@@ -39,14 +39,14 @@ default = ["csr"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_axum",
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_axum",
]
[package.metadata.cargo-all-features]
@@ -54,12 +54,12 @@ denylist = ["axum", "tower", "tower-http", "tokio", "leptos_axum"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "errors_axum"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"

View File

@@ -34,7 +34,7 @@ where
abort_controller.abort()
}
});
T::de(&json).ok()
T::from_json(&json).ok()
}
#[cfg(feature = "ssr")]
@@ -49,7 +49,7 @@ where
.text()
.await
.ok()?;
T::de(&json).map_err(|e| log::error!("{e}")).ok()
T::from_json(&json).map_err(|e| log::error!("{e}")).ok()
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]

View File

@@ -13,7 +13,7 @@ console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
"serde",
] }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta", default-features = false }
@@ -26,7 +26,7 @@ gloo-net = { version = "0.2.5", features = ["http"] }
reqwest = { version = "0.11.13", features = ["json"] }
axum = { version = "0.6.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = ["fs"], optional = true }
tower-http = { version = "0.3.4", features = ["fs"], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.8", optional = true }
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
@@ -38,15 +38,15 @@ default = ["csr"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"dep:http",
"leptos/ssr",
"leptos_axum",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"dep:http",
"leptos/ssr",
"leptos_axum",
"leptos_meta/ssr",
"leptos_router/ssr",
]
[package.metadata.cargo-all-features]
@@ -54,27 +54,27 @@ denylist = ["axum", "tower", "tower-http", "tokio", "http", "leptos_axum"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "hackernews_axum"
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "hackernews_axum"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "npx playwright test"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
browserquery = "defaults"
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
watch = false
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
@@ -95,4 +95,4 @@ lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
lib-default-features = false

View File

@@ -34,7 +34,7 @@ where
abort_controller.abort()
}
});
T::de(&json).ok()
T::from_json(&json).ok()
}
#[cfg(feature = "ssr")]
@@ -49,7 +49,7 @@ where
.text()
.await
.ok()?;
T::de(&json).map_err(|e| log::error!("{e}")).ok()
T::from_json(&json).map_err(|e| log::error!("{e}")).ok()
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]

View File

@@ -1,9 +0,0 @@
[tasks.build]
command = "cargo"
args = ["+nightly", "build-all-features"]
install_crate = "cargo-all-features"
[tasks.check]
command = "cargo"
args = ["+nightly", "check-all-features"]
install_crate = "cargo-all-features"

View File

@@ -1,7 +1,7 @@
# Leptos Login Example
This example demonstrates a scenario of a client-side rendered application
that uses an existing API that you cannot or do not want to change.
that uses uses an existing API that you cannot or do not want to change.
The authentications of this example are done using an API token.
## Run

View File

@@ -14,5 +14,5 @@ mailparse = "0.14"
pwhash = "1.0"
thiserror = "1.0"
tokio = { version = "1.25", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.4", features = ["cors"] }
tower-http = { version = "0.3", features = ["cors"] }
uuid = { version = "1.3", features = ["v4"] }

View File

@@ -1 +0,0 @@
use flake

View File

@@ -1,113 +0,0 @@
[package]
name = "session_auth_axum"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
anyhow = "1.0.66"
console_log = "0.2.0"
rand = { version = "0.8.5", features = ["min_const_gen"], optional = true }
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { version = "0.2.0", default-features = false, features = [
"serde",
] }
leptos_meta = { version = "0.2.0", default-features = false }
leptos_axum = { version = "0.2.0", optional = true }
leptos_router = { version = "0.2.0", default-features = false }
leptos_reactive = { version = "0.2.0", default-features = false }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
serde_json = "1.0.89"
gloo-net = { version = "0.2.5", features = ["http"] }
reqwest = { version = "0.11.13", features = ["json"] }
axum = { version = "0.6.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = ["fs"], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.8" }
sqlx = { version = "0.6.2", features = [
"runtime-tokio-rustls",
"sqlite",
], optional = true }
thiserror = "1.0.38"
tracing = "0.1.37"
wasm-bindgen = "0.2"
axum_sessions_auth = { version = "7.0.0", features = [ "sqlite-rustls" ], optional = true }
axum_database_sessions = { version = "7.0.0", features = [ "sqlite-rustls" ], optional = true }
bcrypt = { version = "0.14", optional = true }
async-trait = {version = "0.1.64", optional = true }
[features]
default = ["csr"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"dep:axum_sessions_auth",
"dep:axum_database_sessions",
"dep:async-trait",
"dep:sqlx",
"dep:bcrypt",
"dep:rand",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_axum",
]
[package.metadata.cargo-all-features]
denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "session_auth_axum"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "npx playwright test"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false

View File

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

View File

@@ -1,9 +0,0 @@
[tasks.build]
command = "cargo"
args = ["+nightly", "build-all-features"]
install_crate = "cargo-all-features"
[tasks.check]
command = "cargo"
args = ["+nightly", "check-all-features"]
install_crate = "cargo-all-features"

View File

@@ -1,42 +0,0 @@
# Leptos Authenticated Todo App Sqlite with Axum
This example creates a basic todo app with an Axum backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server. It lets you login, signup, and submit todos as different users, or a guest.
## Client Side Rendering
This example cannot be built as a trunk standalone CSR-only app. Only the server may directly connect to the database.
## Server Side Rendering with cargo-leptos
cargo-leptos is now the easiest and most featureful way to build server side rendered apps with hydration. It provides automatic recompilation of client and server code, wasm optimisation, CSS minification, and more! Check out more about it [here](https://github.com/akesson/cargo-leptos)
1. Install cargo-leptos
```bash
cargo install --locked cargo-leptos
```
2. Build the site in watch mode, recompiling on file changes
```bash
cargo leptos watch
```
Open browser on [http://localhost:3000/](http://localhost:3000/)
3. When ready to deploy, run
```bash
cargo leptos build --release
```
## Server Side Rendering without cargo-leptos
To run it as a server side app with hydration, you'll need to have wasm-pack installed.
0. Edit the `[package.metadata.leptos]` section and set `site-root` to `"."`. You'll also want to change the path of the `<StyleSheet / >` component in the root component to point towards the CSS file in the root. This tells leptos that the WASM/JS files generated by wasm-pack are available at `./pkg` and that the CSS files are no longer processed by cargo-leptos. Building to alternative folders is not supported at this time. You'll also want to edit the call to `get_configuration()` to pass in `Some(Cargo.toml)`, so that Leptos will read the settings instead of cargo-leptos. If you do so, your file/folder names cannot include dashes.
1. Install wasm-pack
```bash
cargo install wasm-pack
```
2. Build the Webassembly used to hydrate the HTML from the server
```bash
wasm-pack build --target=web --debug --no-default-features --features=hydrate
```
3. Run the server to serve the Webassembly, JS, and HTML
```bash
cargo run --no-default-features --features=ssr
```

Binary file not shown.

View File

@@ -1,80 +0,0 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1676283394,
"narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"locked": {
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1672580127,
"narHash": "sha256-3lW3xZslREhJogoOkjeZtlBtvFMyxHku7I/9IVehhT8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0874168639713f547c05947c76124f78441ea46c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-22.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1677292251,
"narHash": "sha256-D+6q5Z2MQn3UFJtqsM5/AvVHi3NXKZTIMZt1JGq/spA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "34cdbf6ad480ce13a6a526f57d8b9e609f3d65dc",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,37 +0,0 @@
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.05";
inputs.rust-overlay.url = "github:oxalica/rust-overlay";
inputs.rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
in
with pkgs; rec {
devShells.default = mkShell {
shellHook = ''
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig";
'';
nativeBuildInputs = [
pkg-config
];
buildInputs = [
trunk
sqlite
sass
openssl
(rust-bin.nightly.latest.default.override {
extensions = [ "rust-src" ];
targets = [ "wasm32-unknown-unknown" ];
})
];
};
});
}

View File

@@ -1,27 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_permissions (
user_id INTEGER NOT NULL,
token TEXT NOT NULL
);
-- INSERT INTO users (id, anonymous, username, password)
-- SELECT 0, true, 'Guest', ''
-- ON CONFLICT(id) DO UPDATE SET
-- anonymous = EXCLUDED.anonymous,
-- username = EXCLUDED.username;
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
completed BOOLEAN,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,231 +0,0 @@
use std::collections::HashSet;
use cfg_if::cfg_if;
use leptos::*;
use serde::{Deserialize, Serialize};
cfg_if! {
if #[cfg(feature = "ssr")] {
use sqlx::SqlitePool;
use axum_sessions_auth::{SessionSqlitePool, Authentication, HasPermission};
use bcrypt::{hash, verify, DEFAULT_COST};
use crate::todo::{pool, auth};
pub type AuthSession = axum_sessions_auth::AuthSession<User, i64, SessionSqlitePool, SqlitePool>;
}}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub username: String,
pub password: String,
pub permissions: HashSet<String>,
}
impl Default for User {
fn default() -> Self {
let permissions = HashSet::new();
Self {
id: -1,
username: "Guest".into(),
password: "".into(),
permissions,
}
}
}
cfg_if! {
if #[cfg(feature = "ssr")] {
use async_trait::async_trait;
impl User {
pub async fn get(id: i64, pool: &SqlitePool) -> Option<Self> {
let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = ?")
.bind(id)
.fetch_one(pool)
.await
.ok()?;
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
"SELECT token FROM user_permissions WHERE user_id = ?;",
)
.bind(id)
.fetch_all(pool)
.await
.ok()?;
Some(sqluser.into_user(Some(sql_user_perms)))
}
pub async fn get_from_username(name: String, pool: &SqlitePool) -> Option<Self> {
let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE username = ?")
.bind(name)
.fetch_one(pool)
.await
.ok()?;
//lets just get all the tokens the user can use, we will only use the full permissions if modifing them.
let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>(
"SELECT token FROM user_permissions WHERE user_id = ?;",
)
.bind(sqluser.id)
.fetch_all(pool)
.await
.ok()?;
Some(sqluser.into_user(Some(sql_user_perms)))
}
}
#[derive(sqlx::FromRow, Clone)]
pub struct SqlPermissionTokens {
pub token: String,
}
#[async_trait]
impl Authentication<User, i64, SqlitePool> for User {
async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result<User, anyhow::Error> {
let pool = pool.unwrap();
User::get(userid, pool)
.await
.ok_or_else(|| anyhow::anyhow!("Cannot get user"))
}
fn is_authenticated(&self) -> bool {
true
}
fn is_active(&self) -> bool {
true
}
fn is_anonymous(&self) -> bool {
false
}
}
#[async_trait]
impl HasPermission<SqlitePool> for User {
async fn has(&self, perm: &str, _pool: &Option<&SqlitePool>) -> bool {
self.permissions.contains(perm)
}
}
#[derive(sqlx::FromRow, Clone)]
pub struct SqlUser {
pub id: i64,
pub username: String,
pub password: String,
}
impl SqlUser {
pub fn into_user(self, sql_user_perms: Option<Vec<SqlPermissionTokens>>) -> User {
User {
id: self.id,
username: self.username,
password: self.password,
permissions: if let Some(user_perms) = sql_user_perms {
user_perms
.into_iter()
.map(|x| x.token)
.collect::<HashSet<String>>()
} else {
HashSet::<String>::new()
},
}
}
}
}
}
#[server(Foo, "/api")]
pub async fn foo() -> Result<String, ServerFnError> {
Ok(String::from("Bar!"))
}
#[server(GetUser, "/api")]
pub async fn get_user(cx: Scope) -> Result<Option<User>, ServerFnError> {
let auth = auth(cx)?;
Ok(auth.current_user)
}
#[server(Login, "/api")]
pub async fn login(
cx: Scope,
username: String,
password: String,
remember: Option<String>,
) -> Result<(), ServerFnError> {
let pool = pool(cx)?;
let auth = auth(cx)?;
let user: User = User::get_from_username(username, &pool)
.await
.ok_or("User does not exist.")
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
match verify(password, &user.password).map_err(|e| ServerFnError::ServerError(e.to_string()))? {
true => {
auth.login_user(user.id);
auth.remember_user(remember.is_some());
leptos_axum::redirect(cx, "/");
Ok(())
}
false => Err(ServerFnError::ServerError(
"Password does not match.".to_string(),
)),
}
}
#[server(Signup, "/api")]
pub async fn signup(
cx: Scope,
username: String,
password: String,
password_confirmation: String,
remember: Option<String>,
) -> Result<(), ServerFnError> {
let pool = pool(cx)?;
let auth = auth(cx)?;
if password != password_confirmation {
return Err(ServerFnError::ServerError(
"Passwords did not match.".to_string(),
));
}
let password_hashed = hash(password, DEFAULT_COST).unwrap();
sqlx::query("INSERT INTO users (username, password) VALUES (?,?)")
.bind(username.clone())
.bind(password_hashed)
.execute(&pool)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
let user = User::get_from_username(username, &pool)
.await
.ok_or("Signup failed: User does not exist.")
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
auth.login_user(user.id);
auth.remember_user(remember.is_some());
leptos_axum::redirect(cx, "/");
Ok(())
}
#[server(Logout, "/api")]
pub async fn logout(cx: Scope) -> Result<(), ServerFnError> {
let auth = auth(cx)?;
auth.logout_user();
leptos_axum::redirect(cx, "/");
Ok(())
}

View File

@@ -1,61 +0,0 @@
use crate::errors::TodoAppError;
use cfg_if::cfg_if;
use leptos::{Errors, *};
#[cfg(feature = "ssr")]
use leptos_axum::ResponseOptions;
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
// here than just displaying them
#[component]
pub fn ErrorTemplate(
cx: Scope,
#[prop(optional)] outside_errors: Option<Errors>,
#[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> impl IntoView {
let errors = match outside_errors {
Some(e) => create_rw_signal(cx, e),
None => match errors {
Some(e) => e,
None => panic!("No Errors found and we expected errors!"),
},
};
// Get Errors from Signal
// Downcast lets us take a type that implements `std::error::Error`
let errors: Vec<TodoAppError> = errors
.get()
.into_iter()
.filter_map(|(_, v)| v.downcast_ref::<TodoAppError>().cloned())
.collect();
// Only the response code for the first error is actually sent from the server
// this may be customized by the specific application
cfg_if! {
if #[cfg(feature="ssr")]{
let response = use_context::<ResponseOptions>(cx);
if let Some(response) = response{
response.set_status(errors[0].status_code());
}
}
}
view! {cx,
<h1>"Errors"</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each= move || {errors.clone().into_iter().enumerate()}
// a unique key for each item as a reference
key=|(index, _error)| *index
// renders each item to a view
view= move |cx, error| {
let error_string = error.1.to_string();
let error_code= error.1.status_code();
view! {
cx,
<h2>{error_code.to_string()}</h2>
<p>"Error: " {error_string}</p>
}
}
/>
}
}

View File

@@ -1,19 +0,0 @@
use http::status::StatusCode;
use thiserror::Error;
#[derive(Debug, Clone, Error)]
pub enum TodoAppError {
#[error("Not Found")]
NotFound,
#[error("Internal Server Error")]
InternalServerError,
}
impl TodoAppError {
pub fn status_code(&self) -> StatusCode {
match self {
TodoAppError::NotFound => StatusCode::NOT_FOUND,
TodoAppError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}

View File

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

View File

@@ -1,27 +0,0 @@
use cfg_if::cfg_if;
pub mod auth;
pub mod error_template;
pub mod errors;
pub mod fallback;
pub mod todo;
// Needs to be in lib.rs AFAIK because wasm-bindgen needs us to be compiling a lib. I may be wrong.
cfg_if! {
if #[cfg(feature = "hydrate")] {
use wasm_bindgen::prelude::wasm_bindgen;
use crate::todo::*;
use leptos::view;
#[wasm_bindgen]
pub fn hydrate() {
console_error_panic_hook::set_once();
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount_to_body(|cx| {
view! { cx, <TodoApp/> }
});
}
}
}

View File

@@ -1,102 +0,0 @@
use cfg_if::cfg_if;
// boilerplate to run in different modes
cfg_if! {
if #[cfg(feature = "ssr")] {
use axum::{
response::{Response, IntoResponse},
routing::{post, get},
extract::{Path, Extension},
http::{Request, header::HeaderMap},
body::Body as AxumBody,
Router,
};
use session_auth_axum::todo::*;
use session_auth_axum::auth::*;
use session_auth_axum::*;
use session_auth_axum::fallback::file_and_error_handler;
use leptos_axum::{generate_route_list, LeptosRoutes, handle_server_fns_with_context};
use leptos::{log, view, provide_context, LeptosOptions, get_configuration, ServerFnError};
use std::sync::Arc;
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
use axum_database_sessions::{SessionConfig, SessionLayer, SessionStore};
use axum_sessions_auth::{AuthSessionLayer, AuthConfig, SessionSqlitePool};
async fn server_fn_handler(Extension(pool): Extension<SqlitePool>, auth_session: AuthSession, path: Path<String>, headers: HeaderMap, request: Request<AxumBody>) -> impl IntoResponse {
log!("{:?}", path);
handle_server_fns_with_context(path, headers, move |cx| {
provide_context(cx, auth_session.clone());
provide_context(cx, pool.clone());
}, request).await
}
async fn leptos_routes_handler(Extension(pool): Extension<SqlitePool>, auth_session: AuthSession, Extension(options): Extension<Arc<LeptosOptions>>, req: Request<AxumBody>) -> Response{
let handler = leptos_axum::render_app_to_stream_with_context((*options).clone(),
move |cx| {
provide_context(cx, auth_session.clone());
provide_context(cx, pool.clone());
},
|cx| view! { cx, <TodoApp/> }
);
handler(req).await.into_response()
}
#[tokio::main]
async fn main() {
simple_logger::init_with_level(log::Level::Info).expect("couldn't initialize logging");
let pool = SqlitePoolOptions::new()
.connect("sqlite:Todos.db")
.await
.expect("Could not make pool.");
// Auth section
let session_config = SessionConfig::default().with_table_name("axum_sessions");
let auth_config = AuthConfig::<i64>::default();
let session_store = SessionStore::<SessionSqlitePool>::new(Some(pool.clone().into()), session_config);
session_store.initiate().await.unwrap();
sqlx::migrate!()
.run(&pool)
.await
.expect("could not run SQLx migrations");
crate::todo::register_server_functions();
// Setting this to None means we'll be using cargo-leptos and its env vars
let conf = get_configuration(None).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(|cx| view! { cx, <TodoApp/> }).await;
// build our application with a route
let app = Router::new()
.route("/api/*fn_name", post(server_fn_handler))
.leptos_routes_with_handler(routes, get(leptos_routes_handler) )
.fallback(file_and_error_handler)
.layer(AuthSessionLayer::<User, i64, SessionSqlitePool, SqlitePool>::new(Some(pool.clone()))
.with_config(auth_config))
.layer(SessionLayer::new(session_store))
.layer(Extension(Arc::new(leptos_options)))
.layer(Extension(pool));
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
log!("listening on http://{}", &addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
}
// client-only stuff for Trunk
else {
pub fn main() {
// This example cannot be built as a trunk standalone CSR-only app.
// Only the server may directly connect to the database.
}
}
}

View File

@@ -1,375 +0,0 @@
use crate::auth::*;
use crate::error_template::{ErrorTemplate, ErrorTemplateProps};
use cfg_if::cfg_if;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Todo {
id: u32,
user: Option<User>,
title: String,
created_at: String,
completed: bool,
}
cfg_if! {
if #[cfg(feature = "ssr")] {
use sqlx::SqlitePool;
pub fn pool(cx: Scope) -> Result<SqlitePool, ServerFnError> {
Ok(use_context::<SqlitePool>(cx)
.ok_or("Pool missing.")
.map_err(|e| ServerFnError::ServerError(e.to_string()))?)
}
pub fn auth(cx: Scope) -> Result<AuthSession, ServerFnError> {
Ok(use_context::<AuthSession>(cx)
.ok_or("Auth session missing.")
.map_err(|e| ServerFnError::ServerError(e.to_string()))?)
}
pub fn register_server_functions() {
_ = GetTodos::register();
_ = AddTodo::register();
_ = DeleteTodo::register();
_ = Login::register();
_ = Logout::register();
_ = Signup::register();
_ = GetUser::register();
_ = Foo::register();
}
#[derive(sqlx::FromRow, Clone)]
pub struct SqlTodo {
id: u32,
user_id: i64,
title: String,
created_at: String,
completed: bool,
}
impl SqlTodo {
pub async fn into_todo(self, pool: &SqlitePool) -> Todo {
Todo {
id: self.id,
user: User::get(self.user_id, pool).await,
title: self.title,
created_at: self.created_at,
completed: self.completed,
}
}
}
}
}
#[server(GetTodos, "/api")]
pub async fn get_todos(cx: Scope) -> Result<Vec<Todo>, ServerFnError> {
use futures::TryStreamExt;
let pool = pool(cx)?;
let mut todos = Vec::new();
let mut rows = sqlx::query_as::<_, SqlTodo>("SELECT * FROM todos").fetch(&pool);
while let Some(row) = rows
.try_next()
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?
{
todos.push(row);
}
// why can't we just have async closures?
// let mut rows: Vec<Todo> = rows.iter().map(|t| async { t }).collect();
let mut converted_todos = Vec::with_capacity(todos.len());
for t in todos {
let todo = t.into_todo(&pool).await;
converted_todos.push(todo);
}
let todos: Vec<Todo> = converted_todos;
Ok(todos)
}
#[server(AddTodo, "/api")]
pub async fn add_todo(cx: Scope, title: String) -> Result<(), ServerFnError> {
let user = get_user(cx).await?;
let pool = pool(cx)?;
let id = match user {
Some(user) => user.id,
None => -1,
};
// fake API delay
std::thread::sleep(std::time::Duration::from_millis(1250));
match sqlx::query("INSERT INTO todos (title, user_id, completed) VALUES (?, ?, false)")
.bind(title)
.bind(id)
.execute(&pool)
.await
{
Ok(_row) => Ok(()),
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
}
}
#[server(DeleteTodo, "/api")]
pub async fn delete_todo(cx: Scope, id: u16) -> Result<(), ServerFnError> {
let pool = pool(cx)?;
sqlx::query("DELETE FROM todos WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.map(|_| ())
.map_err(|e| ServerFnError::ServerError(e.to_string()))
}
#[component]
pub fn TodoApp(cx: Scope) -> impl IntoView {
let login = create_server_action::<Login>(cx);
let logout = create_server_action::<Logout>(cx);
let signup = create_server_action::<Signup>(cx);
let user = create_resource(
cx,
move || {
(
login.version().get(),
signup.version().get(),
logout.version().get(),
)
},
move |_| get_user(cx),
);
provide_meta_context(cx);
view! {
cx,
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Stylesheet id="leptos" href="/pkg/session_auth_axum.css"/>
<Router>
<header>
<A href="/"><h1>"My Tasks"</h1></A>
<Transition
fallback=move || view! {cx, <span>"Loading..."</span>}
>
{move || {
user.read(cx).map(|user| match user {
Err(e) => view! {cx,
<A href="/signup">"Signup"</A>", "
<A href="/login">"Login"</A>", "
<span>{format!("Login error: {}", e.to_string())}</span>
}.into_view(cx),
Ok(None) => view! {cx,
<A href="/signup">"Signup"</A>", "
<A href="/login">"Login"</A>", "
<span>"Logged out."</span>
}.into_view(cx),
Ok(Some(user)) => view! {cx,
<A href="/settings">"Settings"</A>", "
<span>{format!("Logged in as: {} ({})", user.username, user.id)}</span>
}.into_view(cx)
})
}}
</Transition>
</header>
<hr/>
<main>
<Routes>
<Route path="" view=|cx| view! {
cx,
<ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors=errors/>}>
<Todos/>
</ErrorBoundary>
}/> //Route
<Route path="signup" view=move |cx| view! {
cx,
<Signup action=signup/>
}/>
<Route path="login" view=move |cx| view! {
cx,
<Login action=login />
}/>
<Route path="settings" view=move |cx| view! {
cx,
<h1>"Settings"</h1>
<Logout action=logout />
}/>
</Routes>
</main>
</Router>
}
}
#[component]
pub fn Todos(cx: Scope) -> impl IntoView {
let add_todo = create_server_multi_action::<AddTodo>(cx);
let delete_todo = create_server_action::<DeleteTodo>(cx);
let submissions = add_todo.submissions();
// list of todos is loaded from the server in reaction to changes
let todos = create_resource(
cx,
move || (add_todo.version().get(), delete_todo.version().get()),
move |_| get_todos(cx),
);
view! {
cx,
<div>
<MultiActionForm action=add_todo>
<label>
"Add a Todo"
<input type="text" name="title"/>
</label>
<input type="submit" value="Add"/>
</MultiActionForm>
<Transition fallback=move || view! {cx, <p>"Loading..."</p> }>
{move || {
let existing_todos = {
move || {
todos.read(cx)
.map(move |todos| match todos {
Err(e) => {
vec![view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_any()]
}
Ok(todos) => {
if todos.is_empty() {
vec![view! { cx, <p>"No tasks were found."</p> }.into_any()]
} else {
todos
.into_iter()
.map(move |todo| {
view! {
cx,
<li>
{todo.title}
": Created at "
{todo.created_at}
" by "
{
todo.user.unwrap_or_default().username
}
<ActionForm action=delete_todo>
<input type="hidden" name="id" value={todo.id}/>
<input type="submit" value="X"/>
</ActionForm>
</li>
}
.into_any()
})
.collect::<Vec<_>>()
}
}
})
.unwrap_or_default()
}
};
let pending_todos = move || {
submissions
.get()
.into_iter()
.filter(|submission| submission.pending().get())
.map(|submission| {
view! {
cx,
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li>
}
})
.collect::<Vec<_>>()
};
view! {
cx,
<ul>
{existing_todos}
{pending_todos}
</ul>
}
}
}
</Transition>
</div>
}
}
#[component]
pub fn Login(cx: Scope, action: Action<Login, Result<(), ServerFnError>>) -> impl IntoView {
view! {
cx,
<ActionForm action=action>
<h1>"Log In"</h1>
<label>
"User ID:"
<input type="text" placeholder="User ID" maxlength="32" name="username" class="auth-input" />
</label>
<br/>
<label>
"Password:"
<input type="password" placeholder="Password" name="password" class="auth-input" />
</label>
<br/>
<label>
<input type="checkbox" name="remember" class="auth-input" />
"Remember me?"
</label>
<br/>
<button type="submit" class="button">"Log In"</button>
</ActionForm>
}
}
#[component]
pub fn Signup(cx: Scope, action: Action<Signup, Result<(), ServerFnError>>) -> impl IntoView {
view! {
cx,
<ActionForm action=action>
<h1>"Sign Up"</h1>
<label>
"User ID:"
<input type="text" placeholder="User ID" maxlength="32" name="username" class="auth-input" />
</label>
<br/>
<label>
"Password:"
<input type="password" placeholder="Password" name="password" class="auth-input" />
</label>
<br/>
<label>
"Confirm Password:"
<input type="password" placeholder="Password again" name="password_confirmation" class="auth-input" />
</label>
<br/>
<label>
"Remember me?"
<input type="checkbox" name="remember" class="auth-input" />
</label>
<br/>
<button type="submit" class="button">"Sign Up"</button>
</ActionForm>
}
}
#[component]
pub fn Logout(cx: Scope, action: Action<Logout, Result<(), ServerFnError>>) -> impl IntoView {
view! {
cx,
<div id="loginbox">
<ActionForm action=action>
<button type="submit" class="button">"Log Out"</button>
</ActionForm>
</div>
}
}

View File

@@ -1,7 +0,0 @@
.pending {
color: purple;
}
a {
color: black;
}

View File

@@ -1,9 +0,0 @@
[tasks.build]
command = "cargo"
args = ["+nightly", "build-all-features"]
install_crate = "cargo-all-features"
[tasks.check]
command = "cargo"
args = ["+nightly", "check-all-features"]
install_crate = "cargo-all-features"

View File

@@ -23,7 +23,7 @@ simple_logger = "4"
thiserror = "1"
axum = { version = "0.6.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = ["fs"], optional = true }
tower-http = { version = "0.3.4", features = ["fs"], optional = true }
tokio = { version = "1", features = ["time"], optional = true}
wasm-bindgen = "0.2"
@@ -41,12 +41,12 @@ ssr = [
]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "ssr_modes"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss"

View File

@@ -1,9 +0,0 @@
[tasks.build]
command = "cargo"
args = ["+nightly", "build-all-features"]
install_crate = "cargo-all-features"
[tasks.check]
command = "cargo"
args = ["+nightly", "check-all-features"]
install_crate = "cargo-all-features"

View File

@@ -6,38 +6,32 @@ use leptos_router::*;
pub fn App(cx: Scope) -> impl IntoView {
provide_meta_context(cx);
let (count, set_count) = create_signal(cx, 0);
view! {
cx,
<Stylesheet id="leptos" href="/pkg/tailwind.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Router>
<Routes>
<Route path="" view= move |cx| view! { cx, <Home/> }/>
<Route path="" view= move |cx| view! {
cx,
<main class="my-0 mx-auto max-w-3xl text-center">
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
<button
class="bg-sky-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
on:click=move |_| set_count.update(|count| *count += 1)
>
{move || if count() == 0 {
"Click me!".to_string()
} else {
count().to_string()
}}
</button>
</main>
}/>
</Routes>
</Router>
}
}
#[component]
fn Home(cx: Scope) -> impl IntoView {
let (count, set_count) = create_signal(cx, 0);
view! { cx,
<main class="my-0 mx-auto max-w-3xl text-center">
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
<button
class="bg-amber-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
on:click=move |_| set_count.update(|count| *count += 1)
>
"Something's here | "
{move || if count() == 0 {
"Click me!".to_string()
} else {
count().to_string()
}}
" | Some more text"
</button>
</main>
}
}

View File

@@ -1,5 +1,5 @@
/*
! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com
! tailwindcss v3.1.8 | MIT License | https://tailwindcss.com
*/
/*
@@ -30,7 +30,6 @@
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
*/
html {
@@ -45,8 +44,6 @@ html {
/* 3 */
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
font-feature-settings: normal;
/* 5 */
}
/*
@@ -413,13 +410,54 @@ video {
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
*, ::before, ::after {
::-webkit-backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
@@ -531,39 +569,11 @@ video {
border-radius: 0.5rem;
}
.rounded-md {
border-radius: 0.375rem;
}
.rounded-sm {
border-radius: 0.125rem;
}
.bg-amber-600 {
--tw-bg-opacity: 1;
background-color: rgb(217 119 6 / var(--tw-bg-opacity));
}
.bg-sky-600 {
--tw-bg-opacity: 1;
background-color: rgb(2 132 199 / var(--tw-bg-opacity));
}
.bg-sky-300 {
--tw-bg-opacity: 1;
background-color: rgb(125 211 252 / var(--tw-bg-opacity));
}
.bg-sky-500 {
--tw-bg-opacity: 1;
background-color: rgb(14 165 233 / var(--tw-bg-opacity));
}
.bg-amber-500 {
--tw-bg-opacity: 1;
background-color: rgb(245 158 11 / var(--tw-bg-opacity));
}
.p-6 {
padding: 1.5rem;
}
@@ -595,10 +605,6 @@ video {
text-align: center;
}
.text-right {
text-align: right;
}
.text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
@@ -609,41 +615,6 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.text-red-500 {
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity));
}
.text-red-200 {
--tw-text-opacity: 1;
color: rgb(254 202 202 / var(--tw-text-opacity));
}
.text-sky-500 {
--tw-text-opacity: 1;
color: rgb(14 165 233 / var(--tw-text-opacity));
}
.text-sky-300 {
--tw-text-opacity: 1;
color: rgb(125 211 252 / var(--tw-text-opacity));
}
.text-sky-700 {
--tw-text-opacity: 1;
color: rgb(3 105 161 / var(--tw-text-opacity));
}
.text-sky-800 {
--tw-text-opacity: 1;
color: rgb(7 89 133 / var(--tw-text-opacity));
}
.text-red-800 {
--tw-text-opacity: 1;
color: rgb(153 27 27 / var(--tw-text-opacity));
}
.hover\:bg-sky-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(3 105 161 / var(--tw-bg-opacity));

View File

@@ -1,6 +1,6 @@
# Leptos Todo App Sqlite
This example creates a basic todo app with an Actix backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server.
This example creates a basic todo app with an Axum backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server.
## Client Side Rendering
To run it as a Client Side App, you can issue `trunk serve --open` in the root. This will build the entire

View File

@@ -129,20 +129,7 @@ pub fn Todos(cx: Scope) -> impl IntoView {
view! {
cx,
<div>
<MultiActionForm
// we can handle client-side validation in the on:submit event
// leptos_router implements a `FromFormData` trait that lets you
// parse deserializable types from form data and check them
on:submit=move |ev| {
let data = AddTodo::from_event(&ev).expect("to parse form data");
// silly example of validation: if the todo is "nope!", nope it
if data.title == "nope!" {
// ev.prevent_default() will prevent form submission
ev.prevent_default();
}
}
action=add_todo
>
<MultiActionForm action=add_todo>
<label>
"Add a Todo"
<input type="text" name="title"/>

View File

@@ -27,7 +27,7 @@ gloo-net = { version = "0.2.5", features = ["http"] }
reqwest = { version = "0.11.13", features = ["json"] }
axum = { version = "0.6.1", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4", features = ["fs"], optional = true }
tower-http = { version = "0.3.4", features = ["fs"], optional = true }
tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.8" }
sqlx = { version = "0.6.2", features = [

View File

@@ -1,3 +1,3 @@
.pending {
color: purple;
}
}

View File

@@ -143,7 +143,7 @@ pub fn TodoMVC(cx: Scope) -> impl IntoView {
});
// Callback to add a todo on pressing the `Enter` key, if the field isn't empty
let input_ref = create_node_ref::<Input>(cx);
let input_ref = NodeRef::<Input>::new(cx);
let add_todo = move |ev: web_sys::KeyboardEvent| {
let input = input_ref.get().unwrap();
ev.stop_propagation();
@@ -273,8 +273,8 @@ pub fn Todo(cx: Scope, todo: Todo) -> impl IntoView {
let (editing, set_editing) = create_signal(cx, false);
let set_todos = use_context::<WriteSignal<Todos>>(cx).unwrap();
// this will be filled by node_ref=input below
let todo_input = create_node_ref::<Input>(cx);
// this will be filled by _ref=input below
let todo_input = NodeRef::<Input>::new(cx);
let save = move |value: &str| {
let value = value.trim();
@@ -294,7 +294,7 @@ pub fn Todo(cx: Scope, todo: Todo) -> impl IntoView {
>
<div class="view">
<input
node_ref=todo_input
_ref=todo_input
class="toggle"
type="checkbox"
prop:checked={move || (todo.completed)()}

View File

@@ -111,8 +111,6 @@ pub fn redirect(cx: leptos::Scope, path: &str) {
/// Decomposes an HTTP request into its parts, allowing you to read its headers
/// and other data without consuming the body.
#[deprecated(note = "Replaced with generate_request_and_parts() to allow for \
putting LeptosRequest in the Context")]
pub async fn generate_request_parts(req: Request<Body>) -> RequestParts {
// provide request headers as context in server scope
let (parts, body) = req.into_parts();
@@ -126,88 +124,6 @@ pub async fn generate_request_parts(req: Request<Body>) -> RequestParts {
}
}
/// Decomposes an HTTP request into its parts, allowing you to read its headers
/// and other data without consuming the body. Creates a new Request from the
/// original parts for further processsing
pub async fn generate_request_and_parts(
req: Request<Body>,
) -> (Request<Body>, RequestParts) {
// provide request headers as context in server scope
let (parts, body) = req.into_parts();
let body = body::to_bytes(body).await.unwrap_or_default();
let request_parts = RequestParts {
method: parts.method.clone(),
uri: parts.uri.clone(),
headers: parts.headers.clone(),
version: parts.version,
body: body.clone(),
};
let request = Request::from_parts(parts, body.into());
(request, request_parts)
}
/// A struct to hold the http::request::Request and allow users to take ownership of it
/// Requred by Request not being Clone. See this issue for eventual resolution: https://github.com/hyperium/http/pull/574
#[derive(Debug, Default)]
pub struct LeptosRequest<B>(Arc<RwLock<Option<Request<B>>>>);
impl<B> Clone for LeptosRequest<B> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<B> LeptosRequest<B> {
/// Overwrite the contents of a LeptosRequest with a new Request<B>
pub fn overwrite(&self, req: Option<Request<B>>) {
let mut writable = self.0.write();
*writable = req
}
/// Consume the inner Request<B> inside the LeptosRequest and return it
///```rust, ignore
/// use axum::{
/// RequestPartsExt,
/// headers::Host
/// };
/// #[server(GetHost, "/api")]
/// pub async fn get_host(cx: Scope) -> Result((), ServerFnError){
/// let req = use_context::<leptos_axum::LeptosRequest<axum::body::Body>>(cx);
/// if let Some(req) = req{
/// let owned_req = req.take_request().unwrap();
/// let (mut parts, _body) = owned_req.into_parts();
/// let host: TypedHeader<Host> = parts.extract().await().unwrap();
/// println!("Host: {host:#?}");
/// }
/// }
/// ```
pub fn take_request(&self) -> Option<Request<B>> {
let mut writable = self.0.write();
writable.take()
}
/// Can be used to get immutable access to the interior fields of Request
/// and do something with them
pub fn with(&self, with_fn: impl Fn(Option<&Request<B>>)) {
let readable = self.0.read();
with_fn(readable.as_ref());
}
/// Can be used to mutate the fields of the Request
pub fn update(&self, update_fn: impl Fn(Option<&mut Request<B>>)) {
let mut writable = self.0.write();
update_fn(writable.as_mut());
}
}
/// Generate a wrapper for the http::Request::Request type that allows one to
/// processs it, access the body, and use axum Extractors on it.
/// Requred by Request not being Clone. See this issue for eventual resolution: https://github.com/hyperium/http/pull/574
pub async fn generate_leptos_request<B>(req: Request<B>) -> LeptosRequest<B>
where
B: Default + std::fmt::Debug,
{
let leptos_request = LeptosRequest::default();
leptos_request.overwrite(Some(req));
leptos_request
}
/// An Axum handlers to listens for a request with Leptos server function arguments in the body,
/// run the server function if found, and return the resulting [Response].
///
@@ -302,11 +218,9 @@ async fn handle_server_fns_inner(
additional_context(cx);
let (req, req_parts) =
generate_request_and_parts(req).await;
let leptos_req = generate_leptos_request(req).await; // Add this so we can get details about the Request
let req_parts = generate_request_parts(req).await;
// Add this so we can get details about the Request
provide_context(cx, req_parts.clone());
provide_context(cx, leptos_req);
// Add this so that we can set headers and status of the response
provide_context(cx, ResponseOptions::default());
@@ -642,10 +556,9 @@ where
.run_until(async {
let app = {
let full_path = full_path.clone();
let (req, req_parts) = generate_request_and_parts(req).await;
let leptos_req = generate_leptos_request(req).await;
let req_parts = generate_request_parts(req).await;
move |cx| {
provide_contexts(cx, full_path, req_parts,leptos_req, default_res_options);
provide_contexts(cx, full_path, req_parts, default_res_options);
app_fn(cx).into_view(cx)
}
};
@@ -811,10 +724,9 @@ where
.run_until(async {
let app = {
let full_path = full_path.clone();
let (req, req_parts) = generate_request_and_parts(req).await;
let leptos_req = generate_leptos_request(req).await;
let req_parts = generate_request_parts(req).await;
move |cx| {
provide_contexts(cx, full_path, req_parts,leptos_req, default_res_options);
provide_contexts(cx, full_path, req_parts, default_res_options);
app_fn(cx).into_view(cx)
}
};
@@ -840,18 +752,16 @@ where
}
}
fn provide_contexts<B: 'static + std::fmt::Debug + std::default::Default>(
fn provide_contexts(
cx: Scope,
path: String,
req_parts: RequestParts,
leptos_req: LeptosRequest<B>,
default_res_options: ResponseOptions,
) {
let integration = ServerIntegration { path };
provide_context(cx, RouterIntegrationContext::new(integration));
provide_context(cx, MetaContext::new());
provide_context(cx, req_parts);
provide_context(cx, leptos_req);
provide_context(cx, default_res_options);
provide_server_redirect(cx, move |path| redirect(cx, path));
}
@@ -994,10 +904,9 @@ where
.run_until(async {
let app = {
let full_path = full_path.clone();
let (req, req_parts) = generate_request_and_parts(req).await;
let leptos_req = generate_leptos_request(req).await;
let req_parts = generate_request_parts(req).await;
move |cx| {
provide_contexts(cx, full_path, req_parts,leptos_req, default_res_options);
provide_contexts(cx, full_path, req_parts, default_res_options);
app_fn(cx).into_view(cx)
}
};

View File

@@ -10,7 +10,6 @@ description = "Utilities to help build server integrations for the Leptos web fr
[dependencies]
futures = "0.3"
leptos = { workspace = true, features = ["ssr"] }
leptos_hot_reload = { workspace = true }
leptos_meta = { workspace = true, features = ["ssr"] }
leptos_router = { workspace = true, features = ["ssr"] }
leptos_config = { workspace = true }

View File

@@ -25,7 +25,6 @@ pub fn html_parts(
true => format!(
r#"
<script crossorigin="">(function () {{
{}
var ws = new WebSocket('ws://{site_ip}:{reload_port}/live_reload');
ws.onmessage = (ev) => {{
let msg = JSON.parse(ev.data);
@@ -41,15 +40,11 @@ pub fn html_parts(
}});
if (!found) console.warn(`CSS hot-reload: Could not find a <link href=/\"${{msg.css}}\"> element`);
}};
if(msg.view) {{
patch(msg.view);
}}
}};
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
}})()
</script>
"#,
leptos_hot_reload::HOT_RELOAD_JS
"#
),
false => "".to_string(),
};

View File

@@ -16,8 +16,7 @@ leptos_reactive = { workspace = true }
leptos_server = { workspace = true }
leptos_config = { workspace = true }
tracing = "0.1"
typed-builder = "0.14"
server_fn = { workspace = true }
typed-builder = "0.12"
[dev-dependencies]
leptos = { path = ".", default-features = false }
@@ -51,7 +50,6 @@ stable = [
serde = ["leptos_reactive/serde"]
serde-lite = ["leptos_reactive/serde-lite"]
miniserde = ["leptos_reactive/miniserde"]
rkyv = ["leptos_reactive/rkyv"]
tracing = ["leptos_macro/tracing"]
[package.metadata.cargo-all-features]
@@ -81,16 +79,4 @@ skip_feature_sets = [
"serde",
"miniserde",
],
[
"serde",
"rkyv",
],
[
"miniserde",
"rkyv",
],
[
"serde-lite",
"rkyv",
],
]

View File

@@ -1,20 +1,20 @@
[tasks.check-wasm]
[tasks.build-wasm]
clear = true
dependencies = ["check-hydrate", "check-csr"]
dependencies = ["build-hydrate", "build-csr"]
[tasks.check-hydrate]
[tasks.build-hydrate]
command = "cargo"
args = [
"check",
"build",
"--no-default-features",
"--features=hydrate",
"--target=wasm32-unknown-unknown",
]
[tasks.check-csr]
[tasks.build-csr]
command = "cargo"
args = [
"check",
"build",
"--no-default-features",
"--features=csr",
"--target=wasm32-unknown-unknown",

View File

@@ -38,6 +38,11 @@
//! `Result` types to handle errors.
//! - [`parent_child`](https://github.com/leptos-rs/leptos/tree/main/examples/parent_child) shows four different
//! ways a parent component can communicate with a child, including passing a closure, context, and more
//! - [`todomvc`](https://github.com/leptos-rs/leptos/tree/main/examples/todomvc) implements the classic to-do
//! app in Leptos. This is a good example of a complete, simple app. In particular, you might want to
//! see how we use [create_effect] to [serialize JSON to `localStorage`](https://github.com/leptos-rs/leptos/blob/16f084a71268ac325fbc4a5e50c260df185eadb6/examples/todomvc/src/lib.rs#L164)
//! and [reactively call DOM methods](https://github.com/leptos-rs/leptos/blob/6d7c36655c9e7dcc3a3ad33d2b846a3f00e4ae74/examples/todomvc/src/lib.rs#L291)
//! on [references to elements](https://github.com/leptos-rs/leptos/blob/6d7c36655c9e7dcc3a3ad33d2b846a3f00e4ae74/examples/todomvc/src/lib.rs#L254).
//! - [`fetch`](https://github.com/leptos-rs/leptos/tree/main/examples/fetch) introduces
//! [Resource](leptos_reactive::Resource)s, which allow you to integrate arbitrary `async` code like an
//! HTTP request within your reactive code.
@@ -49,10 +54,6 @@
//! - [`todomvc`](https://github.com/leptos-rs/leptos/tree/main/examples/todomvc) shows the basics of building an
//! isomorphic web app. Both the server and the client import the same app code from the `todomvc` example.
//! The server renders the app directly to an HTML string, and the client hydrates that HTML to make it interactive.
//! You might also want to
//! see how we use [create_effect] to [serialize JSON to `localStorage`](https://github.com/leptos-rs/leptos/blob/16f084a71268ac325fbc4a5e50c260df185eadb6/examples/todomvc/src/lib.rs#L164)
//! and [reactively call DOM methods](https://github.com/leptos-rs/leptos/blob/6d7c36655c9e7dcc3a3ad33d2b846a3f00e4ae74/examples/todomvc/src/lib.rs#L291)
//! on [references to elements](https://github.com/leptos-rs/leptos/blob/6d7c36655c9e7dcc3a3ad33d2b846a3f00e4ae74/examples/todomvc/src/lib.rs#L254).
//! - [`hackernews`](https://github.com/leptos-rs/leptos/tree/main/examples/hackernews)
//! and [`hackernews_axum`](https://github.com/leptos-rs/leptos/tree/main/examples/hackernews_axum)
//! integrate calls to a real external REST API, routing, server-side rendering and hydration to create
@@ -167,7 +168,6 @@ pub use leptos_server::{
self, create_action, create_multi_action, create_server_action,
create_server_multi_action, Action, MultiAction, ServerFn, ServerFnError,
};
pub use server_fn::{self, ServerFn as _};
pub use typed_builder;
mod error_boundary;
pub use error_boundary::*;

View File

@@ -119,7 +119,9 @@ where
if is_first_run(&first_run, &suspense_context) {
let has_local_only = suspense_context.has_local_only();
*prev_children.borrow_mut() = Some(frag.nodes.clone());
if !has_local_only || child_runs.get() > 0 {
if (has_local_only && child_runs.get() > 0)
|| !has_local_only
{
first_run.set(false);
}
}

View File

@@ -16,13 +16,11 @@ fn simple_ssr_test() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-8|open--><div \
id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
"<div id=\"_0-1\"><button id=\"_0-2\">-1</button><span \
id=\"_0-3\">Value: \
<!--hk=_0-4o|leptos-dyn-child-start-->0<!\
--hk=_0-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-8|close-->"
id=\"_0-5\">+1</button></div>"
);
});
}
@@ -56,25 +54,21 @@ fn ssr_test_with_components() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-49|open--><div id=\"_0-1\" \
class=\"counters\"><!--hk=_0-1-0o|leptos-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
"<div class=\"counters\" \
id=\"_0-1\"><!--hk=_0-1-0o|leptos-counter-start--><div \
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
id=\"_0-1-3\">Value: \
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-38|close--><!--hk=_0-1-0c|leptos-counter-end--><!\
--hk=_0-1-5-0o|leptos-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-38|open--><div \
id=\"_0-1-5\">+1</button></div><!\
--hk=_0-1-0c|leptos-counter-end--><!\
--hk=_0-1-5-0o|leptos-counter-start--><div \
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
id=\"_0-1-5-3\">Value: \
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5-5\">+1</button></div><!\
--leptos-view|leptos-tests-ssr.rs-38|close--><!\
--hk=_0-1-5-0c|leptos-counter-end--></div><!\
--leptos-view|leptos-tests-ssr.rs-49|close-->"
--hk=_0-1-5-0c|leptos-counter-end--></div>"
);
});
}
@@ -108,26 +102,22 @@ fn ssr_test_with_snake_case_components() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-101|open--><div id=\"_0-1\" \
class=\"counters\"><!\
--hk=_0-1-0o|leptos-snake-case-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
"<div class=\"counters\" \
id=\"_0-1\"><!\
--hk=_0-1-0o|leptos-snake-case-counter-start--><div \
id=\"_0-1-1\"><button id=\"_0-1-2\">-1</button><span \
id=\"_0-1-3\">Value: \
<!--hk=_0-1-4o|leptos-dyn-child-start-->1<!\
--hk=_0-1-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5\">+1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-90|close--><!--hk=_0-1-0c|leptos-snake-case-counter-end--><!\
--hk=_0-1-5-0o|leptos-snake-case-counter-start--><!\
--leptos-view|leptos-tests-ssr.rs-90|open--><div \
id=\"_0-1-5\">+1</button></div><!\
--hk=_0-1-0c|leptos-snake-case-counter-end--><!\
--hk=_0-1-5-0o|leptos-snake-case-counter-start--><div \
id=\"_0-1-5-1\"><button id=\"_0-1-5-2\">-1</button><span \
id=\"_0-1-5-3\">Value: \
<!--hk=_0-1-5-4o|leptos-dyn-child-start-->2<!\
--hk=_0-1-5-4c|leptos-dyn-child-end-->!</span><button \
id=\"_0-1-5-5\">+1</button></div><!\
--leptos-view|leptos-tests-ssr.rs-90|close--><!\
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div><!\
--leptos-view|leptos-tests-ssr.rs-101|close-->"
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div>"
);
});
}
@@ -146,9 +136,7 @@ fn test_classes() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-142|open--><div id=\"_0-1\" \
class=\"my big red \
car\"></div><!--leptos-view|leptos-tests-ssr.rs-142|close-->"
"<div class=\"my big red car\" id=\"_0-1\"></div>"
);
});
}
@@ -170,10 +158,8 @@ fn ssr_with_styles() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-164|open--><div id=\"_0-1\" \
class=\" myclass\"><button id=\"_0-2\" class=\"btn \
myclass\">-1</button></div><!--leptos-view|leptos-tests-ssr.\
rs-164|close-->"
"<div class=\"myclass\" id=\"_0-1\"><button class=\"btn myclass\" \
id=\"_0-2\">-1</button></div>"
);
});
}
@@ -192,9 +178,7 @@ fn ssr_option() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<!--leptos-view|leptos-tests-ssr.rs-188|open--><option \
id=\"_0-1\"></option><!--leptos-view|leptos-tests-ssr.\
rs-188|close-->"
"<option id=\"_0-1\"></option>"
);
});
}

View File

@@ -14,8 +14,4 @@ fs = "0.0.5"
regex = "1.7.0"
serde = { version = "1.0.151", features = ["derive"] }
thiserror = "1.0.38"
typed-builder = "0.14"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }
tempfile = "3"
typed-builder = "0.12"

View File

@@ -5,10 +5,7 @@ pub mod errors;
use crate::errors::LeptosConfigError;
use config::{Config, File, FileFormat};
use regex::Regex;
use std::{
convert::TryFrom, env::VarError, fs, net::SocketAddr, path::Path,
str::FromStr,
};
use std::{convert::TryFrom, env::VarError, fs, net::SocketAddr, str::FromStr};
use typed_builder::TypedBuilder;
/// A Struct to allow us to parse LeptosOptions from the file. Not really needed, most interactions should
@@ -120,7 +117,6 @@ impl From<&str> for Env {
from_str(str).unwrap_or_else(|err| panic!("{}", err))
}
}
impl From<&Result<String, VarError>> for Env {
fn from(input: &Result<String, VarError>) -> Self {
match input {
@@ -138,67 +134,48 @@ impl TryFrom<String> for Env {
}
}
/// Loads [LeptosOptions] from a Cargo.toml text content with layered overrides.
/// If an env var is specified, like `LEPTOS_ENV`, it will override a setting in the file.
pub fn get_config_from_str(text: &str) -> Result<ConfFile, LeptosConfigError> {
let re: Regex = Regex::new(r#"(?m)^\[package.metadata.leptos\]"#).unwrap();
let start = match re.find(text) {
Some(found) => found.start(),
None => return Err(LeptosConfigError::ConfigSectionNotFound),
};
// so that serde error messages have right line number
let newlines = text[..start].matches('\n').count();
let input = "\n".repeat(newlines) + &text[start..];
let toml = input
.replace("[package.metadata.leptos]", "[leptos_options]")
.replace('-', "_");
let settings = Config::builder()
// Read the "default" configuration file
.add_source(File::from_str(&toml, FileFormat::Toml))
// Layer on the environment-specific values.
// Add in settings from environment variables (with a prefix of LEPTOS and '_' as separator)
// E.g. `LEPTOS_RELOAD_PORT=5001 would set `LeptosOptions.reload_port`
.add_source(config::Environment::with_prefix("LEPTOS").separator("_"))
.build()?;
settings
.try_deserialize()
.map_err(|e| LeptosConfigError::ConfigError(e.to_string()))
}
/// Loads [LeptosOptions] from a Cargo.toml with layered overrides. If an env var is specified, like `LEPTOS_ENV`,
/// it will override a setting in the file. It takes in an optional path to a Cargo.toml file. If None is provided,
/// you'll need to set the options as environment variables or rely on the defaults. This is the preferred
/// approach for cargo-leptos. If Some("./Cargo.toml") is provided, Leptos will read in the settings itself. This
/// option currently does not allow dashes in file or folder names, as all dashes become underscores
/// option currently does not allow dashes in file or foldernames, as all dashes become underscores
pub async fn get_configuration(
path: Option<&str>,
) -> Result<ConfFile, LeptosConfigError> {
if let Some(path) = path {
get_config_from_file(&path).await
let text = fs::read_to_string(path)
.map_err(|_| LeptosConfigError::ConfigNotFound)?;
let re: Regex =
Regex::new(r#"(?m)^\[package.metadata.leptos\]"#).unwrap();
let start = match re.find(&text) {
Some(found) => found.start(),
None => return Err(LeptosConfigError::ConfigSectionNotFound),
};
// so that serde error messages have right line number
let newlines = text[..start].matches('\n').count();
let input = "\n".repeat(newlines) + &text[start..];
let toml = input
.replace("[package.metadata.leptos]", "[leptos_options]")
.replace('-', "_");
let settings = Config::builder()
// Read the "default" configuration file
.add_source(File::from_str(&toml, FileFormat::Toml))
// Layer on the environment-specific values.
// Add in settings from environment variables (with a prefix of LEPTOS and '_' as separator)
// E.g. `LEPTOS_RELOAD_PORT=5001 would set `LeptosOptions.reload_port`
.add_source(
config::Environment::with_prefix("LEPTOS").separator("_"),
)
.build()?;
settings
.try_deserialize()
.map_err(|e| LeptosConfigError::ConfigError(e.to_string()))
} else {
get_config_from_env()
Ok(ConfFile {
leptos_options: LeptosOptions::try_from_env()?,
})
}
}
/// Loads [LeptosOptions] from a Cargo.toml with layered overrides. Leptos will read in the settings itself. This
/// option currently does not allow dashes in file or folder names, as all dashes become underscores
pub async fn get_config_from_file<P: AsRef<Path>>(
path: P,
) -> Result<ConfFile, LeptosConfigError> {
let text = fs::read_to_string(path)
.map_err(|_| LeptosConfigError::ConfigNotFound)?;
get_config_from_str(&text)
}
/// Loads [LeptosOptions] from environment variables or rely on the defaults
pub fn get_config_from_env() -> Result<ConfFile, LeptosConfigError> {
Ok(ConfFile {
leptos_options: LeptosOptions::try_from_env()?,
})
}
#[path = "tests.rs"]
#[cfg(test)]
mod tests;

View File

@@ -1,69 +0,0 @@
use crate::{env_w_default, from_str, Env, LeptosOptions};
use std::{net::SocketAddr, str::FromStr};
#[test]
fn from_str_env() {
assert!(matches!(from_str("dev").unwrap(), Env::DEV));
assert!(matches!(from_str("development").unwrap(), Env::DEV));
assert!(matches!(from_str("DEV").unwrap(), Env::DEV));
assert!(matches!(from_str("DEVELOPMENT").unwrap(), Env::DEV));
assert!(matches!(from_str("prod").unwrap(), Env::PROD));
assert!(matches!(from_str("production").unwrap(), Env::PROD));
assert!(matches!(from_str("PROD").unwrap(), Env::PROD));
assert!(matches!(from_str("PRODUCTION").unwrap(), Env::PROD));
assert!(from_str("TEST").is_err());
assert!(from_str("?").is_err());
}
#[test]
fn env_w_default_test() {
std::env::set_var("LEPTOS_CONFIG_ENV_TEST", "custom");
assert_eq!(
env_w_default("LEPTOS_CONFIG_ENV_TEST", "default").unwrap(),
String::from("custom")
);
std::env::remove_var("LEPTOS_CONFIG_ENV_TEST");
assert_eq!(
env_w_default("LEPTOS_CONFIG_ENV_TEST", "default").unwrap(),
String::from("default")
);
}
#[test]
fn try_from_env_test() {
std::env::remove_var("LEPTOS_OUTPUT_NAME");
assert!(LeptosOptions::try_from_env().is_err());
// Test config values from environment variables
std::env::set_var("LEPTOS_OUTPUT_NAME", "app_test");
std::env::set_var("LEPTOS_SITE_ROOT", "my_target/site");
std::env::set_var("LEPTOS_SITE_PKG_DIR", "my_pkg");
std::env::set_var("LEPTOS_SITE_ADDR", "0.0.0.0:80");
std::env::set_var("LEPTOS_RELOAD_PORT", "8080");
let config = LeptosOptions::try_from_env().unwrap();
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
);
assert_eq!(config.reload_port, 8080);
// Test default config values
std::env::remove_var("LEPTOS_SITE_ROOT");
std::env::remove_var("LEPTOS_SITE_PKG_DIR");
std::env::remove_var("LEPTOS_SITE_ADDR");
std::env::remove_var("LEPTOS_RELOAD_PORT");
let config = LeptosOptions::try_from_env().unwrap();
assert_eq!(config.site_root, "target/site");
assert_eq!(config.site_pkg_dir, "pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("127.0.0.1:3000").unwrap()
);
assert_eq!(config.reload_port, 3001);
}

View File

@@ -1,192 +0,0 @@
use leptos_config::{
get_config_from_file, get_config_from_str, get_configuration, Env,
LeptosOptions,
};
use std::{fs::File, io::Write, net::SocketAddr, path::Path, str::FromStr};
use tempfile::NamedTempFile;
#[test]
fn env_default() {
assert!(matches!(Env::default(), Env::DEV));
}
const CARGO_TOML_CONTENT_OK: &str = r#"\
[package.metadata.leptos]
output-name = "app-test"
site-root = "my_target/site"
site-pkg-dir = "my_pkg"
site-addr = "0.0.0.0:80"
reload-port = "8080"
env = "PROD"
"#;
const CARGO_TOML_CONTENT_ERR: &str = r#"\
[package.metadata.leptos]
_output-name = "app-test"
_site-root = "my_target/site"
_site-pkg-dir = "my_pkg"
_site-addr = "0.0.0.0:80"
_reload-port = "8080"
_env = "PROD"
"#;
#[tokio::test]
async fn get_configuration_from_file_ok() {
let cargo_tmp = NamedTempFile::new().unwrap();
{
let mut output = File::create(&cargo_tmp).unwrap();
write!(output, "{CARGO_TOML_CONTENT_OK}").unwrap();
}
let path: &Path = cargo_tmp.as_ref();
let path_s = path.to_string_lossy().to_string();
let config = get_configuration(Some(&path_s))
.await
.unwrap()
.leptos_options;
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
);
assert_eq!(config.reload_port, 8080);
}
#[tokio::test]
async fn get_configuration_from_invalid_file() {
let cargo_tmp = NamedTempFile::new().unwrap();
{
let mut output = File::create(&cargo_tmp).unwrap();
write!(output, "{CARGO_TOML_CONTENT_ERR}").unwrap();
}
let path: &Path = cargo_tmp.as_ref();
let path_s = path.to_string_lossy().to_string();
assert!(get_configuration(Some(&path_s)).await.is_err());
}
#[tokio::test]
async fn get_configuration_from_empty_file() {
let cargo_tmp = NamedTempFile::new().unwrap();
{
let mut output = File::create(&cargo_tmp).unwrap();
write!(output, "").unwrap();
}
let path: &Path = cargo_tmp.as_ref();
let path_s = path.to_string_lossy().to_string();
assert!(get_configuration(Some(&path_s)).await.is_err());
}
#[tokio::test]
async fn get_config_from_file_ok() {
let cargo_tmp = NamedTempFile::new().unwrap();
{
let mut output = File::create(&cargo_tmp).unwrap();
write!(output, "{CARGO_TOML_CONTENT_OK}").unwrap();
}
let config = get_config_from_file(&cargo_tmp)
.await
.unwrap()
.leptos_options;
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
);
assert_eq!(config.reload_port, 8080);
}
#[tokio::test]
async fn get_config_from_file_invalid() {
let cargo_tmp = NamedTempFile::new().unwrap();
{
let mut output = File::create(&cargo_tmp).unwrap();
write!(output, "{CARGO_TOML_CONTENT_ERR}").unwrap();
}
assert!(get_config_from_file(&cargo_tmp).await.is_err());
}
#[tokio::test]
async fn get_config_from_file_empty() {
let cargo_tmp = NamedTempFile::new().unwrap();
{
let mut output = File::create(&cargo_tmp).unwrap();
write!(output, "").unwrap();
}
assert!(get_config_from_file(&cargo_tmp).await.is_err());
}
#[test]
fn get_config_from_str_content() {
let config = get_config_from_str(CARGO_TOML_CONTENT_OK)
.unwrap()
.leptos_options;
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
);
assert_eq!(config.reload_port, 8080);
}
#[tokio::test]
async fn get_config_from_env() {
std::env::remove_var("LEPTOS_OUTPUT_NAME");
assert!(get_configuration(None).await.is_err());
// Test config values from environment variables
std::env::set_var("LEPTOS_OUTPUT_NAME", "app_test");
std::env::set_var("LEPTOS_SITE_ROOT", "my_target/site");
std::env::set_var("LEPTOS_SITE_PKG_DIR", "my_pkg");
std::env::set_var("LEPTOS_SITE_ADDR", "0.0.0.0:80");
std::env::set_var("LEPTOS_RELOAD_PORT", "8080");
let config = get_configuration(None).await.unwrap().leptos_options;
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
);
assert_eq!(config.reload_port, 8080);
// Test default config values
std::env::remove_var("LEPTOS_SITE_ROOT");
std::env::remove_var("LEPTOS_SITE_PKG_DIR");
std::env::remove_var("LEPTOS_SITE_ADDR");
std::env::remove_var("LEPTOS_RELOAD_PORT");
let config = get_configuration(None).await.unwrap().leptos_options;
assert_eq!(config.site_root, "target/site");
assert_eq!(config.site_pkg_dir, "pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("127.0.0.1:3000").unwrap()
);
assert_eq!(config.reload_port, 3001);
}
#[test]
fn leptos_options_builder_default() {
let conf = LeptosOptions::builder().output_name("app_test").build();
assert_eq!(conf.output_name, "app_test");
assert!(matches!(conf.env, Env::DEV));
assert_eq!(conf.site_pkg_dir, "pkg");
assert_eq!(conf.site_root, ".");
assert_eq!(
conf.site_addr,
SocketAddr::from_str("127.0.0.1:3000").unwrap()
);
assert_eq!(conf.reload_port, 3001);
}

View File

@@ -49,18 +49,15 @@ features = [
"BeforeUnloadEvent",
"ClipboardEvent",
"CompositionEvent",
"CustomEvent",
"DeviceMotionEvent",
"DeviceOrientationEvent",
"DragEvent",
"ErrorEvent",
"Event",
"FocusEvent",
"GamepadEvent",
"HashChangeEvent",
"InputEvent",
"KeyboardEvent",
"MessageEvent",
"MouseEvent",
"PageTransitionEvent",
"PointerEvent",

View File

@@ -63,8 +63,6 @@ pub struct ComponentRepr {
closing: Comment,
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
pub(crate) id: HydrationKey,
#[cfg(debug_assertions)]
pub(crate) view_marker: Option<String>,
}
impl fmt::Debug for ComponentRepr {
@@ -207,8 +205,6 @@ impl ComponentRepr {
children: Vec::with_capacity(1),
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
id,
#[cfg(debug_assertions)]
view_marker: None,
}
}
}

View File

@@ -39,6 +39,7 @@ cfg_if! {
}
}
use leptos_reactive::Scope;
use smallvec::SmallVec;
use std::{borrow::Cow, cell::RefCell, fmt, hash::Hash, ops::Deref, rc::Rc};
/// The internal representation of the [`Each`] core-component.

View File

@@ -25,8 +25,6 @@ pub struct Fragment {
id: HydrationKey,
/// The nodes contained in the fragment.
pub nodes: Vec<View>,
#[cfg(debug_assertions)]
pub(crate) view_marker: Option<String>,
}
impl FromIterator<View> for Fragment {
@@ -54,12 +52,7 @@ impl Fragment {
/// Creates a new [`Fragment`] with the given hydration ID from a [`Vec<Node>`].
pub fn new_with_id(id: HydrationKey, nodes: Vec<View>) -> Self {
Self {
id,
nodes,
#[cfg(debug_assertions)]
view_marker: None,
}
Self { id, nodes }
}
/// Gives access to the [View] children contained within the fragment.
@@ -71,13 +64,6 @@ impl Fragment {
pub fn id(&self) -> &HydrationKey {
&self.id
}
#[cfg(debug_assertions)]
/// Adds an optional marker indicating the view macro source.
pub fn with_view_marker(mut self, marker: impl Into<String>) -> Self {
self.view_marker = Some(marker.into());
self
}
}
impl IntoView for Fragment {
@@ -85,11 +71,6 @@ impl IntoView for Fragment {
fn into_view(self, cx: leptos_reactive::Scope) -> View {
let mut frag = ComponentRepr::new_with_id("", self.id.clone());
#[cfg(debug_assertions)]
{
frag.view_marker = self.view_marker;
}
frag.children = self.nodes;
frag.into_view(cx)

View File

@@ -128,36 +128,22 @@ generate_event_types! {
// =========================================================
// WindowEventHandlersEventMap
// =========================================================
#[does_not_bubble]
afterprint: Event,
#[does_not_bubble]
beforeprint: Event,
#[does_not_bubble]
beforeunload: BeforeUnloadEvent,
#[does_not_bubble]
gamepadconnected: GamepadEvent,
#[does_not_bubble]
gamepaddisconnected: GamepadEvent,
hashchange: HashChangeEvent,
#[does_not_bubble]
languagechange: Event,
#[does_not_bubble]
message: MessageEvent,
#[does_not_bubble]
messageerror: MessageEvent,
#[does_not_bubble]
offline: Event,
#[does_not_bubble]
online: Event,
#[does_not_bubble]
pagehide: PageTransitionEvent,
#[does_not_bubble]
pageshow: PageTransitionEvent,
popstate: PopStateEvent,
rejectionhandled: PromiseRejectionEvent,
#[does_not_bubble]
storage: StorageEvent,
#[does_not_bubble]
unhandledrejection: PromiseRejectionEvent,
#[does_not_bubble]
unload: Event,
@@ -175,9 +161,7 @@ generate_event_types! {
beforeinput: InputEvent,
#[does_not_bubble]
blur: FocusEvent,
#[does_not_bubble]
canplay: Event,
#[does_not_bubble]
canplaythrough: Event,
change: Event,
click: MouseEvent,
@@ -187,7 +171,6 @@ generate_event_types! {
compositionstart: CompositionEvent,
compositionupdate: CompositionEvent,
contextmenu: MouseEvent,
#[does_not_bubble]
cuechange: Event,
dblclick: MouseEvent,
drag: DragEvent,
@@ -197,11 +180,8 @@ generate_event_types! {
dragover: DragEvent,
dragstart: DragEvent,
drop: DragEvent,
#[does_not_bubble]
durationchange: Event,
#[does_not_bubble]
emptied: Event,
#[does_not_bubble]
ended: Event,
#[does_not_bubble]
error: ErrorEvent,
@@ -212,43 +192,32 @@ generate_event_types! {
#[does_not_bubble]
focusout: FocusEvent,
formdata: Event, // web_sys does not include `FormDataEvent`
#[does_not_bubble]
gotpointercapture: PointerEvent,
input: Event,
#[does_not_bubble]
invalid: Event,
keydown: KeyboardEvent,
keypress: KeyboardEvent,
keyup: KeyboardEvent,
#[does_not_bubble]
load: Event,
#[does_not_bubble]
loadeddata: Event,
#[does_not_bubble]
loadedmetadata: Event,
#[does_not_bubble]
loadstart: Event,
lostpointercapture: PointerEvent,
mousedown: MouseEvent,
#[does_not_bubble]
mouseenter: MouseEvent,
#[does_not_bubble]
mouseleave: MouseEvent,
mousemove: MouseEvent,
mouseout: MouseEvent,
mouseover: MouseEvent,
mouseup: MouseEvent,
#[does_not_bubble]
pause: Event,
#[does_not_bubble]
play: Event,
#[does_not_bubble]
playing: Event,
pointercancel: PointerEvent,
pointerdown: PointerEvent,
#[does_not_bubble]
pointerenter: PointerEvent,
#[does_not_bubble]
pointerleave: PointerEvent,
pointermove: PointerEvent,
pointerout: PointerEvent,
@@ -256,29 +225,21 @@ generate_event_types! {
pointerup: PointerEvent,
#[does_not_bubble]
progress: ProgressEvent,
#[does_not_bubble]
ratechange: Event,
reset: Event,
#[does_not_bubble]
resize: UiEvent,
#[does_not_bubble]
scroll: Event,
securitypolicyviolation: SecurityPolicyViolationEvent,
#[does_not_bubble]
seeked: Event,
#[does_not_bubble]
seeking: Event,
select: Event,
#[does_not_bubble]
selectionchange: Event,
selectstart: Event,
slotchange: Event,
#[does_not_bubble]
stalled: Event,
submit: SubmitEvent,
#[does_not_bubble]
suspend: Event,
#[does_not_bubble]
timeupdate: Event,
#[does_not_bubble]
toggle: Event,
@@ -290,9 +251,7 @@ generate_event_types! {
transitionend: TransitionEvent,
transitionrun: TransitionEvent,
transitionstart: TransitionEvent,
#[does_not_bubble]
volumechange: Event,
#[does_not_bubble]
waiting: Event,
webkitanimationend: Event,
webkitanimationiteration: Event,
@@ -304,11 +263,8 @@ generate_event_types! {
// WindowEventMap
// =========================================================
DOMContentLoaded: Event,
#[does_not_bubble]
devicemotion: DeviceMotionEvent,
#[does_not_bubble]
deviceorientation: DeviceOrientationEvent,
#[does_not_bubble]
orientationchange: Event,
// =========================================================
@@ -325,18 +281,16 @@ generate_event_types! {
fullscreenerror: Event,
pointerlockchange: Event,
pointerlockerror: Event,
#[does_not_bubble]
readystatechange: Event,
visibilitychange: Event,
}
// Export `web_sys` event types
pub use web_sys::{
AnimationEvent, BeforeUnloadEvent, CompositionEvent, CustomEvent,
DeviceMotionEvent, DeviceOrientationEvent, DragEvent, ErrorEvent, Event,
FocusEvent, GamepadEvent, HashChangeEvent, InputEvent, KeyboardEvent,
MouseEvent, PageTransitionEvent, PointerEvent, PopStateEvent,
ProgressEvent, PromiseRejectionEvent, SecurityPolicyViolationEvent,
StorageEvent, SubmitEvent, TouchEvent, TransitionEvent, UiEvent,
WheelEvent,
AnimationEvent, BeforeUnloadEvent, CompositionEvent, DeviceMotionEvent,
DeviceOrientationEvent, DragEvent, ErrorEvent, FocusEvent, GamepadEvent,
HashChangeEvent, InputEvent, KeyboardEvent, MouseEvent,
PageTransitionEvent, PointerEvent, PopStateEvent, ProgressEvent,
PromiseRejectionEvent, SecurityPolicyViolationEvent, StorageEvent,
SubmitEvent, TouchEvent, TransitionEvent, UiEvent, WheelEvent,
};

View File

@@ -90,8 +90,6 @@ where
element,
#[cfg(debug_assertions)]
span: ::tracing::Span::current(),
#[cfg(debug_assertions)]
view_marker: None,
}
}
@@ -261,8 +259,6 @@ cfg_if! {
pub(crate) span: ::tracing::Span,
pub(crate) cx: Scope,
pub(crate) element: El,
#[cfg(debug_assertions)]
pub(crate) view_marker: Option<String>
}
// Server needs to build a virtualized DOM tree
} else {
@@ -278,9 +274,7 @@ cfg_if! {
#[allow(clippy::type_complexity)]
pub(crate) children: SmallVec<[View; 4]>,
#[educe(Debug(ignore))]
pub(crate) prerendered: Option<Cow<'static, str>>,
#[cfg(debug_assertions)]
pub(crate) view_marker: Option<String>
pub(crate) prerendered: Option<Cow<'static, str>>
}
}
}
@@ -308,9 +302,7 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
cx,
element,
#[cfg(debug_assertions)]
span: ::tracing::Span::current(),
#[cfg(debug_assertions)]
view_marker: None
span: ::tracing::Span::current()
}
} else {
Self {
@@ -318,9 +310,7 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
attrs: smallvec![],
children: smallvec![],
element,
prerendered: None,
#[cfg(debug_assertions)]
view_marker: None
prerendered: None
}
}
}
@@ -339,18 +329,9 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
children: smallvec![],
element,
prerendered: Some(html.into()),
#[cfg(debug_assertions)]
view_marker: None,
}
}
#[cfg(debug_assertions)]
/// Adds an optional marker indicating the view macro source.
pub fn with_view_marker(mut self, marker: impl Into<String>) -> Self {
self.view_marker = Some(marker.into());
self
}
/// Converts this element into [`HtmlElement<AnyElement>`].
pub fn into_any(self) -> HtmlElement<AnyElement> {
cfg_if! {
@@ -359,9 +340,7 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
cx,
element,
#[cfg(debug_assertions)]
span,
#[cfg(debug_assertions)]
view_marker
span
} = self;
HtmlElement {
@@ -372,9 +351,7 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
is_void: element.is_void(),
},
#[cfg(debug_assertions)]
span,
#[cfg(debug_assertions)]
view_marker
span
}
} else {
let Self {
@@ -382,9 +359,7 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
attrs,
children,
element,
prerendered,
#[cfg(debug_assertions)]
view_marker
prerendered
} = self;
HtmlElement {
@@ -395,10 +370,8 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
element: AnyElement {
name: element.name(),
is_void: element.is_void(),
id: element.hydration_id().clone()
id: element.hydration_id().clone(),
},
#[cfg(debug_assertions)]
view_marker
}
}
}
@@ -603,17 +576,6 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
}
}
/// Adds a list of classes separated by ASCII whitespace to an element.
#[track_caller]
pub fn classes(self, classes: impl Into<Cow<'static, str>>) -> Self {
let classes = classes.into();
let mut this = self;
for class in classes.split_ascii_whitespace() {
this = this.class(class.to_string(), true);
}
this
}
/// Sets a property on an element.
#[track_caller]
pub fn prop(
@@ -769,8 +731,6 @@ impl<El: ElementDescriptor> IntoView for HtmlElement<El> {
mut attrs,
children,
prerendered,
#[cfg(debug_assertions)]
view_marker,
..
} = self;
@@ -789,11 +749,6 @@ impl<El: ElementDescriptor> IntoView for HtmlElement<El> {
element.children.extend(children);
element.prerendered = prerendered;
#[cfg(debug_assertions)]
{
element.view_marker = view_marker;
}
View::Element(element)
}
}

View File

@@ -30,24 +30,6 @@ cfg_if! {
map
});
#[cfg(debug_assertions)]
pub(crate) static VIEW_MARKERS: LazyCell<HashMap<String, web_sys::Comment>> = LazyCell::new(|| {
let document = crate::document();
let body = document.body().unwrap();
let walker = document
.create_tree_walker_with_what_to_show(&body, 128)
.unwrap();
let mut map = HashMap::new();
while let Ok(Some(node)) = walker.next_node() {
if let Some(content) = node.text_content() {
if let Some(id) = content.strip_prefix("leptos-view|") {
map.insert(id.into(), node.unchecked_into());
}
}
}
map
});
static IS_HYDRATING: RefCell<LazyCell<bool>> = RefCell::new(LazyCell::new(|| {
#[cfg(debug_assertions)]
return crate::document().get_element_by_id("_0-0-0").is_some()

View File

@@ -148,9 +148,6 @@ cfg_if! {
pub name: Cow<'static, str>,
#[doc(hidden)]
pub element: web_sys::HtmlElement,
#[cfg(debug_assertions)]
/// Optional marker for the view macro source of the element.
pub view_marker: Option<String>
}
impl fmt::Debug for Element {
@@ -170,9 +167,6 @@ cfg_if! {
children: Vec<View>,
prerendered: Option<Cow<'static, str>>,
id: HydrationKey,
#[cfg(debug_assertions)]
/// Optional marker for the view macro source, in debug mode.
pub view_marker: Option<String>
}
impl fmt::Debug for Element {
@@ -206,12 +200,7 @@ impl Element {
pub fn into_html_element(self, cx: Scope) -> HtmlElement<AnyElement> {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
{
let Self {
element,
#[cfg(debug_assertions)]
view_marker,
..
} = self;
let Self { element, .. } = self;
let name = element.node_name().to_ascii_lowercase();
@@ -226,8 +215,6 @@ impl Element {
element,
#[cfg(debug_assertions)]
span: ::tracing::Span::current(),
#[cfg(debug_assertions)]
view_marker,
}
}
@@ -240,8 +227,6 @@ impl Element {
children,
id,
prerendered,
#[cfg(debug_assertions)]
view_marker,
} = self;
let element = AnyElement { name, is_void, id };
@@ -252,8 +237,6 @@ impl Element {
attrs,
children: children.into_iter().collect(),
prerendered,
#[cfg(debug_assertions)]
view_marker,
}
}
}
@@ -275,8 +258,6 @@ impl Element {
#[cfg(debug_assertions)]
name: el.name(),
element: el.as_ref().clone(),
#[cfg(debug_assertions)]
view_marker: None
}
}
else {
@@ -286,9 +267,7 @@ impl Element {
attrs: Default::default(),
children: Default::default(),
id: el.hydration_id().clone(),
prerendered: None,
#[cfg(debug_assertions)]
view_marker: None
prerendered: None
}
}
}
@@ -451,12 +430,6 @@ impl<const N: usize> IntoView for [View; N] {
}
}
impl IntoView for &Fragment {
fn into_view(self, cx: Scope) -> View {
self.to_owned().into_view(cx)
}
}
#[cfg(all(target_arch = "wasm32", feature = "web"))]
impl Mountable for View {
fn get_mountable_node(&self) -> web_sys::Node {

View File

@@ -67,7 +67,7 @@ pub fn console_error(s: &str) {
if is_server() {
eprintln!("{s}");
} else {
web_sys::console::error_1(&JsValue::from_str(s));
web_sys::console::warn_1(&JsValue::from_str(s));
}
}

View File

@@ -267,12 +267,12 @@ pub fn attribute_helper(
create_render_effect(cx, move |old| {
let new = f();
if old.as_ref() != Some(&new) {
attribute_expression(&el, &name, new.clone(), true);
attribute_expression(&el, &name, new.clone());
}
new
});
}
_ => attribute_expression(el, &name, value, false),
_ => attribute_expression(el, &name, value),
};
}
@@ -281,44 +281,39 @@ pub(crate) fn attribute_expression(
el: &web_sys::Element,
attr_name: &str,
value: Attribute,
force: bool,
) {
use crate::HydrationCtx;
if force || !HydrationCtx::is_hydrating() {
match value {
Attribute::String(value) => {
let value = wasm_bindgen::intern(&value);
if attr_name == "inner_html" {
el.set_inner_html(value);
} else {
let attr_name = wasm_bindgen::intern(attr_name);
el.set_attribute(attr_name, value).unwrap_throw();
}
}
Attribute::Option(_, value) => {
if attr_name == "inner_html" {
el.set_inner_html(&value.unwrap_or_default());
} else {
let attr_name = wasm_bindgen::intern(attr_name);
match value {
Some(value) => {
let value = wasm_bindgen::intern(&value);
el.set_attribute(attr_name, value).unwrap_throw();
}
None => el.remove_attribute(attr_name).unwrap_throw(),
}
}
}
Attribute::Bool(value) => {
match value {
Attribute::String(value) => {
let value = wasm_bindgen::intern(&value);
if attr_name == "inner_html" {
el.set_inner_html(value);
} else {
let attr_name = wasm_bindgen::intern(attr_name);
if value {
el.set_attribute(attr_name, attr_name).unwrap_throw();
} else {
el.remove_attribute(attr_name).unwrap_throw();
el.set_attribute(attr_name, value).unwrap_throw();
}
}
Attribute::Option(_, value) => {
if attr_name == "inner_html" {
el.set_inner_html(&value.unwrap_or_default());
} else {
let attr_name = wasm_bindgen::intern(attr_name);
match value {
Some(value) => {
let value = wasm_bindgen::intern(&value);
el.set_attribute(attr_name, value).unwrap_throw();
}
None => el.remove_attribute(attr_name).unwrap_throw(),
}
}
_ => panic!("Remove nested Fn in Attribute"),
}
Attribute::Bool(value) => {
let attr_name = wasm_bindgen::intern(attr_name);
if value {
el.set_attribute(attr_name, attr_name).unwrap_throw();
} else {
el.remove_attribute(attr_name).unwrap_throw();
}
}
_ => panic!("Remove nested Fn in Attribute"),
}
}

View File

@@ -1,4 +1,6 @@
use leptos_reactive::Scope;
#[cfg(all(target_arch = "wasm32", feature = "web"))]
use wasm_bindgen::UnwrapThrowExt;
/// Represents the different possible values a single class on an element could have,
/// allowing you to do fine-grained updates to single items
@@ -83,14 +85,12 @@ pub fn class_helper(
create_render_effect(cx, move |old| {
let new = f();
if old.as_ref() != Some(&new) && (old.is_some() || new) {
class_expression(&class_list, &name, new, true)
class_expression(&class_list, &name, new)
}
new
});
}
Class::Value(value) => {
class_expression(&class_list, &name, value, false)
}
Class::Value(value) => class_expression(&class_list, &name, value),
};
}
@@ -99,21 +99,11 @@ pub(crate) fn class_expression(
class_list: &web_sys::DomTokenList,
class_name: &str,
value: bool,
force: bool,
) {
use crate::HydrationCtx;
if force || !HydrationCtx::is_hydrating() {
let class_name = wasm_bindgen::intern(class_name);
if value {
if let Err(e) = class_list.add_1(class_name) {
crate::error!("[HtmlElement::class()] {e:?}");
}
} else {
if let Err(e) = class_list.remove_1(class_name) {
crate::error!("[HtmlElement::class()] {e:?}");
}
}
let class_name = wasm_bindgen::intern(class_name);
if value {
class_list.add_1(class_name).unwrap_throw();
} else {
class_list.remove_1(class_name).unwrap_throw();
}
}

View File

@@ -19,8 +19,8 @@ type PinnedFuture<T> = Pin<Box<dyn Future<Output = T>>>;
/// let html = leptos::ssr::render_to_string(|cx| view! { cx,
/// <p>"Hello, world!"</p>
/// });
/// // trim off the beginning, which has a bunch of hydration info, for comparison
/// assert!(html.contains("Hello, world!</p>"));
/// // static HTML includes some hydration info
/// assert_eq!(html, "<p id=\"_0-1\">Hello, world!</p>");
/// # }}
/// ```
pub fn render_to_string<F, N>(f: F) -> String
@@ -132,7 +132,7 @@ pub fn render_to_stream_with_prefix_undisposed_with_context(
let (
(shell, prefix, pending_resources, pending_fragments, serializers),
scope,
disposer,
_,
) = run_scope_undisposed(runtime, {
move |cx| {
// Add additional context items
@@ -164,31 +164,31 @@ pub fn render_to_stream_with_prefix_undisposed_with_context(
// stream HTML for each <Suspense/> as it resolves
// TODO can remove id_before_suspense entirely now
let fragments = fragments.map(|(fragment_id, html)| {
format!(
r#"
<template id="{fragment_id}f">{html}</template>
<script>
var id = "{fragment_id}";
var open;
var close;
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT);
while(walker.nextNode()) {{
if(walker.currentNode.textContent == `suspense-open-${{id}}`) {{
open = walker.currentNode;
}} else if(walker.currentNode.textContent == `suspense-close-${{id}}`) {{
close = walker.currentNode;
}}
}}
var range = new Range();
range.setStartAfter(open);
range.setEndBefore(close);
range.deleteContents();
var tpl = document.getElementById("{fragment_id}f");
close.parentNode.insertBefore(tpl.content.cloneNode(true), close);
</script>
"#
)
});
format!(
r#"
<template id="{fragment_id}f">{html}</template>
<script>
var id = "{fragment_id}";
var open;
var close;
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT);
while(walker.nextNode()) {{
if(walker.currentNode.textContent == `suspense-open-${{id}}`) {{
open = walker.currentNode;
}} else if(walker.currentNode.textContent == `suspense-close-${{id}}`) {{
close = walker.currentNode;
}}
}}
var range = new Range();
range.setStartAfter(open);
range.setEndBefore(close);
range.deleteContents();
var tpl = document.getElementById("{fragment_id}f");
close.parentNode.insertBefore(tpl.content.cloneNode(true), close);
</script>
"#
)
});
// stream data for each Resource as it resolves
let resources = render_serializers(serializers);
@@ -196,25 +196,20 @@ pub fn render_to_stream_with_prefix_undisposed_with_context(
let stream = futures::stream::once(async move {
format!(
r#"
{prefix}
{shell}
<script>
__LEPTOS_PENDING_RESOURCES = {pending_resources};
__LEPTOS_RESOLVED_RESOURCES = new Map();
__LEPTOS_RESOURCE_RESOLVERS = new Map();
</script>
"#
{prefix}
{shell}
<script>
__LEPTOS_PENDING_RESOURCES = {pending_resources};
__LEPTOS_RESOLVED_RESOURCES = new Map();
__LEPTOS_RESOURCE_RESOLVERS = new Map();
</script>
"#
)
})
// TODO these should be combined again in a way that chains them appropriately
// such that individual resources can resolve before all fragments are done
.chain(fragments)
.chain(resources)
// dispose of the root scope
.chain(futures::stream::once(async move {
disposer.dispose();
Default::default()
}));
.chain(resources);
(stream, runtime, scope)
}
@@ -237,17 +232,12 @@ impl View {
};
cfg_if! {
if #[cfg(debug_assertions)] {
let content = format!(r#"<!--hk={}|leptos-{name}-start-->{}<!--hk={}|leptos-{name}-end-->"#,
format!(r#"<!--hk={}|leptos-{name}-start-->{}<!--hk={}|leptos-{name}-end-->"#,
HydrationCtx::to_string(&node.id, false),
content(),
HydrationCtx::to_string(&node.id, true),
name = to_kebab_case(&node.name)
);
if let Some(id) = node.view_marker {
format!("<!--leptos-view|{id}|open-->{content}<!--leptos-view|{id}|close-->").into()
} else {
content.into()
}
).into()
} else {
format!(
r#"{}<!--hk={}-->"#,
@@ -385,7 +375,7 @@ impl View {
}
}
View::Element(el) => {
let el_html = if let Some(prerendered) = el.prerendered {
if let Some(prerendered) = el.prerendered {
prerendered
} else {
let tag_name = el.name;
@@ -430,17 +420,6 @@ impl View {
format!("<{tag_name}{attrs}>{children}</{tag_name}>")
.into()
}
};
cfg_if! {
if #[cfg(debug_assertions)] {
if let Some(id) = el.view_marker {
format!("<!--leptos-view|{id}|open-->{el_html}<!--leptos-view|{id}|close-->").into()
} else {
el_html
}
} else {
el_html
}
}
}
View::Transparent(_) => Default::default(),

View File

@@ -76,7 +76,7 @@ pub fn render_to_stream_in_order_with_prefix_undisposed_with_context(
// create the runtime
let runtime = create_runtime();
let ((chunks, prefix, pending_resources, serializers), scope_id, disposer) =
let ((chunks, prefix, pending_resources, serializers), scope_id, _) =
run_scope_undisposed(runtime, |cx| {
// add additional context
additional_context(cx);
@@ -111,12 +111,7 @@ pub fn render_to_stream_in_order_with_prefix_undisposed_with_context(
)
})
.chain(rx)
.chain(render_serializers(serializers))
// dispose of the scope
.chain(futures::stream::once(async move {
disposer.dispose();
Default::default()
}));
.chain(render_serializers(serializers));
(stream, runtime, scope_id)
}
@@ -180,12 +175,6 @@ impl View {
}
}
View::Element(el) => {
#[cfg(debug_assertions)]
if let Some(id) = &el.view_marker {
chunks.push(StreamChunk::Sync(
format!("<!--leptos-view|{id}|open-->").into(),
));
}
if let Some(prerendered) = el.prerendered {
chunks.push(StreamChunk::Sync(prerendered))
} else {
@@ -240,12 +229,6 @@ impl View {
));
}
}
#[cfg(debug_assertions)]
if let Some(id) = &el.view_marker {
chunks.push(StreamChunk::Sync(
format!("<!--leptos-view|{id}|close-->").into(),
));
}
}
View::Transparent(_) => {}
View::CoreComponent(node) => {

View File

@@ -1,27 +0,0 @@
[package]
name = "leptos_hot_reload"
version = { workspace = true }
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Utility types used for dev mode and hot-reloading for the Leptos web framework."
readme = "../README.md"
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
syn = { version = "1", features = [
"full",
"parsing",
"extra-traits",
"visit",
"printing",
] }
quote = "1"
syn-rsx = "0.9"
proc-macro2 = { version = "1", features = ["span-locations", "nightly"] }
parking_lot = "0.12"
walkdir = "2"
camino = "1.1.3"
indexmap = "1.9.2"

View File

@@ -1,550 +0,0 @@
use crate::node::{LAttributeValue, LNode};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
// TODO: insertion and removal code are still somewhat broken
// namely, it will tend to remove and move or mutate nodes,
// which causes a bit of a problem for DynChild etc.
#[derive(Debug, Default)]
struct OldChildren(IndexMap<LNode, Vec<usize>>);
impl LNode {
pub fn diff(&self, other: &LNode) -> Vec<Patch> {
let mut old_children = OldChildren::default();
self.add_old_children(vec![], &mut old_children);
self.diff_at(other, &[], &old_children)
}
fn to_replacement_node(
&self,
old_children: &OldChildren,
) -> ReplacementNode {
match old_children.0.get(self) {
// if the child already exists in the DOM, we can pluck it out
// and move it around
Some(path) => ReplacementNode::Path(path.to_owned()),
// otherwise, we should generate some HTML
// but we need to do this recursively in case we're replacing an element
// with children who need to be plucked out
None => match self {
LNode::Fragment(fragment) => ReplacementNode::Fragment(
fragment
.iter()
.map(|node| node.to_replacement_node(old_children))
.collect(),
),
LNode::Element {
name,
attrs,
children,
} => ReplacementNode::Element {
name: name.to_owned(),
attrs: attrs
.iter()
.filter_map(|(name, value)| match value {
LAttributeValue::Boolean => {
Some((name.to_owned(), name.to_owned()))
}
LAttributeValue::Static(value) => {
Some((name.to_owned(), value.to_owned()))
}
_ => None,
})
.collect(),
children: children
.iter()
.map(|node| node.to_replacement_node(old_children))
.collect(),
},
LNode::Text(_)
| LNode::Component(_, _)
| LNode::DynChild(_) => ReplacementNode::Html(self.to_html()),
},
}
}
fn add_old_children(&self, path: Vec<usize>, positions: &mut OldChildren) {
match self {
LNode::Fragment(frag) => {
for (idx, child) in frag.iter().enumerate() {
let mut new_path = path.clone();
new_path.push(idx);
child.add_old_children(new_path, positions);
}
}
LNode::Element { children, .. } => {
for (idx, child) in children.iter().enumerate() {
let mut new_path = path.clone();
new_path.push(idx);
child.add_old_children(new_path, positions);
}
}
// only need to insert dynamic content, as these might change
LNode::Component(_, _) | LNode::DynChild(_) => {
positions.0.insert(self.clone(), path);
}
// can just create text nodes, whatever
LNode::Text(_) => {}
}
}
fn diff_at(
&self,
other: &LNode,
path: &[usize],
orig_children: &OldChildren,
) -> Vec<Patch> {
if std::mem::discriminant(self) != std::mem::discriminant(other) {
return vec![Patch {
path: path.to_owned(),
action: PatchAction::ReplaceWith(
other.to_replacement_node(orig_children),
),
}];
}
match (self, other) {
// fragment: diff children
(LNode::Fragment(old), LNode::Fragment(new)) => {
LNode::diff_children(path, old, new, orig_children)
}
// text node: replace text
(LNode::Text(_), LNode::Text(new)) => vec![Patch {
path: path.to_owned(),
action: PatchAction::SetText(new.to_owned()),
}],
// elements
(
LNode::Element {
name: old_name,
attrs: old_attrs,
children: old_children,
},
LNode::Element {
name: new_name,
attrs: new_attrs,
children: new_children,
},
) => {
let tag_patch = (old_name != new_name).then(|| Patch {
path: path.to_owned(),
action: PatchAction::ChangeTagName(new_name.to_owned()),
});
let attrs_patch = LNode::diff_attrs(path, old_attrs, new_attrs);
let children_patch = LNode::diff_children(
path,
old_children,
new_children,
orig_children,
);
attrs_patch
.into_iter()
// tag patch comes second so we remove old attrs before copying them over
.chain(tag_patch)
.chain(children_patch)
.collect()
}
// components + dynamic context: no patches
_ => vec![],
}
}
fn diff_attrs<'a>(
path: &'a [usize],
old: &'a [(String, LAttributeValue)],
new: &'a [(String, LAttributeValue)],
) -> impl Iterator<Item = Patch> + 'a {
let additions = new
.iter()
.filter_map(|(name, new_value)| {
let old_attr = old.iter().find(|(o_name, _)| o_name == name);
let replace = match old_attr {
None => true,
Some((_, old_value)) if old_value != new_value => true,
_ => false,
};
if replace {
match &new_value {
LAttributeValue::Boolean => {
Some((name.to_owned(), "".to_string()))
}
LAttributeValue::Static(s) => {
Some((name.to_owned(), s.to_owned()))
}
_ => None,
}
} else {
None
}
})
.map(|(name, value)| Patch {
path: path.to_owned(),
action: PatchAction::SetAttribute(name, value),
});
let removals = old.iter().filter_map(|(name, _)| {
if !new.iter().any(|(new_name, _)| new_name == name) {
Some(Patch {
path: path.to_owned(),
action: PatchAction::RemoveAttribute(name.to_owned()),
})
} else {
None
}
});
additions.chain(removals)
}
fn diff_children(
path: &[usize],
old: &[LNode],
new: &[LNode],
old_children: &OldChildren,
) -> Vec<Patch> {
if old.is_empty() && new.is_empty() {
vec![]
} else if old.is_empty() {
vec![Patch {
path: path.to_owned(),
action: PatchAction::AppendChildren(
new.iter()
.map(LNode::to_html)
.map(ReplacementNode::Html)
.collect(),
),
}]
} else if new.is_empty() {
vec![Patch {
path: path.to_owned(),
action: PatchAction::ClearChildren,
}]
} else {
let mut a = 0;
let mut b = std::cmp::max(old.len(), new.len()) - 1; // min is 0, have checked both have items
let mut patches = vec![];
// common prefix
while a < b {
let old = old.get(a);
let new = new.get(a);
match (old, new) {
(None, None) => {}
(None, Some(new)) => patches.push(Patch {
path: path.to_owned(),
action: PatchAction::InsertChild {
before: a,
child: new.to_replacement_node(old_children),
},
}),
(Some(_), None) => patches.push(Patch {
path: path.to_owned(),
action: PatchAction::RemoveChild { at: a },
}),
(Some(old), Some(new)) => {
if old != new {
break;
}
}
}
a += 1;
}
// common suffix
while b >= a {
let old = old.get(b);
let new = new.get(b);
match (old, new) {
(None, None) => {}
(None, Some(new)) => patches.push(Patch {
path: path.to_owned(),
action: PatchAction::InsertChildAfter {
after: b - 1,
child: new.to_replacement_node(old_children),
},
}),
(Some(_), None) => patches.push(Patch {
path: path.to_owned(),
action: PatchAction::RemoveChild { at: b },
}),
(Some(old), Some(new)) => {
if old != new {
break;
}
}
}
if b == 0 {
break;
} else {
b -= 1;
}
}
// diffing in middle
if b >= a {
let old_slice_end =
if b >= old.len() { old.len() - 1 } else { b };
let new_slice_end =
if b >= new.len() { new.len() - 1 } else { b };
let old = &old[a..=old_slice_end];
let new = &new[a..=new_slice_end];
for (new_idx, new_node) in new.iter().enumerate() {
match old.get(new_idx) {
Some(old_node) => {
let mut new_path = path.to_vec();
new_path.push(new_idx + a);
let diffs = old_node.diff_at(
new_node,
&new_path,
old_children,
);
patches.extend(&mut diffs.into_iter());
}
None => patches.push(Patch {
path: path.to_owned(),
action: PatchAction::InsertChild {
before: new_idx,
child: new_node
.to_replacement_node(old_children),
},
}),
}
}
}
patches
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Patches(pub Vec<(String, Vec<Patch>)>);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Patch {
path: Vec<usize>,
action: PatchAction,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PatchAction {
ReplaceWith(ReplacementNode),
ChangeTagName(String),
RemoveAttribute(String),
SetAttribute(String, String),
SetText(String),
ClearChildren,
AppendChildren(Vec<ReplacementNode>),
RemoveChild {
at: usize,
},
InsertChild {
before: usize,
child: ReplacementNode,
},
InsertChildAfter {
after: usize,
child: ReplacementNode,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReplacementNode {
Html(String),
Path(Vec<usize>),
Fragment(Vec<ReplacementNode>),
Element {
name: String,
attrs: Vec<(String, String)>,
children: Vec<ReplacementNode>,
},
}
#[cfg(test)]
mod tests {
use crate::{
diff::{Patch, PatchAction, ReplacementNode},
node::LAttributeValue,
LNode,
};
#[test]
fn patches_text() {
let a = LNode::Text("foo".into());
let b = LNode::Text("bar".into());
let delta = a.diff(&b);
assert_eq!(
delta,
vec![Patch {
path: vec![],
action: PatchAction::SetText("bar".into())
}]
);
}
#[test]
fn patches_attrs() {
let a = LNode::Element {
name: "button".into(),
attrs: vec![
("class".into(), LAttributeValue::Static("a".into())),
("type".into(), LAttributeValue::Static("button".into())),
],
children: vec![],
};
let b = LNode::Element {
name: "button".into(),
attrs: vec![
("class".into(), LAttributeValue::Static("a b".into())),
("id".into(), LAttributeValue::Static("button".into())),
],
children: vec![],
};
let delta = a.diff(&b);
assert_eq!(
delta,
vec![
Patch {
path: vec![],
action: PatchAction::SetAttribute(
"class".into(),
"a b".into()
)
},
Patch {
path: vec![],
action: PatchAction::SetAttribute(
"id".into(),
"button".into()
)
},
Patch {
path: vec![],
action: PatchAction::RemoveAttribute("type".into())
},
]
);
}
#[test]
fn patches_child_text() {
let a = LNode::Element {
name: "button".into(),
attrs: vec![],
children: vec![
LNode::Text("foo".into()),
LNode::Text("bar".into()),
],
};
let b = LNode::Element {
name: "button".into(),
attrs: vec![],
children: vec![
LNode::Text("foo".into()),
LNode::Text("baz".into()),
],
};
let delta = a.diff(&b);
assert_eq!(
delta,
vec![Patch {
path: vec![1],
action: PatchAction::SetText("baz".into())
},]
);
}
#[test]
fn inserts_child() {
let a = LNode::Element {
name: "div".into(),
attrs: vec![],
children: vec![LNode::Element {
name: "button".into(),
attrs: vec![],
children: vec![LNode::Text("bar".into())],
}],
};
let b = LNode::Element {
name: "div".into(),
attrs: vec![],
children: vec![
LNode::Element {
name: "button".into(),
attrs: vec![],
children: vec![LNode::Text("foo".into())],
},
LNode::Element {
name: "button".into(),
attrs: vec![],
children: vec![LNode::Text("bar".into())],
},
],
};
let delta = a.diff(&b);
assert_eq!(
delta,
vec![
Patch {
path: vec![],
action: PatchAction::InsertChildAfter {
after: 0,
child: ReplacementNode::Element {
name: "button".into(),
attrs: vec![],
children: vec![ReplacementNode::Html("bar".into())]
}
}
},
Patch {
path: vec![0, 0],
action: PatchAction::SetText("foo".into())
}
]
);
}
#[test]
fn removes_child() {
let a = LNode::Element {
name: "div".into(),
attrs: vec![],
children: vec![
LNode::Element {
name: "button".into(),
attrs: vec![],
children: vec![LNode::Text("foo".into())],
},
LNode::Element {
name: "button".into(),
attrs: vec![],
children: vec![LNode::Text("bar".into())],
},
],
};
let b = LNode::Element {
name: "div".into(),
attrs: vec![],
children: vec![LNode::Element {
name: "button".into(),
attrs: vec![],
children: vec![LNode::Text("foo".into())],
}],
};
let delta = a.diff(&b);
assert_eq!(
delta,
vec![Patch {
path: vec![],
action: PatchAction::RemoveChild { at: 1 }
},]
);
}
}

View File

@@ -1,162 +0,0 @@
extern crate proc_macro;
use anyhow::Result;
use camino::Utf8PathBuf;
use diff::Patches;
use node::LNode;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
fs::File,
io::Read,
path::{Path, PathBuf},
sync::Arc,
};
use syn::{
spanned::Spanned,
visit::{self, Visit},
Macro,
};
use walkdir::WalkDir;
pub mod diff;
pub mod node;
pub mod parsing;
pub const HOT_RELOAD_JS: &str = include_str!("patch.js");
#[derive(Debug, Clone, Default)]
pub struct ViewMacros {
// keyed by original location identifier
views: Arc<RwLock<HashMap<Utf8PathBuf, Vec<MacroInvocation>>>>,
}
impl ViewMacros {
pub fn new() -> Self {
Self::default()
}
pub fn update_from_paths<T: AsRef<Path>>(&self, paths: &[T]) -> Result<()> {
let mut views = HashMap::new();
for path in paths {
for entry in WalkDir::new(path).into_iter().flatten() {
if entry.file_type().is_file() {
let path: PathBuf = entry.path().into();
let path = Utf8PathBuf::try_from(path)?;
if path.extension() == Some("rs") || path.ends_with(".rs") {
let macros = Self::parse_file(&path)?;
let entry = views.entry(path.clone()).or_default();
*entry = macros;
}
}
}
}
*self.views.write() = views;
Ok(())
}
pub fn parse_file(path: &Utf8PathBuf) -> Result<Vec<MacroInvocation>> {
let mut file = File::open(path)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
let ast = syn::parse_file(&content)?;
let mut visitor = ViewMacroVisitor::default();
visitor.visit_file(&ast);
let mut views = Vec::new();
for view in visitor.views {
let span = view.span();
let id = span_to_stable_id(path, span);
let mut tokens = view.tokens.clone().into_iter();
tokens.next(); // cx
tokens.next(); // ,
// TODO handle class = ...
let rsx =
syn_rsx::parse2(tokens.collect::<proc_macro2::TokenStream>())?;
let template = LNode::parse_view(rsx)?;
views.push(MacroInvocation { id, template })
}
Ok(views)
}
pub fn patch(&self, path: &Utf8PathBuf) -> Result<Option<Patches>> {
let new_views = Self::parse_file(path)?;
let mut lock = self.views.write();
let diffs = match lock.get(path) {
None => return Ok(None),
Some(current_views) => {
if current_views.len() == new_views.len() {
let mut diffs = Vec::new();
for (current_view, new_view) in
current_views.iter().zip(&new_views)
{
if current_view.id == new_view.id
&& current_view.template != new_view.template
{
diffs.push((
current_view.id.clone(),
current_view.template.diff(&new_view.template),
));
}
}
diffs
} else {
return Ok(None);
}
}
};
// update the status to the new views
lock.insert(path.clone(), new_views);
Ok(Some(Patches(diffs)))
}
}
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MacroInvocation {
id: String,
template: LNode,
}
impl std::fmt::Debug for MacroInvocation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MacroInvocation")
.field("id", &self.id)
.finish()
}
}
#[derive(Default, Debug)]
pub struct ViewMacroVisitor<'a> {
views: Vec<&'a Macro>,
}
impl<'ast> Visit<'ast> for ViewMacroVisitor<'ast> {
fn visit_macro(&mut self, node: &'ast Macro) {
let ident = node.path.get_ident().map(|n| n.to_string());
if ident == Some("view".to_string()) {
self.views.push(node);
}
// Delegate to the default impl to visit any nested functions.
visit::visit_macro(self, node);
}
}
pub fn span_to_stable_id(
path: impl AsRef<Path>,
site: proc_macro2::Span,
) -> String {
let file = path
.as_ref()
.to_str()
.unwrap_or_default()
.replace(['/', '\\'], "-");
let start = site.start();
format!("{}-{:?}", file, start.line)
}

View File

@@ -1,168 +0,0 @@
use crate::parsing::{is_component_node, value_to_string};
use anyhow::Result;
use quote::quote;
use serde::{Deserialize, Serialize};
use syn_rsx::Node;
// A lightweight virtual DOM structure we can use to hold
// the state of a Leptos view macro template. This is because
// `syn` types are `!Send` so we can't store them as we might like.
// This is only used to diff view macros for hot reloading so it's very minimal
// and ignores many of the data types.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LNode {
Fragment(Vec<LNode>),
Text(String),
Element {
name: String,
attrs: Vec<(String, LAttributeValue)>,
children: Vec<LNode>,
},
// don't need anything; skipped during patching because it should
// contain its own view macros
Component(String, Vec<(String, String)>),
DynChild(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LAttributeValue {
Boolean,
Static(String),
// safely ignored
Dynamic,
Noop,
}
impl LNode {
pub fn parse_view(nodes: Vec<Node>) -> Result<LNode> {
let mut out = Vec::new();
for node in nodes {
LNode::parse_node(node, &mut out)?;
}
if out.len() == 1 {
Ok(out.pop().unwrap())
} else {
Ok(LNode::Fragment(out))
}
}
pub fn parse_node(node: Node, views: &mut Vec<LNode>) -> Result<()> {
match node {
Node::Fragment(frag) => {
for child in frag.children {
LNode::parse_node(child, views)?;
}
}
Node::Text(text) => {
if let Some(value) = value_to_string(&text.value) {
views.push(LNode::Text(value));
} else {
let value = text.value.as_ref();
let code = quote! { #value };
let code = code.to_string();
views.push(LNode::DynChild(code));
}
}
Node::Block(block) => {
let value = block.value.as_ref();
let code = quote! { #value };
let code = code.to_string();
views.push(LNode::DynChild(code));
}
Node::Element(el) => {
if is_component_node(&el) {
views.push(LNode::Component(
el.name.to_string(),
el.attributes
.into_iter()
.filter_map(|attr| match attr {
Node::Attribute(attr) => Some((
attr.key.to_string(),
format!("{:#?}", attr.value),
)),
_ => None,
})
.collect(),
));
} else {
let name = el.name.to_string();
let mut attrs = Vec::new();
for attr in el.attributes {
if let Node::Attribute(attr) = attr {
let name = attr.key.to_string();
if let Some(value) =
attr.value.as_ref().and_then(value_to_string)
{
attrs.push((
name,
LAttributeValue::Static(value),
));
} else {
attrs.push((name, LAttributeValue::Dynamic));
}
}
}
let mut children = Vec::new();
for child in el.children {
LNode::parse_node(child, &mut children)?;
}
views.push(LNode::Element {
name,
attrs,
children,
});
}
}
_ => {}
}
Ok(())
}
pub fn to_html(&self) -> String {
match self {
LNode::Fragment(frag) => frag.iter().map(LNode::to_html).collect(),
LNode::Text(text) => text.to_owned(),
LNode::Component(name, _) => format!(
"<!--<{name}>--><pre>&lt;{name}/&gt; will load once Rust code \
has been compiled.</pre><!--</{name}>-->"
),
LNode::DynChild(_) => "<!--<DynChild>--><pre>Dynamic content will \
load once Rust code has been \
compiled.</pre><!--</DynChild>-->"
.to_string(),
LNode::Element {
name,
attrs,
children,
} => {
// this is naughty, but the browsers are tough and can handle it
// I wouldn't do this for real code, but this is just for dev mode
let is_self_closing = children.is_empty();
let attrs = attrs
.iter()
.filter_map(|(name, value)| match value {
LAttributeValue::Boolean => Some(format!("{name} ")),
LAttributeValue::Static(value) => {
Some(format!("{name}=\"{value}\" "))
}
LAttributeValue::Dynamic => None,
LAttributeValue::Noop => None,
})
.collect::<String>();
let children =
children.iter().map(LNode::to_html).collect::<String>();
if is_self_closing {
format!("<{name} {attrs}/>")
} else {
format!("<{name} {attrs}>{children}</{name}>")
}
}
}
}
}

View File

@@ -1,20 +0,0 @@
use syn_rsx::{NodeElement, NodeValueExpr};
pub fn value_to_string(value: &NodeValueExpr) -> Option<String> {
match &value.as_ref() {
syn::Expr::Lit(lit) => match &lit.lit {
syn::Lit::Str(s) => Some(s.value()),
syn::Lit::Char(c) => Some(c.value().to_string()),
syn::Lit::Int(i) => Some(i.base10_digits().to_string()),
syn::Lit::Float(f) => Some(f.base10_digits().to_string()),
_ => None,
},
_ => None,
}
}
pub fn is_component_node(node: &NodeElement) -> bool {
node.name
.to_string()
.starts_with(|c: char| c.is_ascii_uppercase())
}

View File

@@ -1,359 +0,0 @@
console.log("[HOT RELOADING] Connected to server.");
function patch(json) {
try {
const views = JSON.parse(json);
for (const [id, patches] of views) {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT),
open = `leptos-view|${id}|open`,
close = `leptos-view|${id}|close`;
let start, end;
const instances = [];
while (walker.nextNode()) {
if (walker.currentNode.textContent == open) {
start = walker.currentNode;
} else if (walker.currentNode.textContent == close) {
end = walker.currentNode;
instances.push([start, end]);
start = undefined;
end = undefined;
}
}
for(const [start, end] of instances) {
// build tree of current actual children
const actualChildren = childrenFromRange(start.parentElement, start, end);
const actions = [];
// build up the set of actions
for (const patch of patches) {
const child = childAtPath(
actualChildren.length > 1 ? { children: actualChildren } : actualChildren[0],
patch.path
);
const action = patch.action;
if (action == "ClearChildren") {
actions.push(() => {
console.log("[HOT RELOAD] > ClearChildren", child.node);
child.node.textContent = ""
});
} else if (action.ReplaceWith) {
actions.push(() => {
console.log("[HOT RELOAD] > ReplaceWith", child, action.ReplaceWith);
const replacement = fromReplacementNode(action.ReplaceWith, actualChildren);
if (child.node) {
child.node.replaceWith(replacement)
} else {
const range = new Range();
range.setStartAfter(child.start);
range.setEndAfter(child.end);
range.deleteContents();
child.start.replaceWith(replacement);
}
});
} else if (action.ChangeTagName) {
const oldNode = child.node;
actions.push(() => {
console.log("[HOT RELOAD] > ChangeTagName", child.node, action.ChangeTagName);
const newElement = document.createElement(action.ChangeTagName);
for (const attr of oldNode.attributes) {
newElement.setAttribute(attr.name, attr.value);
}
for (const childNode of child.node.childNodes) {
newElement.appendChild(childNode);
}
child.node.replaceWith(newElement)
});
} else if (action.RemoveAttribute) {
actions.push(() => {
console.log("[HOT RELOAD] > RemoveAttribute", child.node, action.RemoveAttribute);
child.node.removeAttribute(action.RemoveAttribute);
});
} else if (action.SetAttribute) {
const [name, value] = action.SetAttribute;
actions.push(() => {
console.log("[HOT RELOAD] > SetAttribute", child.node, action.SetAttribute);
child.node.setAttribute(name, value);
});
} else if (action.SetText) {
const node = child.node;
actions.push(() => {
console.log("[HOT RELOAD] > SetText", child.node, action.SetText);
node.textContent = action.SetText
});
} else if (action.AppendChildren) {
actions.push(() => {
console.log("[HOT RELOAD] > AppendChildren", child.node, action.AppendChildren);
const newChildren = fromReplacementNode(action.AppendChildren, actualChildren);
child.node.append(newChildren);
});
} else if (action.RemoveChild) {
actions.push(() => {
console.log("[HOT RELOAD] > RemoveChild", child.node, child.children, action.RemoveChild);
const toRemove = child.children[action.RemoveChild.at];
let toRemoveNode = toRemove.node;
if (!toRemoveNode) {
const range = new Range();
range.setStartBefore(toRemove.start);
range.setEndAfter(toRemove.end);
toRemoveNode = range.deleteContents();
} else {
toRemoveNode.parentNode.removeChild(toRemoveNode);
}
})
} else if (action.InsertChild) {
const newChild = fromReplacementNode(action.InsertChild.child, actualChildren);
let children = [];
if(child.children) {
children = child.children;
} else if (child.start && child.end) {
children = childrenFromRange(child.node || child.start.parentElement, start, end);
} else {
console.warn("InsertChildAfter could not build children.");
}
const before = children[action.InsertChild.before];
actions.push(() => {
console.log("[HOT RELOAD] > InsertChild", child, child.node, action.InsertChild, " before ", before);
if (!before && child.node) {
child.node.appendChild(newChild);
} else {
let node = child.node || child.end.parentElement;
const reference = before ? before.node || before.start : child.end;
node.insertBefore(newChild, reference);
}
})
} else if (action.InsertChildAfter) {
const newChild = fromReplacementNode(action.InsertChildAfter.child, actualChildren);
let children = [];
if(child.children) {
children = child.children;
} else if (child.start && child.end) {
children = childrenFromRange(child.node || child.start.parentElement, start, end);
} else {
console.warn("InsertChildAfter could not build children.");
}
const after = children[action.InsertChildAfter.after];
actions.push(() => {
console.log("[HOT RELOAD] > InsertChildAfter", child, child.node, action.InsertChildAfter, " after ", after);
if (child.node && (!after || !(after.node || after.start).nextSibling)) {
child.node.appendChild(newChild);
} else {
const node = child.node || child.end;
const parent = node.nodeType === Node.COMMENT_NODE ? node.parentNode : node;
if(!after) {
parent.appendChild(newChild);
} else {
parent.insertBefore(newChild, (after.node || after.start).nextSibling);
}
}
})
} else {
console.warn("[HOT RELOADING] Unmatched action", action);
}
}
// actually run the actions
// the reason we delay them is so that children aren't moved before other children are found, etc.
for (const action of actions) {
action();
}
}
}
} catch (e) {
console.warn("[HOT RELOADING] Error: ", e);
}
function fromReplacementNode(node, actualChildren) {
if (node.Html) {
return fromHTML(node.Html);
}
else if (node.Fragment) {
const frag = document.createDocumentFragment();
for (const child of node.Fragment) {
frag.appendChild(fromReplacementNode(child, actualChildren));
}
return frag;
}
else if (node.Element) {
const element = document.createElement(node.Element.name);
for (const [name, value] of node.Element.attrs) {
element.setAttribute(name, value);
}
for (const child of node.Element.children) {
element.appendChild(fromReplacementNode(child, actualChildren));
}
return element;
}
else {
const child = childAtPath(
actualChildren.length > 1 ? { children: actualChildren } : actualChildren[0],
node.Path
);
if (child) {
let childNode = child.node;
if (!childNode) {
const range = new Range();
range.setStartBefore(child.start);
range.setEndAfter(child.end);
// okay this is somewhat silly
// if we do cloneContents() here to return it,
// we strip away the event listeners
// if we're moving just one object, this is less than ideal
// so I'm actually going to *extract* them, then clone and reinsert
/* const toReinsert = range.cloneContents();
if (child.end.nextSibling) {
child.end.parentNode.insertBefore(toReinsert, child.end.nextSibling);
} else {
child.end.parentNode.appendChild(toReinsert);
} */
childNode = range.cloneContents();
}
return childNode;
} else {
console.warn("[HOT RELOADING] Could not find replacement node at ", node.Path);
return undefined;
}
}
}
function buildActualChildren(element, range) {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT,
{
acceptNode(node) {
return node.parentNode == element && (!range || range.isPointInRange(node, 0));
}
}
);
const actualChildren = [],
elementCount = {};
while (walker.nextNode()) {
if (walker.currentNode.nodeType == Node.ELEMENT_NODE) {
if (elementCount[walker.currentNode.nodeName]) {
elementCount[walker.currentNode.nodeName] += 1;
} else {
elementCount[walker.currentNode.nodeName] = 0;
}
elementCount[walker.currentNode.nodeName];
actualChildren.push({
type: "element",
name: walker.currentNode.nodeName,
number: elementCount[walker.currentNode.nodeName],
node: walker.currentNode,
children: buildActualChildren(walker.currentNode)
});
} else if (walker.currentNode.nodeType == Node.TEXT_NODE) {
actualChildren.push({
type: "text",
node: walker.currentNode
});
} else if (walker.currentNode.nodeType == Node.COMMENT_NODE) {
if (walker.currentNode.textContent.trim().startsWith("leptos-view")) {
} else if (walker.currentNode.textContent.trim() == "<() />") {
actualChildren.push({
type: "unit",
node: walker.currentNode
});
} else if (walker.currentNode.textContent.trim() == "<DynChild>") {
let start = walker.currentNode;
let depth = 1;
while (walker.nextNode()) {
if (walker.currentNode.textContent.trim() == "</DynChild>") {
depth--;
} else if (walker.currentNode.textContent.trim() == "<DynChild>") {
depth++;
}
if(depth == 0) {
break;
}
}
let end = walker.currentNode;
actualChildren.push({
type: "dyn-child",
start, end
});
} else if (walker.currentNode.textContent.trim() == "<>") {
let start = walker.currentNode;
let depth = 1;
while (walker.nextNode()) {
if (walker.currentNode.textContent.trim() == "</>") {
depth--;
} else if (walker.currentNode.textContent.trim() == "<>") {
depth++;
}
if(depth == 0) {
break;
}
}
let end = walker.currentNode;
actualChildren.push({
type: "fragment",
children: childrenFromRange(start.parentElement, start, end),
start, end
});
} else if (walker.currentNode.textContent.trim().startsWith("<")) {
let componentName = walker.currentNode.textContent.trim();
let endMarker = componentName.replace("<", "</");
let depth = 1;
let start = walker.currentNode;
while (walker.nextNode()) {
if (walker.currentNode.textContent.trim() == endMarker) {
depth--;
} else if (walker.currentNode.textContent.trim() == componentName) {
depth++;
}
if(depth == 0) {
break;
}
}
let end = walker.currentNode;
actualChildren.push({
type: "component",
start, end
});
}
} else {
console.warn("[HOT RELOADING] Building children, encountered", walker.currentNode);
}
}
return actualChildren;
}
function childAtPath(element, path) {
if (path.length == 0) {
return element;
} else if (element.children) {
const next = element.children[path[0]],
rest = path.slice(1);
return childAtPath(next, rest);
} else if (path == [0]) {
return element;
} else if (element.start && element.end) {
const actualChildren = childrenFromRange(element.node || element.start.parentElement, element.start, element.end);
return childAtPath({ children: actualChildren }, path);
} else {
console.warn("[HOT RELOADING] Child at ", path, "not found in ", element);
return element;
}
}
function childrenFromRange(parent, start, end) {
const range = new Range();
range.setStartAfter(start);
range.setEndBefore(end);
return buildActualChildren(parent, range);
}
function fromHTML(html) {
const template = document.createElement("template");
template.innerHTML = html;
return template.content.cloneNode(true);
}
}

View File

@@ -12,7 +12,6 @@ readme = "../README.md"
proc-macro = true
[dependencies]
attribute-derive = { version = "0.5", features = ["syn-full"] }
cfg-if = "1"
html-escape = "0.2"
itertools = "0.10"
@@ -23,16 +22,14 @@ quote = "1"
syn = { version = "1", features = ["full"] }
syn-rsx = "0.9"
leptos_dom = { workspace = true }
leptos_hot_reload = { workspace = true }
leptos_reactive = { workspace = true }
server_fn_macro = { workspace = true }
leptos_server = { workspace = true }
convert_case = "0.6.0"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
log = "0.4"
typed-builder = "0.14"
trybuild = "1"
typed-builder = "0.12"
leptos = { path = "../leptos" }
[features]

View File

@@ -1,15 +1,17 @@
use attribute_derive::Attribute as AttributeDerive;
use convert_case::{
Case::{Pascal, Snake},
Casing,
};
use itertools::Itertools;
use proc_macro2::{Ident, TokenStream};
use proc_macro_error::ResultExt;
use quote::{format_ident, ToTokens, TokenStreamExt};
use std::collections::HashSet;
use syn::{
parse::Parse, parse_quote, AngleBracketedGenericArguments, Attribute,
FnArg, GenericArgument, ItemFn, LitStr, Meta, MetaNameValue, Pat, PatIdent,
Path, PathArguments, ReturnType, Type, TypePath, Visibility,
FnArg, GenericArgument, ItemFn, LitStr, Meta, MetaList, MetaNameValue,
NestedMeta, Pat, PatIdent, Path, PathArguments, ReturnType, Type, TypePath,
Visibility,
};
pub struct Model {
@@ -236,7 +238,7 @@ impl Model {
struct Prop {
docs: Docs,
prop_opts: PropOpt,
prop_opts: HashSet<PropOpt>,
name: PatIdent,
ty: Type,
}
@@ -249,12 +251,53 @@ impl Prop {
abort!(arg, "receiver not allowed in `fn`");
};
let prop_opts =
PropOpt::from_attributes(&typed.attrs).unwrap_or_else(|e| {
// TODO: replace with `.unwrap_or_abort()` once https://gitlab.com/CreepySkeleton/proc-macro-error/-/issues/17 is fixed
abort!(e.span(), e.to_string());
let prop_opts = typed
.attrs
.iter()
.enumerate()
.filter_map(|(i, attr)| {
PropOpt::from_attribute(attr).map(|opt| (i, opt))
})
.fold(HashSet::new(), |mut acc, cur| {
// Make sure opts aren't repeated
if acc.intersection(&cur.1).next().is_some() {
abort!(
typed.attrs[cur.0],
"`#[prop]` options are repeated"
);
}
acc.extend(cur.1);
acc
});
// Make sure conflicting options are not present
if prop_opts.contains(&PropOpt::Optional)
&& prop_opts.contains(&PropOpt::OptionalNoStrip)
{
abort!(
typed,
"`optional` and `optional_no_strip` options are mutually \
exclusive"
);
} else if prop_opts.contains(&PropOpt::Optional)
&& prop_opts.contains(&PropOpt::StripOption)
{
abort!(
typed,
"`optional` and `strip_option` options are mutually exclusive"
);
} else if prop_opts.contains(&PropOpt::OptionalNoStrip)
&& prop_opts.contains(&PropOpt::StripOption)
{
abort!(
typed,
"`optional_no_strip` and `strip_option` options are mutually \
exclusive"
);
}
let name = if let Pat::Ident(i) = *typed.pat {
i
} else {
@@ -364,34 +407,98 @@ impl Docs {
}
}
#[derive(Clone, Debug, AttributeDerive)]
#[attribute(ident = prop)]
struct PropOpt {
#[attribute(conflicts = [optional_no_strip, strip_option])]
optional: bool,
#[attribute(conflicts = [optional, strip_option])]
optional_no_strip: bool,
#[attribute(conflicts = [optional, optional_no_strip])]
strip_option: bool,
#[attribute(example = "5 * 10")]
default: Option<syn::Expr>,
into: bool,
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
enum PropOpt {
Optional,
OptionalNoStrip,
OptionalWithDefault(syn::Lit),
StripOption,
Into,
}
impl PropOpt {
fn from_attribute(attr: &Attribute) -> Option<HashSet<Self>> {
const ABORT_OPT_MESSAGE: &str =
"only `optional`, `optional_no_strip`, `strip_option`, `default` \
and `into` are allowed as arguments to `#[prop()]`";
if attr.path != parse_quote!(prop) {
return None;
}
if let Meta::List(MetaList { nested, .. }) =
attr.parse_meta().unwrap_or_abort()
{
Some(
nested
.iter()
.map(|opt| match opt {
NestedMeta::Meta(Meta::Path(opt)) => {
if *opt == parse_quote!(optional) {
PropOpt::Optional
} else if *opt == parse_quote!(optional_no_strip) {
PropOpt::OptionalNoStrip
} else if *opt == parse_quote!(strip_option) {
PropOpt::StripOption
} else if *opt == parse_quote!(into) {
PropOpt::Into
} else {
abort!(
opt,
"invalid prop option";
help = ABORT_OPT_MESSAGE
);
}
}
NestedMeta::Meta(Meta::NameValue(MetaNameValue {
path,
eq_token: _,
lit,
})) => {
if *path == parse_quote!(default) {
PropOpt::OptionalWithDefault(lit.to_owned())
} else {
abort!(
opt,
"invalid prop option";
help = ABORT_OPT_MESSAGE
);
}
}
_ => abort!(opt, ABORT_OPT_MESSAGE,),
})
.collect(),
)
} else {
abort!(
attr,
"the syntax for `#[prop]` is incorrect";
help = "try `#[prop(optional)]`";
help = ABORT_OPT_MESSAGE
);
}
}
}
struct TypedBuilderOpts {
default: bool,
default_with_value: Option<syn::Expr>,
default_with_value: Option<syn::Lit>,
strip_option: bool,
into: bool,
}
impl TypedBuilderOpts {
fn from_opts(opts: &PropOpt, is_ty_option: bool) -> Self {
fn from_opts(opts: &HashSet<PropOpt>, is_ty_option: bool) -> Self {
Self {
default: opts.optional || opts.optional_no_strip,
default_with_value: opts.default.clone(),
strip_option: opts.strip_option || opts.optional && is_ty_option,
into: opts.into,
default: opts.contains(&PropOpt::Optional)
|| opts.contains(&PropOpt::OptionalNoStrip),
default_with_value: opts.iter().find_map(|p| match p {
PropOpt::OptionalWithDefault(v) => Some(v.to_owned()),
_ => None,
}),
strip_option: opts.contains(&PropOpt::StripOption)
|| (opts.contains(&PropOpt::Optional) && is_ty_option),
into: opts.contains(&PropOpt::Into),
}
}
}
@@ -399,8 +506,7 @@ impl TypedBuilderOpts {
impl ToTokens for TypedBuilderOpts {
fn to_tokens(&self, tokens: &mut TokenStream) {
let default = if let Some(v) = &self.default_with_value {
let v = v.to_token_stream().to_string();
quote! { default_code=#v, }
quote! { default=#v, }
} else if self.default {
quote! { default, }
} else {
@@ -470,7 +576,8 @@ fn generate_component_fn_prop_docs(props: &[Prop]) -> TokenStream {
let required_prop_docs = props
.iter()
.filter(|Prop { prop_opts, .. }| {
!(prop_opts.optional || prop_opts.optional_no_strip)
!(prop_opts.contains(&PropOpt::Optional)
|| prop_opts.contains(&PropOpt::OptionalNoStrip))
})
.map(|p| prop_to_doc(p, PropDocStyle::List))
.collect::<TokenStream>();
@@ -478,7 +585,8 @@ fn generate_component_fn_prop_docs(props: &[Prop]) -> TokenStream {
let optional_prop_docs = props
.iter()
.filter(|Prop { prop_opts, .. }| {
prop_opts.optional || prop_opts.optional_no_strip
prop_opts.contains(&PropOpt::Optional)
|| prop_opts.contains(&PropOpt::OptionalNoStrip)
})
.map(|p| prop_to_doc(p, PropDocStyle::List))
.collect::<TokenStream>();
@@ -539,10 +647,10 @@ fn unwrap_option(ty: &Type) -> Type {
AngleBracketedGenericArguments { args, .. },
) = &first.arguments
{
if let [GenericArgument::Type(ty)] =
&args.iter().collect::<Vec<_>>()[..]
{
return ty.clone();
if let [first] = &args.iter().collect::<Vec<_>>()[..] {
if let GenericArgument::Type(ty) = first {
return ty.clone();
}
}
}
}
@@ -571,7 +679,9 @@ fn prop_to_doc(
}: &Prop,
style: PropDocStyle,
) -> TokenStream {
let ty = if (prop_opts.optional || prop_opts.strip_option) && is_option(ty)
let ty = if (prop_opts.contains(&PropOpt::Optional)
|| prop_opts.contains(&PropOpt::StripOption))
&& is_option(ty)
{
unwrap_option(ty)
} else {
@@ -595,7 +705,7 @@ fn prop_to_doc(
match style {
PropDocStyle::List => {
let arg_ty_doc = LitStr::new(
&if !prop_opts.into {
&if !prop_opts.contains(&PropOpt::Into) {
format!("- **{}**: [`{}`]", quote!(#name), pretty_ty)
} else {
format!(
@@ -616,7 +726,7 @@ fn prop_to_doc(
}
PropDocStyle::Inline => {
let arg_ty_doc = LitStr::new(
&if !prop_opts.into {
&if !prop_opts.contains(&PropOpt::Into) {
format!(
"**{}**: [`{}`]{}",
quote!(#name),

View File

@@ -7,9 +7,9 @@ extern crate proc_macro_error;
use proc_macro::TokenStream;
use proc_macro2::TokenTree;
use quote::ToTokens;
use server_fn_macro::{server_macro_impl, ServerContext};
use server::server_macro_impl;
use syn::parse_macro_input;
use syn_rsx::{parse, NodeAttribute};
use syn_rsx::{parse, NodeAttribute, NodeElement};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum Mode {
@@ -35,6 +35,7 @@ mod view;
use template::render_template;
use view::render_view;
mod component;
mod server;
mod template;
/// The `view` macro uses RSX (like JSX, but Rust!) It follows most of the
@@ -284,7 +285,6 @@ pub fn view(tokens: TokenStream) -> TokenStream {
let tokens: proc_macro2::TokenStream = tokens.into();
let mut tokens = tokens.into_iter();
let (cx, comma) = (tokens.next(), tokens.next());
match (cx, comma) {
(Some(TokenTree::Ident(cx)), Some(TokenTree::Punct(punct)))
if punct.as_char() == ',' =>
@@ -327,9 +327,8 @@ pub fn view(tokens: TokenStream) -> TokenStream {
Ok(nodes) => render_view(
&proc_macro2::Ident::new(&cx.to_string(), cx.span()),
&nodes,
Mode::default(),
Mode::Client, //Mode::default(),
global_class.as_ref(),
normalized_call_site(proc_macro::Span::call_site()),
),
Err(error) => error.to_compile_error(),
}
@@ -344,20 +343,6 @@ pub fn view(tokens: TokenStream) -> TokenStream {
}
}
fn normalized_call_site(site: proc_macro::Span) -> Option<String> {
cfg_if::cfg_if! {
if #[cfg(all(debug_assertions, not(feature = "stable")))] {
Some(leptos_hot_reload::span_to_stable_id(
site.source_file().path(),
site.into()
))
} else {
_ = site;
None
}
}
}
/// An optimized, cached template for client-side rendering. Follows the same
/// syntax as the [view!] macro. In hydration or server-side rendering mode,
/// behaves exactly as the `view` macro. In client-side rendering mode, uses a `<template>`
@@ -693,16 +678,7 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// or response or other server-only dependencies, but it does *not* have access to reactive state that exists in the client.
#[proc_macro_attribute]
pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
let context = ServerContext {
ty: syn::parse_quote!(Scope),
path: syn::parse_quote!(::leptos::Scope),
};
match server_macro_impl(
args.into(),
s.into(),
Some(context),
Some(syn::parse_quote!(::leptos::server_fn)),
) {
match server_macro_impl(args, s.into()) {
Err(e) => e.to_compile_error().into(),
Ok(s) => s.to_token_stream().into(),
}
@@ -720,6 +696,12 @@ pub fn params_derive(
}
}
pub(crate) fn is_component_node(node: &NodeElement) -> bool {
node.name
.to_string()
.starts_with(|c: char| c.is_ascii_uppercase())
}
pub(crate) fn attribute_value(attr: &NodeAttribute) -> &syn::Expr {
match &attr.value {
Some(value) => value.as_ref(),

View File

@@ -19,7 +19,7 @@ pub fn impl_params(ast: &syn::DeriveInput) -> proc_macro::TokenStream {
let span = field.span();
quote_spanned! {
span => #ident: <#ty>::into_param(map.get(#field_name_string).map(|n| n.as_str()), #field_name_string)?
span.into() => #ident: <#ty>::into_param(map.get(#field_name_string).map(|n| n.as_str()), #field_name_string)?
}
})
.collect()

View File

@@ -1,12 +1,6 @@
#![cfg_attr(not(feature = "stable"), feature(proc_macro_span))]
#![forbid(unsafe_code)]
#![deny(missing_docs)]
//! Implementation of the server_fn macro.
//!
//! This crate contains the implementation of the server_fn macro. [server_macro_impl] can be used to implement custom versions of the macro for different frameworks that allow users to pass a custom context from the server to the server function.
use cfg_if::cfg_if;
use leptos_server::Encoding;
use proc_macro2::{Literal, TokenStream as TokenStream2};
use proc_macro_error::abort;
use quote::quote;
use syn::{
parse::{Parse, ParseStream},
@@ -14,22 +8,13 @@ use syn::{
*,
};
/// Discribes the custom context from the server that passed to the server function. Optionally, the first argument of a server function
/// can be a custom context of this type. This context can be used to access the server's state within the server function.
pub struct ServerContext {
/// The type of the context.
pub ty: Ident,
/// The path to the context type. Used to reference the context type in the generated code.
pub path: Path,
}
fn fn_arg_is_cx(f: &syn::FnArg, server_context: &ServerContext) -> bool {
fn fn_arg_is_cx(f: &syn::FnArg) -> bool {
if let FnArg::Typed(t) = f {
if let Type::Path(path) = &*t.ty {
path.path
.segments
.iter()
.any(|segment| segment.ident == server_context.ty)
.any(|segment| segment.ident == "Scope")
} else {
false
}
@@ -38,81 +23,57 @@ fn fn_arg_is_cx(f: &syn::FnArg, server_context: &ServerContext) -> bool {
}
}
/// The implementation of the server_fn macro.
/// To allow the macro to accept a custom context from the server, pass a custom server context to this function.
/// **The Context comes from the server.** Optionally, the first argument of a server function
/// can be a custom context. This context can be used to inject dependencies like the HTTP request
/// or response or other server-only dependencies, but it does *not* have access to state that exists in the client.
///
/// The paths passed into this function are used in the generated code, so they must be in scope when the macro is called.
///
/// ```ignore
/// #[proc_macro_attribute]
/// pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// let server_context = Some(ServerContext {
/// ty: syn::parse_quote!(MyContext),
/// path: syn::parse_quote!(my_crate::prelude::MyContext),
/// });
/// match server_macro_impl(
/// args.into(),
/// s.into(),
/// Some(server_context),
/// Some(syn::parse_quote!(my_crate::exports::server_fn)),
/// ) {
/// Err(e) => e.to_compile_error().into(),
/// Ok(s) => s.to_token_stream().into(),
/// }
/// }
/// ```
pub fn server_macro_impl(
args: TokenStream2,
body: TokenStream2,
server_context: Option<ServerContext>,
server_fn_path: Option<Path>,
args: proc_macro::TokenStream,
s: TokenStream2,
) -> Result<TokenStream2> {
let ServerFnName {
struct_name,
prefix,
encoding,
..
} = syn::parse2::<ServerFnName>(args)?;
} = syn::parse::<ServerFnName>(args)?;
let prefix = prefix.unwrap_or_else(|| Literal::string(""));
let encoding = quote!(#server_fn_path::#encoding);
let encoding = match encoding {
Encoding::Cbor => quote! { ::leptos::leptos_server::Encoding::Cbor },
Encoding::Url => quote! { ::leptos::leptos_server::Encoding::Url },
};
let body = syn::parse::<ServerFnBody>(body.into())?;
let body = syn::parse::<ServerFnBody>(s.into())?;
let fn_name = &body.ident;
let fn_name_as_str = body.ident.to_string();
let vis = body.vis;
let block = body.block;
let fields = body
.inputs
.iter()
.filter(|f| {
if let Some(ctx) = &server_context {
!fn_arg_is_cx(f, ctx)
} else {
true
cfg_if! {
if #[cfg(all(not(feature = "stable"), debug_assertions))] {
use proc_macro::Span;
let span = Span::call_site();
#[cfg(not(target_os = "windows"))]
let url = format!("{}/{}", span.source_file().path().to_string_lossy(), fn_name_as_str).replace('/', "-");
#[cfg(target_os = "windows")]
let url = format!("{}\\{}", span.source_file().path().to_string_lossy(), fn_name_as_str).replace("\\", "-");
} else {
let url = fn_name_as_str;
}
}
let fields = body.inputs.iter().filter(|f| !fn_arg_is_cx(f)).map(|f| {
let typed_arg = match f {
FnArg::Receiver(_) => {
abort!(f, "cannot use receiver types in server function macro")
}
})
.map(|f| {
let typed_arg = match f {
FnArg::Receiver(_) => {
abort!(
f,
"cannot use receiver types in server function macro"
)
}
FnArg::Typed(t) => t,
};
quote! { pub #typed_arg }
});
FnArg::Typed(t) => t,
};
quote! { pub #typed_arg }
});
let cx_arg = body.inputs.iter().next().and_then(|f| {
server_context
.as_ref()
.and_then(|ctx| fn_arg_is_cx(f, ctx).then_some(f))
if fn_arg_is_cx(f) {
Some(f)
} else {
None
}
});
let cx_assign_statement = if let Some(FnArg::Typed(arg)) = cx_arg {
if let Pat::Ident(id) = &*arg.pat {
@@ -139,11 +100,7 @@ pub fn server_macro_impl(
}
FnArg::Typed(t) => t,
};
let is_cx = if let Some(ctx) = &server_context {
!fn_arg_is_cx(f, ctx)
} else {
true
};
let is_cx = fn_arg_is_cx(f);
if is_cx {
quote! {
#[allow(unused)]
@@ -158,12 +115,8 @@ pub fn server_macro_impl(
let field_names = body.inputs.iter().filter_map(|f| match f {
FnArg::Receiver(_) => todo!(),
FnArg::Typed(t) => {
if let Some(ctx) = &server_context {
if fn_arg_is_cx(f, ctx) {
None
} else {
Some(&t.pat)
}
if fn_arg_is_cx(f) {
None
} else {
Some(&t.pat)
}
@@ -195,24 +148,13 @@ pub fn server_macro_impl(
);
};
let server_ctx_path = if let Some(ctx) = &server_context {
let path = &ctx.path;
quote!(#path)
} else {
quote!(())
};
let server_fn_path = server_fn_path
.map(|path| quote!(#path))
.unwrap_or_else(|| quote! { server_fn });
Ok(quote::quote! {
#[derive(Clone, Debug, ::serde::Serialize, ::serde::Deserialize)]
pub struct #struct_name {
#(#fields),*
}
impl #server_fn_path::ServerFn<#server_ctx_path> for #struct_name {
impl leptos::ServerFn for #struct_name {
type Output = #output_ty;
fn prefix() -> &'static str {
@@ -220,22 +162,22 @@ pub fn server_macro_impl(
}
fn url() -> &'static str {
#server_fn_path::const_format::concatcp!(#fn_name_as_str, #server_fn_path::xxhash_rust::const_xxh64::xxh64(concat!(env!("CARGO_MANIFEST_DIR"), ":", file!(), ":", line!(), ":", column!()).as_bytes(), 0))
#url
}
fn encoding() -> #server_fn_path::Encoding {
fn encoding() -> ::leptos::leptos_server::Encoding {
#encoding
}
#[cfg(feature = "ssr")]
fn call_fn(self, cx: #server_ctx_path) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Output, server_fn::ServerFnError>>>> {
fn call_fn(self, cx: ::leptos::Scope) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Output, ::leptos::ServerFnError>>>> {
let #struct_name { #(#field_names),* } = self;
#cx_assign_statement;
Box::pin(async move { #fn_name( #cx_fn_arg #(#field_names_2),*).await })
}
#[cfg(not(feature = "ssr"))]
fn call_fn_client(self, cx: #server_ctx_path) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Output, server_fn::ServerFnError>>>> {
fn call_fn_client(self, cx: ::leptos::Scope) -> 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( #cx_fn_arg #(#field_names_4),*).await })
}
@@ -245,22 +187,21 @@ pub fn server_macro_impl(
#vis async fn #fn_name(#(#fn_args),*) #output_arrow #return_ty {
#block
}
#[cfg(not(feature = "ssr"))]
#vis async fn #fn_name(#(#fn_args_2),*) #output_arrow #return_ty {
let prefix = #struct_name::prefix().to_string();
let url = prefix + "/" + #struct_name::url();
#server_fn_path::call_server_fn(&url, #struct_name { #(#field_names_5),* }, #encoding).await
::leptos::leptos_server::call_server_fn(&url, #struct_name { #(#field_names_5),* }, #encoding).await
}
})
}
struct ServerFnName {
pub struct ServerFnName {
struct_name: Ident,
_comma: Option<Token![,]>,
prefix: Option<Literal>,
_comma2: Option<Token![,]>,
encoding: Path,
encoding: Encoding,
}
impl Parse for ServerFnName {
@@ -269,14 +210,7 @@ impl Parse for ServerFnName {
let _comma = input.parse()?;
let prefix = input.parse()?;
let _comma2 = input.parse()?;
let encoding = input
.parse::<Literal>()
.map(|encoding| match encoding.to_string().as_str() {
"\"Url\"" => syn::parse_quote!(Encoding::Url),
"\"Cbor\"" => syn::parse_quote!(Encoding::Cbor),
_ => abort!(encoding, "Encoding Not Found"),
})
.unwrap_or(syn::parse_quote!(Encoding::Url));
let encoding = input.parse().unwrap_or(Encoding::Url);
Ok(Self {
struct_name,
@@ -288,8 +222,7 @@ impl Parse for ServerFnName {
}
}
#[allow(unused)]
struct ServerFnBody {
pub struct ServerFnBody {
pub attrs: Vec<Attribute>,
pub vis: syn::Visibility,
pub async_token: Token![async],

View File

@@ -1,5 +1,4 @@
use crate::attribute_value;
use leptos_hot_reload::parsing::is_component_node;
use crate::{attribute_value, is_component_node};
use proc_macro2::{Ident, Span, TokenStream};
use quote::{quote, quote_spanned};
use syn::spanned::Spanned;
@@ -88,9 +87,7 @@ fn root_element_to_tokens(
leptos::leptos_dom::View::Element(leptos::leptos_dom::Element {
#[cfg(debug_assertions)]
name: #tag_name.into(),
element: root.unchecked_into(),
#[cfg(debug_assertions)]
view_marker: None
element: root.unchecked_into()
})
}
}
@@ -269,8 +266,8 @@ fn attr_to_tokens(
expressions: &mut Vec<TokenStream>,
) {
let name = node.key.to_string();
let name = name.strip_prefix('_').unwrap_or(&name);
let name = name.strip_prefix("attr:").unwrap_or(name);
let name = name.strip_prefix("_").unwrap_or(&name);
let name = name.strip_prefix("attr:").unwrap_or(&name);
let value = match &node.value {
Some(expr) => match expr.as_ref() {
@@ -302,7 +299,7 @@ fn attr_to_tokens(
}
// Properties
else if let Some(name) = name.strip_prefix("prop:") {
let value = attribute_value(node);
let value = attribute_value(&node);
expressions.push(quote_spanned! {
span => leptos_dom::property(#cx, #el_id.unchecked_ref(), #name, #value.into_property(#cx))
@@ -310,7 +307,7 @@ fn attr_to_tokens(
}
// Classes
else if let Some(name) = name.strip_prefix("class:") {
let value = attribute_value(node);
let value = attribute_value(&node);
expressions.push(quote_spanned! {
span => leptos::leptos_dom::class_helper(#el_id.unchecked_ref(), #name.into(), #value.into_class(#cx))
@@ -321,14 +318,14 @@ fn attr_to_tokens(
match value {
AttributeValue::Empty => {
template.push(' ');
template.push_str(name);
template.push_str(&name);
}
// Static attributes (i.e., just a literal given as value, not an expression)
// are just set in the template — again, nothing programmatic
AttributeValue::Static(value) => {
template.push(' ');
template.push_str(name);
template.push_str(&name);
template.push_str("=\"");
template.push_str(&value);
template.push('"');
@@ -466,7 +463,7 @@ fn block_to_tokens(
let mount_kind = match &next_sib {
Some(child) => {
quote! { leptos::leptos_dom::MountKind::Before(&#child.clone()) }
quote! { leptos::leptos_dom::MountKind::Before(#child.clone()) }
}
None => {
quote! { leptos::leptos_dom::MountKind::Append(&#parent) }

View File

@@ -1,5 +1,4 @@
use crate::{attribute_value, Mode};
use leptos_hot_reload::parsing::{is_component_node, value_to_string};
use crate::{attribute_value, is_component_node, Mode};
use proc_macro2::{Ident, Span, TokenStream, TokenTree};
use quote::{format_ident, quote, quote_spanned};
use syn::{spanned::Spanned, Expr, ExprLit, ExprPath, Lit};
@@ -147,7 +146,6 @@ pub(crate) fn render_view(
nodes: &[Node],
mode: Mode,
global_class: Option<&TokenTree>,
call_site: Option<String>,
) -> TokenStream {
if mode == Mode::Ssr {
match nodes.len() {
@@ -157,15 +155,12 @@ pub(crate) fn render_view(
span => leptos::leptos_dom::Unit
}
}
1 => {
root_node_to_tokens_ssr(cx, &nodes[0], global_class, call_site)
}
1 => root_node_to_tokens_ssr(cx, &nodes[0], global_class),
_ => fragment_to_tokens_ssr(
cx,
Span::call_site(),
nodes,
global_class,
call_site,
),
}
} else {
@@ -176,13 +171,7 @@ pub(crate) fn render_view(
span => leptos::leptos_dom::Unit
}
}
1 => node_to_tokens(
cx,
&nodes[0],
TagType::Unknown,
global_class,
call_site,
),
1 => node_to_tokens(cx, &nodes[0], TagType::Unknown, global_class),
_ => fragment_to_tokens(
cx,
Span::call_site(),
@@ -190,7 +179,6 @@ pub(crate) fn render_view(
true,
TagType::Unknown,
global_class,
call_site,
),
}
}
@@ -200,7 +188,6 @@ fn root_node_to_tokens_ssr(
cx: &Ident,
node: &Node,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
match node {
Node::Fragment(fragment) => fragment_to_tokens_ssr(
@@ -208,7 +195,6 @@ fn root_node_to_tokens_ssr(
Span::call_site(),
&fragment.children,
global_class,
view_marker,
),
Node::Comment(_) | Node::Doctype(_) | Node::Attribute(_) => quote! {},
Node::Text(node) => {
@@ -225,7 +211,7 @@ fn root_node_to_tokens_ssr(
}
}
Node::Element(node) => {
root_element_to_tokens_ssr(cx, node, global_class, view_marker)
root_element_to_tokens_ssr(cx, node, global_class)
}
}
}
@@ -235,15 +221,9 @@ fn fragment_to_tokens_ssr(
_span: Span,
nodes: &[Node],
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
let nodes = nodes.iter().map(|node| {
let node = root_node_to_tokens_ssr(cx, node, global_class, None);
let node = root_node_to_tokens_ssr(cx, node, global_class);
quote! {
#node.into_view(#cx)
}
@@ -253,7 +233,6 @@ fn fragment_to_tokens_ssr(
leptos::Fragment::lazy(|| vec![
#(#nodes),*
])
#view_marker
}
}
}
@@ -262,7 +241,6 @@ fn root_element_to_tokens_ssr(
cx: &Ident,
node: &NodeElement,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
if is_component_node(node) {
component_to_tokens(cx, node, global_class)
@@ -287,10 +265,10 @@ fn root_element_to_tokens_ssr(
}
} else {
quote! {
format!(
#template,
#(#holes)*
)
format!(
#template,
#(#holes)*
)
}
};
@@ -320,15 +298,10 @@ fn root_element_to_tokens_ssr(
leptos::leptos_dom::#typed_element_name::default()
}
};
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
quote! {
{
#(#exprs_for_compiler)*
::leptos::HtmlElement::from_html(cx, #full_name, #template)#view_marker
::leptos::HtmlElement::from_html(cx, #full_name, #template)
}
}
}
@@ -456,6 +429,19 @@ fn element_to_tokens_ssr(
}
}
fn value_to_string(value: &syn_rsx::NodeValueExpr) -> Option<String> {
match &value.as_ref() {
syn::Expr::Lit(lit) => match &lit.lit {
syn::Lit::Str(s) => Some(s.value()),
syn::Lit::Char(c) => Some(c.value().to_string()),
syn::Lit::Int(i) => Some(i.base10_digits().to_string()),
syn::Lit::Float(f) => Some(f.base10_digits().to_string()),
_ => None,
},
_ => None,
}
}
// returns `inner_html`
fn attribute_to_tokens_ssr<'a>(
cx: &Ident,
@@ -501,8 +487,6 @@ fn attribute_to_tokens_ssr<'a>(
.unwrap_or_default(),
})
}
} else {
template.push_str(&name);
}
}
};
@@ -648,29 +632,20 @@ fn fragment_to_tokens(
lazy: bool,
parent_type: TagType,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
let nodes = nodes.iter().map(|node| {
let node = node_to_tokens(cx, node, parent_type, global_class, None);
let node = node_to_tokens(cx, node, parent_type, global_class);
quote! {
#node.into_view(#cx)
}
});
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
if lazy {
quote! {
{
leptos::Fragment::lazy(|| vec![
#(#nodes),*
])
#view_marker
}
}
} else {
@@ -679,7 +654,6 @@ fn fragment_to_tokens(
leptos::Fragment::new(vec![
#(#nodes),*
])
#view_marker
}
}
}
@@ -690,7 +664,6 @@ fn node_to_tokens(
node: &Node,
parent_type: TagType,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
match node {
Node::Fragment(fragment) => fragment_to_tokens(
@@ -700,7 +673,6 @@ fn node_to_tokens(
true,
parent_type,
global_class,
view_marker,
),
Node::Comment(_) | Node::Doctype(_) => quote! {},
Node::Text(node) => {
@@ -715,7 +687,7 @@ fn node_to_tokens(
}
Node::Attribute(node) => attribute_to_tokens(cx, node),
Node::Element(node) => {
element_to_tokens(cx, node, parent_type, global_class, view_marker)
element_to_tokens(cx, node, parent_type, global_class)
}
}
}
@@ -725,7 +697,6 @@ fn element_to_tokens(
node: &NodeElement,
mut parent_type: TagType,
global_class: Option<&TokenTree>,
view_marker: Option<String>,
) -> TokenStream {
if is_component_node(node) {
component_to_tokens(cx, node, global_class)
@@ -777,10 +748,7 @@ fn element_to_tokens(
None => quote! {},
Some(class) => {
quote! {
.classes(
#[allow(unused_braces)]
#class
)
.class(#class, true)
}
}
};
@@ -793,7 +761,6 @@ fn element_to_tokens(
true,
parent_type,
global_class,
None,
),
Node::Text(node) => {
let value = node.value.as_ref();
@@ -808,7 +775,7 @@ fn element_to_tokens(
}
}
Node::Element(node) => {
element_to_tokens(cx, node, parent_type, global_class, None)
element_to_tokens(cx, node, parent_type, global_class)
}
Node::Comment(_) | Node::Doctype(_) | Node::Attribute(_) => {
quote! {}
@@ -818,17 +785,11 @@ fn element_to_tokens(
.child((#cx, #child))
}
});
let view_marker = if let Some(marker) = view_marker {
quote! { .with_view_marker(#marker) }
} else {
quote! {}
};
quote! {
#name
#(#attrs)*
#global_class_expr
#(#children)*
#view_marker
}
}
}
@@ -859,7 +820,7 @@ fn attribute_to_tokens(cx: &Ident, node: &NodeAttribute) -> TokenStream {
};
let event_type = if is_custom {
quote! { Custom::new(#name) }
quote! { leptos::ev::Custom::new(#name) }
} else {
event_type
};
@@ -1060,7 +1021,6 @@ pub(crate) fn component_to_tokens(
true,
TagType::Unknown,
global_class,
None,
);
let clonables = items_to_clone

View File

@@ -1,23 +0,0 @@
use core::num::NonZeroUsize;
use leptos::*;
#[component]
fn Component(
_cx: Scope,
#[prop(optional)] optional: bool,
#[prop(optional_no_strip)] optional_no_strip: Option<String>,
#[prop(strip_option)] strip_option: Option<u8>,
#[prop(default = NonZeroUsize::new(10).unwrap())] default: NonZeroUsize,
#[prop(into)] into: String,
) -> impl IntoView {
}
#[test]
fn component() {
let cp = ComponentProps::builder().into("").strip_option(9).build();
assert_eq!(cp.optional, false);
assert_eq!(cp.optional_no_strip, None);
assert_eq!(cp.strip_option, Some(9));
assert_eq!(cp.default, NonZeroUsize::new(10).unwrap());
assert_eq!(cp.into, "");
}

View File

@@ -1,5 +0,0 @@
#[test]
fn ui() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
}

Some files were not shown because too many files have changed in this diff Show More