Compare commits

...

65 Commits

Author SHA1 Message Date
Greg Johnston
b97aaa0401 swap from stable feature to nightly feature 2023-02-23 20:16:59 -05:00
Denis Nazarov
efe29fd057 Relax Eq to PartialEq for create_slice() (#570)
Co-authored-by: Denis Nazarov <denis.nazarov@gmail.com>
2023-02-23 08:00:01 -05:00
Greg Johnston
05b5b25c0e fixes issue #565 (#566) 2023-02-22 09:20:33 -05:00
Greg Johnston
46f6deddbf fix: transition fallback (closes #562) (#563) 2023-02-21 15:11:08 -05:00
Fangdun Tsai
e9c4b490e5 feat: viz integration (#506) 2023-02-21 12:29:15 -05:00
PolarMutex
f7d0eea04d feature: add class prop to <Html/> component (#554) 2023-02-21 12:28:11 -05:00
Greg Johnston
bf246a62e7 fix: issue with local resources blocking <Suspense/> fragments from resolving (#561) 2023-02-21 06:29:29 -05:00
Greg Johnston
b8acfd758d fix: remove unnecessary log (#560) 2023-02-20 21:34:09 -05:00
Greg Johnston
dc8fd37461 docs: add create_resource, <Suspense/>, and <Transition/> (#559) 2023-02-20 17:39:17 -05:00
Greg Johnston
cc2b310e4c docs: add example of <ButtonC on:click/> syntax (#558) 2023-02-20 15:41:56 -05:00
Thomas Versteeg
884348d7f8 doc: fix button name in parent_child example (#555) 2023-02-20 14:53:04 -05:00
Greg Johnston
dafd6e51c5 v0.2.0-beta (#557) 2023-02-20 14:52:31 -05:00
Ben Wishovich
322041917d fix issue with redirects in server fns creating multiple Location headers (#550) 2023-02-20 08:55:47 -05:00
Ikko Eltociear Ashimine
a2eaf9b3ee fix: typo in hydration docs(#552)
identifer -> identifier
2023-02-20 07:09:33 -05:00
Chrislearn Young
4032bfc210 fix: document docs typo (#553) 2023-02-20 07:08:51 -05:00
Greg Johnston
4ff08f042b change: pass Scope as argument into Resource::read() and Resource::with() (#542) 2023-02-19 19:52:31 -05:00
Greg Johnston
ce4b0ecbe1 fix: more work on hydration IDs with <Suspense/> (#545) 2023-02-18 21:20:40 -05:00
Greg Johnston
6c31d09eb2 revert PR #538 (#544) 2023-02-18 18:39:08 -05:00
Greg Johnston
59ad6a4725 revert accident 2023-02-18 16:03:20 -05:00
Greg Johnston
884dacbc6c fix example 2023-02-18 16:02:19 -05:00
Dmitrii Kuzmin
9c572f7617 fix(examples): hackernews_axum styles href (#536) 2023-02-18 15:17:54 -05:00
jquesada2016
487dba90d8 fix: off-by-one error in <For/> (closes #533) (#538) 2023-02-18 14:57:14 -05:00
Greg Johnston
20f24d2f3a fix: building leptos_reactive in release mode (#540) 2023-02-18 12:45:58 -05:00
Greg Johnston
20cbc240ee v0.2.0-alpha2 (#539) 2023-02-18 12:45:46 -05:00
jquesada2016
f2f52b2533 change: move signal method implementations into traits in signal prelude (#490) 2023-02-18 07:30:03 -05:00
Sean Aye
46d6e3f78c fix compile of leptos dom (#535) 2023-02-17 18:25:58 -05:00
Greg Johnston
586f524015 feature: in-order streaming and async rendering (#496) 2023-02-17 17:31:32 -05:00
Greg Johnston
79781ec20c Fix test import location 2023-02-17 16:18:22 -05:00
Greg Johnston
91f6d9a404 What's in a name? 2023-02-17 07:25:42 -05:00
Greg Johnston
76a74ecde2 fix: hydration IDs for elements following <Suspense/> (closes #527) (#531) 2023-02-16 21:12:52 -05:00
Greg Johnston
0071a48b8a feature: reintroduce limited template-node cloning w/ template macro (#526) 2023-02-16 07:02:01 -05:00
Greg Johnston
8d42e91eb8 fix: top-level SVG in view macro with new exports (#525) 2023-02-15 15:38:06 -05:00
Greg Johnston
00a796d204 change: tweak API of Errors and implement IntoIter (#522) 2023-02-15 14:03:16 -05:00
henrik
bde585dc3e feature: enable cargo-leptos to reload multiple CSS files (#524) 2023-02-14 18:51:47 -05:00
Greg Johnston
0a534bd7fd Reexport web-sys event types in leptos::ev to make it easier to type handlers (#521) 2023-02-13 20:45:46 -05:00
Greg Johnston
50d8eae694 fix: correct namespace for Unit in empty views (closes #518) (#520) 2023-02-13 20:25:26 -05:00
martin frances
e732a4952b leptos_dom erros.rs remove<E>() does not need to be generic. (#516)
* leptos_dom erros.rs remove<E>() does not need to be generic.

* fixed up errors.remove().
2023-02-13 20:25:11 -05:00
Greg Johnston
8a99623fd6 0.2.0-alpha (#515) 2023-02-13 07:49:29 -05:00
Greg Johnston
7d6c4930e4 remove .unwrap() from redirect in Actix integration (#514) 2023-02-13 06:02:43 -05:00
IcosaHedron
81d6689cc0 do not unwrap use_context in integrations axum redirect (#513) 2023-02-12 21:59:12 -05:00
Greg Johnston
989b5b93c3 CI: fix Wasm testing (#511) 2023-02-12 19:39:32 -05:00
Greg Johnston
ca510f72c1 fix: SSR export in Wasm mode (#512) 2023-02-12 19:12:15 -05:00
Greg Johnston
6dd3be75d1 fix: import in leptos_dom and add Wasm build to CI for regressions (#510) 2023-02-12 18:58:57 -05:00
g-re-g
51e11e756a Typos and a small cleanup (#509) 2023-02-12 18:11:31 -05:00
Greg Johnston
1dbcfe2861 change: reorganize module exports and reexports (#503) 2023-02-12 17:04:36 -05:00
Greg Johnston
db3f46c501 Add docs on testing (closes #489) (#508) 2023-02-12 17:03:12 -05:00
Greg Johnston
1cba54d47e fix: <For/> in todomvc example (#504) 2023-02-11 16:30:09 -05:00
Greg Johnston
d1ae3b49cc docs: further additions (#505) 2023-02-11 15:55:43 -05:00
Greg Johnston
6bab4ad966 apply new formatting everywhere (#502) 2023-02-11 14:30:06 -05:00
jquesada2016
d4648da5c6 chore: add workspace rustfmt.tml (#483) 2023-02-11 14:25:55 -05:00
Greg Johnston
cf7deaaea3 fix: proper disposal of nested route scopes (#499) 2023-02-11 14:12:59 -05:00
g-re-g
d0cacecfc6 Allow literal string as class in view macro (#500) 2023-02-10 22:43:40 -05:00
Greg Johnston
ce2c3ec97c examples: remove unused index.html (#497) 2023-02-10 08:02:26 -05:00
martin frances
b9f05f94ce chore: remove unused .clone() call in <Suspense/>. (#486) 2023-02-08 20:44:10 -05:00
Greg Johnston
fe7aacb0c8 Handle <ErrorBoundary/> hydration correctly (closes #456) 2023-02-08 20:32:59 -05:00
Greg Johnston
3fd3e73a10 Correctly handle custom elements in SSR 2023-02-08 20:32:59 -05:00
Greg Johnston
7dca740e47 Add error boundary example to list 2023-02-08 20:32:59 -05:00
Greg Johnston
73420affed Basic error boundary example 2023-02-08 20:32:59 -05:00
Greg Johnston
7c25f59a68 Update README.md 2023-02-08 20:32:32 -05:00
Greg Johnston
c24874d9c8 change: add Scope to view function in <For/> to avoid memory "leak" (#492) 2023-02-08 20:28:04 -05:00
Greg Johnston
4759dfcb60 missing ; 2023-02-08 14:34:57 -05:00
Greg Johnston
ca9419b53f fix: fix debug_warn behavior in reactive crate and remove log dependency (#491) 2023-02-08 07:04:01 -05:00
jquesada2016
765006158a change: NodeRef<HtmlElement<Div>> generics to NodeRef<Div> (#481) 2023-02-07 20:13:25 -05:00
Greg Johnston
8a1adaefaf fix: typed route params with #[derive(Params)] (#488) 2023-02-07 17:28:46 -05:00
Greg Johnston
086326324e Fix inner_html in SSR (#487) 2023-02-07 13:14:14 -05:00
192 changed files with 12596 additions and 7141 deletions

View File

@@ -30,6 +30,9 @@ jobs:
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
@@ -43,4 +46,3 @@ jobs:
- name: Run tests with all features
run: cargo make ci

View File

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

View File

@@ -12,13 +12,17 @@ dependencies = ["build", "check-examples", "test"]
[tasks.build]
clear = true
dependencies = ["build-all"]
dependencies = ["build-all", "build-wasm"]
[tasks.build-all]
command = "cargo"
args = ["+nightly", "build-all-features"]
install_crate = "cargo-all-features"
[tasks.build-wasm]
clear = true
dependencies = [{ name = "build-wasm", path = "leptos" }]
[tasks.check-examples]
clear = true
dependencies = [
@@ -36,6 +40,7 @@ dependencies = [
{ name = "check", path = "examples/tailwind" },
{ name = "check", path = "examples/todo_app_sqlite" },
{ name = "check", path = "examples/todo_app_sqlite_axum" },
{ name = "check", path = "examples/todo_app_sqlite_viz" },
{ name = "check", path = "examples/todomvc" },
]

View File

@@ -78,7 +78,7 @@ rustup target add wasm32-unknown-unknown
If youre on `stable`, note the following:
1. You need to enable the `"stable"` flag in `Cargo.toml`: `leptos = { version = "0.1.0-alpha", features = ["stable"] }`
1. You need to enable the `"stable"` flag in `Cargo.toml`: `leptos = { version = "0.1.0", features = ["stable"] }`
2. `nightly` enables the function call syntax for accessing and setting signals. If youre using `stable`,
youll just call `.get()`, `.set()`, or `.update()` manually. Check out the
[`counters_stable` example](https://github.com/leptos-rs/leptos/blob/main/examples/counters_stable/src/main.rs)
@@ -99,6 +99,10 @@ Open browser on [http://localhost:3000/](http://localhost:3000/)
## FAQs
### Whats up with the name?
*Leptos* (λεπτός) is an ancient Greek word meaning “thin, light, refine, fine-grained.” To me, a classicist and not a dog owner, it evokes the lightweight reactive system that powers the framework. I've since learned the same word is at the root of the medical term “leptospirosis,” a blood infection that affects humans and animals... My bad. No dogs were harmed in the creation of this framework.
### Is it production ready?
People usually mean one of three things by this question.

1
docs/book/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
book

View File

@@ -11,14 +11,14 @@
- [Control Flow](./view/06_control_flow.md)
- [Error Handling](./view/07_errors.md)
- [Parent-Child Communication](./view/08_parent_child.md)
- [Passing Children to Components]()
- [Interlude: Reactivity and Functions]()
- [Testing]()
- [Interlude: Styling — CSS, Tailwind, Style.rs, and more]()
- [Async]()
- [Resource]()
- [Suspense]()
- [Passing Children to Components](./view/09_component_children.md)
- [Interlude: Reactivity and Functions](./interlude_functions.md)
- [Testing](./testing.md)
- [Async](./async/README.md)
- [Loading Data with Resources](./async/10_resources.md)
- [Suspense](./async/11_suspense.md)
- [Transition]()
- [Interlude: Styling — CSS, Tailwind, Style.rs, and more]()
- [State Management]()
- [Interlude: Advanced Reactivity]()
- [Router]()

View File

@@ -0,0 +1,53 @@
# Loading Data with Resources
A [Resource](https://docs.rs/leptos/latest/leptos/struct.Resource.html) is a reactive data structure that reflects the current state of an asynchronous task, allowing you to integrate asynchronous `Future`s into the synchronous reactive system. Rather than waiting for its data to load with `.await`, you transform the `Future` into a signal that returns `Some(T)` if it has resolved, and `None` if its still pending.
You do this by using the [`create_resource`](https://docs.rs/leptos/latest/leptos/fn.create_resource.html) function. This takes two arguments (other than the ubiquitous `cx`):
1. a source signal, which will generate a new `Future` whenever it changes
2. a fetcher function, which takes the data from that signal and returns a `Future`
Heres an example
```rust
// our source signal: some synchronous, local state
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
},
);
```
To create a resource that simply runs once, you can pass a non-reactive, empty source signal:
```rust
let once = create_resource(cx, || (), |_| async move { load_data().await });
```
To access the value you can use `.read(cx)` or `.with(cx, |data| /* */)`. These work just like `.get()` and `.with()` on a signal—`read` clones the value and returns it, `with` applies a closure to it—but with two differences
1. For any `Resource<_, T>`, they always return `Option<T>`, not `T`: because its always possible that your resource is still loading.
2. They take a `Scope` argument. Youll see why in the next chapter, on `<Suspense/>`.
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)
}}
}
```
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

@@ -0,0 +1,72 @@
# `<Suspense/>`
In the previous chapter, we showed how you can create a simple loading screen to show some fallback while a resource is loading.
```rust
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)
}}
}
```
But what if we have two resources, and want to wait for both of them?
```rust
let (count, set_count) = create_signal(cx, 0);
let (count2, set_count2) = create_signal(cx, 0);
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)
}}
}
```
Thats not _so_ bad, but its kind of annoying. What if we could invert the flow of control?
The [`<Suspense/>`](https://docs.rs/leptos/latest/leptos/fn.Suspense.html) component lets us do exactly that. You give it a `fallback` prop and children, one or more of which usually involves reading from a resource. Reading from a resource “under” a `<Suspense/>` (i.e., in one of its children) registers that resource with the `<Suspense/>`. If its still waiting for resources to load, it shows the `fallback`. When theyve all loaded, it shows the children.
```rust
let (count, set_count) = create_signal(cx, 0);
let (count2, set_count2) = create_signal(cx, 0);
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>
}
```
Every time one of the resources is reloading, the `"Loading..."` fallback will show again.
This inversion of the flow of control makes it easier to add or remove individual resources, as you dont need to handle the matching yourself. It also unlocks some massive performance improvements during server-side rendering, which well talk about during a later chapter.
<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

@@ -0,0 +1,9 @@
# `<Transition/>`
Youll notice in the `<Suspense/>` example that if you keep reloading the data, it keeps flickering back to `"Loading..."`. Sometimes this is fine. For other times, theres [`<Transition/>`](https://docs.rs/leptos/latest/leptos/fn.Suspense.html).
`<Transition/>` behaves exactly the same as `<Suspense/>`, but instead of falling back every time, it only shows the fallback the first time. On all subsequent loads, it continues showing the old data until the new data are ready. This can be really handy to prevent the flickering effect, and to allow users to continue interacting with your application.
This example shows how you can create a simple tabbed contact list with `<Transition/>`. When you select a new tab, it continues showing the current contact until the new data laods. This can be a much better user experience than constantly falling back to a loading message.
<iframe src="https://codesandbox.io/p/sandbox/12-transition-sn38sd?selection=%5B%7B%22endColumn%22%3A15%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A15%2C%22startLineNumber%22%3A2%7D%5D&file=%2Fsrc%2Fmain.rs" width="100%" height="1000px"></iframe>

View File

@@ -0,0 +1,9 @@
# Working with `async`
So far weve only been working with synchronous users interfaces: You provide some input,
the app immediately process it and updates the interface. This is great, but is a tiny
subset of what web applications do. In particular, most web apps have to deal with some kind
of asynchronous data loading, usually loading something from an API.
Asynchronous data is notoriously hard to integrate with the synchronous parts of your code.
In this chapter, well see how Leptos helps smooth out that process for you.

View File

@@ -0,0 +1,76 @@
# Interlude: Reactivity and Functions
One of our core contributors said to me recently: “I never used closures this often
until I started using Leptos.” And its true. Closures are at the heart of any Leptos
application. It sometimes looks a little silly:
```rust
// a signal holds a value, and can be updated
let (count, set_count) = create_signal(cx, 0);
// a derived signal is a function that accesses other signals
let double_count = move || count() * 2;
let count_is_odd = move || count() & 1 == 1;
let text = move || if count_is_odd() {
"odd"
} else {
"even"
};
// an effect automatically tracks the signals it depends on
// and re-runs when they change
create_effect(cx, move |_| {
log!("text = {}", text());
});
view! { cx,
<p>{move || text().to_uppercase()}</p>
}
```
Closures, closures everywhere!
But why?
## Functions and UI Frameworks
Functions are at the heart of every UI framework. And this makes perfect sense. Creating a user interface is basically divided into two phases:
1. initial rendering
2. updates
In a web framework, the framework does some kind of initial rendering. Then it hands control back over to the browser. When certain events fire (like a mouse click) or asynchronous tasks finish (like an HTTP request finishing), the browser wakes the framework back up to update something. The framework runs some kind of code to update your user interface, and goes back asleep until the browser wakes it up again.
The key phrase here is “runs some kind of code.” The natural way to “run some kind of code” at an arbitrary point in time—in Rust or in any other programming language—is to call a function. And in fact every UI framework is based on rerunning some kind of function over and over:
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 re-run
Thats what all our components are doing.
Take our typical `<SimpleCounter/>` example in its simplest form:
```rust
#[component]
pub fn SimpleCounter(cx: Scope) -> impl IntoView {
let (value, set_value) = create_signal(cx, 0);
let increment = move |_| set_value.update(|value| *value += 1);
view! { cx,
<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 re-run the smallest possible unit of your application in responsive to a change.
So remember two things:
1. Your component function is a setup function, not a render function: it only runs once.
2. For values in your view template to be reactive, they must be functions: either signals (which implement the `Fn` traits) or closures.

180
docs/book/src/testing.md Normal file
View File

@@ -0,0 +1,180 @@
# Testing Your Components
Testing user interfaces can be relatively tricky, but really important. This article
will discuss a couple principles and approaches for testing a Leptos app.
## 1. Test business logic with ordinary Rust tests
In many cases, it makes sense to pull the logic out of your components and test
it separately. For some simple components, theres no particular logic to test, but
for many its worth using a testable wrapping type and implementing the logic in
ordinary Rust `impl` blocks.
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 maximum = move || todos.with(|todos| {
todos.iter().filter(|todo| todo.completed).sum()
});
}
```
You could pull that logic out into a separate data structure and test it:
```rust
pub struct Todos(Vec<Todo>);
impl Todos {
pub fn remaining(&self) -> usize {
todos.iter().filter(|todo| todo.completed).sum()
}
}
#[cfg(test)]
mod tests {
#[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 maximum = move || todos.with(Todos::remaining);
}
```
In general, the less of your logic is wrapped into your components themselves, the
more idiomatic your code will feel and the easier it will be to test.
## 2. Test components with `wasm-bindgen-test`
[`wasm-bindgen-test`](https://crates.io/crates/wasm-bindgen-test) is a great utility
for integrating or end-to-end testing WebAssembly apps in a headless browser.
To use this testing utility, you need to add `wasm-bindgen-test` to your `Cargo.toml`:
```toml
[dev-dependencies]
wasm-bindgen-test = "0.3.0"
```
You should create tests in a separate `tests` directory. You can then run your tests in the browser of your choice:
```bash
wasm-pack test --firefox
```
> To see the full setup, check out the tests for the [`counter`](https://github.com/leptos-rs/leptos/tree/main/examples/counter) example.
### Writing Your Tests
Most tests will involve some combination of vanilla DOM manipulation and comparison to a `view`. For example, heres a test [for the
`counter` example](https://github.com/leptos-rs/leptos/blob/main/examples/counter/tests/mod.rs).
First, we set up the testing environment.
```rust
use wasm_bindgen_test::*;
use counter::*;
use leptos::*;
use web_sys::HtmlElement;
// tell the test runner to run tests in the browser
wasm_bindgen_test_configure!(run_in_browser);
```
Im going to create a simpler wrapper for each test case, and mount it there.
This makes it easy to encapsulate the test results.
```rust
// like marking a regular test with #[test]
#[wasm_bindgen_test]
fn clear() {
let document = leptos::document();
let test_wrapper = document.create_element("section").unwrap();
document.body().unwrap().append_child(&test_wrapper);
// start by rendering our counter and mounting it to the DOM
// note that we start at the initial value of 10
mount_to(
test_wrapper.clone().unchecked_into(),
|cx| view! { cx, <SimpleCounter initial_value=10 step=1/> },
);
```
Well use some manual DOM operations to grab the `<div>` that wraps
the whole component, as well as the `clear` button.
```rust
// now we extract the buttons by iterating over the DOM
// this would be easier if they had IDs
let div = test_wrapper.query_selector("div").unwrap().unwrap();
let clear = test_wrapper
.query_selector("button")
.unwrap()
.unwrap()
.unchecked_into::<web_sys::HtmlElement>();
```
Now we can use ordinary DOM APIs to simulate user interaction.
```rust
// now let's click the `clear` button
clear.click();
```
You can test individual DOM element attributes or text node values. Sometimes
I like to test the whole view at once. We can do this by testing the elements
`outerHTML` against our expectations.
```rust
assert_eq!(
div.outer_html(),
// here we spawn a mini reactive system to render the test case
run_scope(create_runtime(), |cx| {
// it's as if we're creating it with a value of 0, right?
let (value, set_value) = create_signal(cx, 0);
// we can remove the event listeners because they're not rendered to HTML
view! { cx,
<div>
<button>"Clear"</button>
<button>"-1"</button>
<span>"Value: " {value} "!"</span>
<button>"+1"</button>
</div>
}
// the view returned an HtmlElement<Div>, which is a smart pointer for
// a DOM element. So we can still just call .outer_html()
.outer_html()
})
);
```
That test involved us manually replicating the `view` thats inside the component.
There's actually an easier way to do this... We can just test against a `<SimpleCounter/>`
with the initial value `0`. This is where our wrapping element comes in: Ill just test
the wrappers `innerHTML` against another comparison case.
```rust
assert_eq!(test_wrapper.inner_html(), {
let comparison_wrapper = document.create_element("section").unwrap();
leptos::mount_to(
comparison_wrapper.clone().unchecked_into(),
|cx| view! { cx, <SimpleCounter initial_value=0 step=1/>},
);
comparison_wrapper.inner_html()
});
}
```
This is only a very limited introduction to testing. But I hope its useful as you begin to build applications.
> For more, see [the testing section of the `wasm-bindgen` guide](https://rustwasm.github.io/wasm-bindgen/wasm-bindgen-test/index.html#testing-on-wasm32-unknown-unknown-with-wasm-bindgen-test).

View File

@@ -187,6 +187,8 @@ This rerenders `<Small/>` five times, then `<Big/>` infinitely. If theyre
loading resources, creating signals, or even just creating DOM nodes, this is
unnecessary work.
### `<Show/>`
The [`<Show/>`](https://docs.rs/leptos/latest/leptos/fn.Show.html) component is
the answer. You pass it a `when` condition function, a `fallback` to be shown if
the `when` function returns `false`, and children to be rendered if `when` is `true`.

View File

@@ -0,0 +1,126 @@
# Component Children
Its pretty common to want to pass children into a component, just as you can pass
children into an HTML element. For example, imagine I have a `<FancyForm/>` component
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>
}
```
How can you do this in Leptos? There are basically two ways to pass components to
other components:
1. **render props**: properties that are functions that return a view
2. the **`children`** prop: a special component property that includes anything
you pass as a child to the component.
In fact, youve already seen these both in action in the [`<Show/>`](/view/06_control_flow.html#show) component:
```rust
view! { cx,
<Show
// `when` is a normal prop
when=move || value() > 5
// `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/>
</Show>
}
```
Lets define a component that takes some children and a render prop.
```rust
#[component]
pub fn TakesChildren<F, IV>(
cx: Scope,
/// Takes a function (type F) that returns anything that can be
/// converted into a View (type IV)
render_prop: F,
/// `children` takes the `Children` type
children: Children,
) -> impl IntoView
where
F: Fn() -> IV,
IV: IntoView,
{
view! { cx,
<h2>"Render Prop"</h2>
{render_prop()}
<h2>"Children"</h2>
{children(cx)}
}
}
```
`render_prop` and `children` are both functions, so we can call them to generate
the appropriate views. `children`, in particular, is an alias for
`Box<dyn FnOnce(Scope) -> Fragment>`. (Aren't you glad we named it `Children` instead?)
> If you need a `Fn` or `FnMut` here because you need to call `children` more than once,
> we also provide `ChildrenFn` and `ChildrenMut` aliases.
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>
}
```
## Manipulating Children
The [`Fragment`](https://docs.rs/leptos/latest/leptos/struct.Fragment.html) type is
basically a way of wrapping a `Vec<View>`. You can insert it anywhere into your view.
But you can also access those inner views directly to manipulate them. For example, heres
a component that takes its children and turns them into an unordered list.
```rust
#[component]
pub fn WrapsChildren(cx: Scope, children: Children) -> impl IntoView {
// Fragment has `nodes` field that contains a Vec<View>
let children = children(cx)
.nodes
.into_iter()
.map(|child| view! { cx, <li>{child}</li> })
.collect::<Vec<_>>();
view! { cx,
<ul>{children}</ul>
}
}
```
Calling it like this will create a list:
```rust
view! { cx,
<WrappedChildren>
"A"
"B"
"C"
</WrappedChildren>
}
```
<iframe src="https://codesandbox.io/p/sandbox/9-component-children-2wrdfd?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A12%2C%22endLineNumber%22%3A19%2C%22startColumn%22%3A12%2C%22startLineNumber%22%3A19%7D%5D" width="100%" height="1000px"></iframe>

View File

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

View File

@@ -1,16 +1,86 @@
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
use counter::*;
use leptos::*;
use web_sys::HtmlElement;
use counter::*;
#[wasm_bindgen_test]
fn clear() {
let document = leptos::document();
let test_wrapper = document.create_element("section").unwrap();
document.body().unwrap().append_child(&test_wrapper);
// start by rendering our counter and mounting it to the DOM
// note that we start at the initial value of 10
mount_to(
test_wrapper.clone().unchecked_into(),
|cx| view! { cx, <SimpleCounter initial_value=10 step=1/> },
);
// now we extract the buttons by iterating over the DOM
// this would be easier if they had IDs
let div = test_wrapper.query_selector("div").unwrap().unwrap();
let clear = test_wrapper
.query_selector("button")
.unwrap()
.unwrap()
.unchecked_into::<web_sys::HtmlElement>();
// now let's click the `clear` button
clear.click();
// now let's test the <div> against the expected value
// we can do this by testing its `outerHTML`
assert_eq!(
div.outer_html(),
// here we spawn a mini reactive system, just to render the
// test case
run_scope(create_runtime(), |cx| {
// it's as if we're creating it with a value of 0, right?
let (value, set_value) = create_signal(cx, 0);
// we can remove the event listeners because they're not rendered to HTML
view! { cx,
<div>
<button>"Clear"</button>
<button>"-1"</button>
<span>"Value: " {value} "!"</span>
<button>"+1"</button>
</div>
}
// the view returned an HtmlElement<Div>, which is a smart pointer for
// a DOM element. So we can still just call .outer_html()
.outer_html()
})
);
// There's actually an easier way to do this...
// We can just test against a <SimpleCounter/> with the initial value 0
assert_eq!(test_wrapper.inner_html(), {
let comparison_wrapper = document.create_element("section").unwrap();
leptos::mount_to(
comparison_wrapper.clone().unchecked_into(),
|cx| view! { cx, <SimpleCounter initial_value=0 step=1/>},
);
comparison_wrapper.inner_html()
});
}
#[wasm_bindgen_test]
fn inc() {
mount_to_body(|cx| view! { cx, <SimpleCounter initial_value=0 step=1/> });
let document = leptos::document();
let div = document.query_selector("div").unwrap().unwrap();
let test_wrapper = document.create_element("section").unwrap();
document.body().unwrap().append_child(&test_wrapper);
mount_to(
test_wrapper.clone().unchecked_into(),
|cx| view! { cx, <SimpleCounter initial_value=0 step=1/> },
);
// You can do testing with vanilla DOM operations
let document = leptos::document();
let div = test_wrapper.query_selector("div").unwrap().unwrap();
let clear = div
.first_child()
.unwrap()
@@ -47,4 +117,40 @@ fn inc() {
clear.click();
assert_eq!(text.text_content(), Some("Value: 0!".to_string()));
// Or you can test against a sample view!
assert_eq!(
div.outer_html(),
run_scope(create_runtime(), |cx| {
let (value, _) = create_signal(cx, 0);
view! { cx,
<div>
<button>"Clear"</button>
<button>"-1"</button>
<span>"Value: " {value} "!"</span>
<button>"+1"</button>
</div>
}
}
.outer_html())
);
inc.click();
assert_eq!(
div.outer_html(),
run_scope(create_runtime(), |cx| {
// because we've clicked, it's as if the signal is starting at 1
let (value, _) = create_signal(cx, 1);
view! { cx,
<div>
<button>"Clear"</button>
<button>"-1"</button>
<span>"Value: " {value} "!"</span>
<button>"+1"</button>
</div>
}
}
.outer_html())
);
}

View File

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

View File

@@ -97,10 +97,10 @@ pub fn Counter(cx: Scope) -> impl IntoView {
|_| get_server_count(),
);
let value = move || counter.read().map(|count| count.unwrap_or(0)).unwrap_or(0);
let value = move || counter.read(cx).map(|count| count.unwrap_or(0)).unwrap_or(0);
let error_msg = move || {
counter
.read()
.read(cx)
.map(|res| match res {
Ok(_) => None,
Err(e) => Some(e),
@@ -143,7 +143,7 @@ pub fn FormCounter(cx: Scope) -> impl IntoView {
let value = move || {
log::debug!("FormCounter looking for value");
counter
.read()
.read(cx)
.map(|n| n.ok())
.flatten()
.map(|n| n)
@@ -194,16 +194,16 @@ pub fn MultiuserCounter(cx: Scope) -> impl IntoView {
use futures::StreamExt;
let mut source = gloo_net::eventsource::futures::EventSource::new("/api/events")
.expect_throw("couldn't connect to SSE stream");
.expect("couldn't connect to SSE stream");
let s = create_signal_from_stream(
cx,
source.subscribe("message").unwrap().map(|value| {
value
.expect_throw("no message event")
.expect("no message event")
.1
.data()
.as_string()
.expect_throw("expected string value")
.expect("expected string value")
}),
);

View File

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

View File

@@ -1,4 +1,4 @@
use leptos::{ev, *};
use leptos::{ev, html::*, *};
pub struct Props {
/// The starting value for the counter
@@ -25,7 +25,9 @@ pub fn view(cx: Scope, props: Props) -> impl IntoView {
.child((
cx,
button(cx)
.on(ev::click, move |_| set_value.update(|value| *value -= step))
.on(ev::click, move |_| {
set_value.update(|value| *value -= step)
})
.child((cx, "-1")),
))
.child((
@@ -38,7 +40,9 @@ pub fn view(cx: Scope, props: Props) -> impl IntoView {
.child((
cx,
button(cx)
.on(ev::click, move |_| set_value.update(|value| *value += step))
.on(ev::click, move |_| {
set_value.update(|value| *value += step)
})
.child((cx, "+1")),
))
}

View File

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

View File

@@ -1,5 +1,4 @@
use leptos::*;
use leptos::{For, ForProps};
use leptos::{For, ForProps, *};
const MANY_COUNTERS: usize = 1000;
@@ -66,9 +65,8 @@ pub fn Counters(cx: Scope) -> impl IntoView {
<For
each=counters
key=|counter| counter.0
view=move |(id, (value, set_value)): (usize, (ReadSignal<i32>, WriteSignal<i32>))| {
view! {
cx,
view=move |cx, (id, (value, set_value)): (usize, (ReadSignal<i32>, WriteSignal<i32>))| {
view! { cx,
<Counter id value set_value/>
}
}
@@ -85,9 +83,11 @@ fn Counter(
value: ReadSignal<i32>,
set_value: WriteSignal<i32>,
) -> impl IntoView {
let CounterUpdater { set_counters } = use_context(cx).unwrap_throw();
let CounterUpdater { set_counters } = use_context(cx).unwrap();
let input = move |ev| set_value(event_target_value(&ev).parse::<i32>().unwrap_or_default());
let input = move |ev| {
set_value(event_target_value(&ev).parse::<i32>().unwrap_or_default())
};
// just an example of how a cleanup function works
// this will run when the scope is disposed, i.e., when this row is deleted

View File

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

View File

@@ -72,7 +72,7 @@ pub fn Counters(cx: Scope) -> impl IntoView {
<For
each={move || counters.get()}
key={|counter| counter.0}
view=move |(id, (value, set_value))| {
view=move |cx, (id, (value, set_value))| {
view! {
cx,
<Counter id value set_value/>
@@ -91,9 +91,12 @@ fn Counter(
value: ReadSignal<i32>,
set_value: WriteSignal<i32>,
) -> impl IntoView {
let CounterUpdater { set_counters } = use_context(cx).unwrap_throw();
let CounterUpdater { set_counters } = use_context(cx).unwrap();
let input = move |ev| set_value.set(event_target_value(&ev).parse::<i32>().unwrap_or_default());
let input = move |ev| {
set_value
.set(event_target_value(&ev).parse::<i32>().unwrap_or_default())
};
view! { cx,
<li>

View File

@@ -0,0 +1,10 @@
[package]
name = "error_boundary"
version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
console_log = "0.2"
log = "0.4"
console_error_panic_hook = "0.1.7"

View File

@@ -0,0 +1,9 @@
[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

@@ -0,0 +1,7 @@
# Leptos `<ErrorBoundary/>` Example
This example shows how to handle basic errors using Leptos.
To run it, just issue the `trunk serve --open` command in the example root. This will build the app, run it, and open a new browser to serve it.
> If you don't have `trunk` installed, [click here for install instructions.](https://trunkrs.dev/)

View File

@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<link data-trunk rel="rust" data-wasm-opt="z"/>
<link data-trunk rel="icon" type="image/ico" href="/public/favicon.ico"/>
</head>
<body></body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,48 @@
use leptos::*;
#[component]
pub fn App(cx: Scope) -> impl IntoView {
let (value, set_value) = create_signal(cx, Ok(0));
// when input changes, try to parse a number from the input
let on_input = move |ev| set_value(event_target_value(&ev).parse::<i32>());
view! { cx,
<h1>"Error Handling"</h1>
<label>
"Type a number (or something that's not a number!)"
<input type="number" on:input=on_input/>
// If an `Err(_) had been rendered inside the <ErrorBoundary/>,
// the fallback will be displayed. Otherwise, the children of the
// <ErrorBoundary/> will be displayed.
<ErrorBoundary
// the fallback receives a signal containing current errors
fallback=|cx, errors| view! { cx,
<div class="error">
<p>"Not a number! Errors: "</p>
// we can render a list of errors
// as strings, if we'd like
<ul>
{move || errors.get()
.0
.into_iter()
.map(|(_, e)| view! { cx, <li>{e.to_string()}</li>})
.collect::<Vec<_>>()
}
</ul>
</div>
}
>
<p>
"You entered "
// because `value` is `Result<i32, _>`,
// it will render the `i32` if it is `Ok`,
// and render nothing and trigger the error boundary
// if it is `Err`. It's a signal, so this will dynamically
// update when `value` changes
<strong>{value}</strong>
</p>
</ErrorBoundary>
</label>
}
}

View File

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

View File

@@ -12,13 +12,11 @@ console_log = "0.2.0"
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../../leptos/leptos", default-features = false, features = [
"serde",
] }
leptos_axum = { path = "../../../leptos/integrations/axum", default-features = false, optional = true }
leptos_meta = { path = "../../../leptos/meta", default-features = false }
leptos_router = { path = "../../../leptos/router", default-features = false }
leptos_reactive = { path = "../../../leptos/leptos_reactive", default-features = false }
leptos = { path = "../../../leptos/leptos", features = ["serde"] }
leptos_axum = { path = "../../../leptos/integrations/axum", optional = true }
leptos_meta = { path = "../../../leptos/meta" }
leptos_router = { path = "../../../leptos/router" }
leptos_reactive = { path = "../../../leptos/leptos_reactive" }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
@@ -32,45 +30,49 @@ tokio = { version = "1.22.0", features = ["full"], optional = true }
http = { version = "0.2.8" }
thiserror = "1.0.38"
tracing = "0.1.37"
wasm-bindgen = "0.2"
[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", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "dep:leptos_axum"]
ssr = [
"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]
denylist = [
"axum",
"tower",
"tower-http",
"tokio",
"leptos_axum",
]
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
output-name = "errors_axum"
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
site-pkg-dir = "pkg"
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "npx playwright test"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
browserquery = "defaults"
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
watch = false
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target

View File

@@ -1,8 +1,6 @@
use crate::errors::AppError;
use cfg_if::cfg_if;
use leptos::Errors;
use leptos::*;
use leptos::{Errors, *};
#[cfg(feature = "ssr")]
use leptos_axum::ResponseOptions;
@@ -23,12 +21,11 @@ pub fn ErrorTemplate(
};
// Get Errors from Signal
let errors = errors.get().0;
// Downcast lets us take a type that implements `std::error::Error`
let errors: Vec<AppError> = errors
.get()
.into_iter()
.filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned())
.filter_map(|(_, v)| v.downcast_ref::<AppError>().cloned())
.collect();
log!("Errors: {errors:#?}");
@@ -47,9 +44,9 @@ pub fn ErrorTemplate(
// 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
key=|(index, _)| *index
// renders each item to a view
view= move |error| {
view=move |cx, error| {
let error_string = error.1.to_string();
let error_code= error.1.status_code();
view! { cx,

View File

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

View File

@@ -9,6 +9,12 @@
max-width: 250px;
height: auto;
}
.error {
border: 1px solid red;
color: red;
background-color: lightpink;
}
</style>
<body></body>
</html>

View File

@@ -1,3 +1,4 @@
use anyhow::Result;
use leptos::*;
use serde::{Deserialize, Serialize};
@@ -6,18 +7,18 @@ pub struct Cat {
url: String,
}
async fn fetch_cats(count: u32) -> Result<Vec<String>, ()> {
async fn fetch_cats(count: u32) -> Result<Vec<String>> {
if count > 0 {
// make the request
let res = reqwasm::http::Request::get(&format!(
"https://api.thecatapi.com/v1/images/search?limit={}",
count
"https://api.thecatapi.com/v1/images/search?limit={count}",
))
.send()
.await
.map_err(|_| ())?
.await?
// convert it to JSON
.json::<Vec<Cat>>()
.await
.map_err(|_| ())?
.await?
// extract the URL field for each cat
.into_iter()
.map(|cat| cat.url)
.collect::<Vec<_>>();
@@ -29,9 +30,45 @@ async fn fetch_cats(count: u32) -> Result<Vec<String>, ()> {
pub fn fetch_example(cx: Scope) -> impl IntoView {
let (cat_count, set_cat_count) = create_signal::<u32>(cx, 1);
let cats = create_resource(cx, cat_count, |count| fetch_cats(count));
view! { cx,
// we use local_resource here because
// 1) anyhow::Result isn't serializable/deserializable
// 2) we're not doing server-side rendering in this example anyway
// (during SSR, create_resource will begin loading on the server and resolve on the client)
let cats = create_local_resource(cx, cat_count, fetch_cats);
let fallback = move |cx, errors: RwSignal<Errors>| {
let error_list = move || {
errors.with(|errors| {
errors
.iter()
.map(|(_, e)| view! { cx, <li>{e.to_string()}</li>})
.collect::<Vec<_>>()
})
};
view! { cx,
<div class="error">
<h2>"Error"</h2>
<ul>{error_list}</ul>
</div>
}
};
// the renderer can handle Option<_> and Result<_> states
// by displaying nothing for None if the resource is still loading
// and by using the ErrorBoundary fallback to catch Err(_)
// so we'll just implement our happy path and let the framework handle the rest
let cats_view = move || {
cats.with(cx, |data| {
data.iter()
.flatten()
.map(|cat| view! { cx, <img src={cat}/> })
.collect::<Vec<_>>()
})
};
view! { cx,
<div>
<label>
"How many cats would you like?"
@@ -43,25 +80,11 @@ pub fn fetch_example(cx: Scope) -> impl IntoView {
}
/>
</label>
<Transition fallback=move || view! { cx, <div>"Loading (Suspense Fallback)..."</div>}>
{move || {
cats.read().map(|data| match data {
Err(_) => view! { cx, <pre>"Error"</pre> }.into_view(cx),
Ok(cats) => view! { cx,
<div>{
cats.iter()
.map(|src| {
view! { cx,
<img src={src}/>
}
})
.collect::<Vec<_>>()
}</div>
}.into_view(cx),
})
}
}
</Transition>
<ErrorBoundary fallback>
<Transition fallback=move || view! { cx, <div>"Loading (Suspense Fallback)..."</div>}>
{cats_view}
</Transition>
</ErrorBoundary>
</div>
}
}

View File

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

View File

@@ -38,7 +38,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
let (pending, set_pending) = create_signal(cx, false);
let hide_more_link =
move || pending() || stories.read().unwrap_or(None).unwrap_or_default().len() < 28;
move || pending() || stories.read(cx).unwrap_or(None).unwrap_or_default().len() < 28;
view! {
cx,
@@ -82,7 +82,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
fallback=move || view! { cx, <p>"Loading..."</p> }
set_pending=set_pending.into()
>
{move || match stories.read() {
{move || match stories.read(cx) {
None => None,
Some(None) => Some(view! { cx, <p>"Error loading stories."</p> }.into_any()),
Some(Some(stories)) => {
@@ -91,7 +91,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
<For
each=move || stories.clone()
key=|story| story.id
view=move |story: api::Story| {
view=move |cx, story: api::Story| {
view! { cx,
<Story story/>
}

View File

@@ -17,13 +17,13 @@ pub fn Story(cx: Scope) -> impl IntoView {
}
},
);
let meta_description = move || story.read().and_then(|story| story.map(|story| story.title)).unwrap_or_else(|| "Loading story...".to_string());
let meta_description = move || story.read(cx).and_then(|story| story.map(|story| story.title)).unwrap_or_else(|| "Loading story...".to_string());
view! { cx,
<>
<Meta name="description" content=meta_description/>
<Suspense fallback=|| view! { cx, "Loading..." }>
{move || story.read().map(|story| match story {
{move || story.read(cx).map(|story| match story {
None => view! { cx, <div class="item-view">"Error loading this story."</div> },
Some(story) => view! { cx,
<div class="item-view">
@@ -53,7 +53,7 @@ pub fn Story(cx: Scope) -> impl IntoView {
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
view=move |comment| view! { cx, <Comment comment /> }
view=move |cx, comment| view! { cx, <Comment comment /> }
/>
</ul>
</div>
@@ -98,7 +98,7 @@ pub fn Comment(cx: Scope, comment: api::Comment) -> impl IntoView {
<For
each=move || comments.clone()
key=|comment| comment.id
view=move |comment: api::Comment| view! { cx, <Comment comment /> }
view=move |cx, comment: api::Comment| view! { cx, <Comment comment /> }
/>
</ul>
}

View File

@@ -19,7 +19,7 @@ pub fn User(cx: Scope) -> impl IntoView {
view! { cx,
<div class="user-view">
<Suspense fallback=|| view! { cx, "Loading..." }>
{move || user.read().map(|user| match user {
{move || user.read(cx).map(|user| match user {
None => view! { cx, <h1>"User not found."</h1> }.into_any(),
Some(user) => view! { cx,
<div>

View File

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

@@ -2,7 +2,7 @@
<html>
<head>
<link data-trunk rel="rust" data-wasm-opt="z"/>
<link data-trunk rel="css" href="./static/style.css"/>
<link data-trunk rel="css" href="/style.css"/>
</head>
<body></body>
</html>

View File

@@ -1,5 +1,7 @@
use leptos::Errors;
use leptos::{view, For, ForProps, IntoView, RwSignal, Scope, View};
use leptos::{
signal_prelude::*, view, Errors, For, ForProps, IntoView, RwSignal, Scope,
View,
};
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
// here than just displaying them
@@ -7,21 +9,22 @@ pub fn error_template(cx: Scope, errors: Option<RwSignal<Errors>>) -> View {
let Some(errors) = errors else {
panic!("No Errors found and we expected errors!");
};
view! {cx,
<h1>"Errors"</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each= move || {errors.get().0.into_iter()}
// a unique key for each item as a reference
key=|error| error.0.clone()
// renders each item to a view
view= move |error| {
let error_string = error.1.to_string();
view! {
cx,
<p>"Error: " {error_string}</p>
<h1>"Errors"</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each=errors
// a unique key for each item as a reference
key=|(key, _)| key.clone()
// renders each item to a view
view= move |cx, (_, error)| {
let error_string = error.to_string();
view! {
cx,
<p>"Error: " {error_string}</p>
}
}
}
/>
}
.into_view(cx)

View File

@@ -38,7 +38,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
let (pending, set_pending) = create_signal(cx, false);
let hide_more_link =
move || pending() || stories.read().unwrap_or(None).unwrap_or_default().len() < 28;
move || pending() || stories.read(cx).unwrap_or(None).unwrap_or_default().len() < 28;
view! {
cx,
@@ -82,7 +82,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
fallback=move || view! { cx, <p>"Loading..."</p> }
set_pending=set_pending.into()
>
{move || match stories.read() {
{move || match stories.read(cx) {
None => None,
Some(None) => Some(view! { cx, <p>"Error loading stories."</p> }.into_any()),
Some(Some(stories)) => {
@@ -91,7 +91,7 @@ pub fn Stories(cx: Scope) -> impl IntoView {
<For
each=move || stories.clone()
key=|story| story.id
view=move |story: api::Story| {
view=move |cx, story: api::Story| {
view! { cx,
<Story story/>
}

View File

@@ -17,13 +17,13 @@ pub fn Story(cx: Scope) -> impl IntoView {
}
},
);
let meta_description = move || story.read().and_then(|story| story.map(|story| story.title)).unwrap_or_else(|| "Loading story...".to_string());
let meta_description = move || story.read(cx).and_then(|story| story.map(|story| story.title)).unwrap_or_else(|| "Loading story...".to_string());
view! { cx,
<>
<Meta name="description" content=meta_description/>
<Suspense fallback=|| view! { cx, "Loading..." }>
{move || story.read().map(|story| match story {
{move || story.read(cx).map(|story| match story {
None => view! { cx, <div class="item-view">"Error loading this story."</div> },
Some(story) => view! { cx,
<div class="item-view">
@@ -53,7 +53,7 @@ pub fn Story(cx: Scope) -> impl IntoView {
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
view=move |comment| view! { cx, <Comment comment /> }
view=move |cx, comment| view! { cx, <Comment comment /> }
/>
</ul>
</div>
@@ -98,7 +98,7 @@ pub fn Comment(cx: Scope, comment: api::Comment) -> impl IntoView {
<For
each=move || comments.clone()
key=|comment| comment.id
view=move |comment: api::Comment| view! { cx, <Comment comment /> }
view=move |cx, comment: api::Comment| view! { cx, <Comment comment /> }
/>
</ul>
}

View File

@@ -19,7 +19,7 @@ pub fn User(cx: Scope) -> impl IntoView {
view! { cx,
<div class="user-view">
<Suspense fallback=|| view! { cx, "Loading..." }>
{move || user.read().map(|user| match user {
{move || user.read(cx).map(|user| match user {
None => view! { cx, <h1>"User not found."</h1> }.into_any(),
Some(user) => view! { cx,
<div>

View File

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

View File

@@ -7,7 +7,8 @@ use web_sys::MouseEvent;
// for the child component to write into and the parent to read
// 2) <ButtonB/>: passing a closure as one of the child component props, for
// the child component to call
// 4) <ButtonC/>: providing a context that is used in the component (rather than prop drilling)
// 3) <ButtonC/>: adding an `on:` event listener to a component
// 4) <ButtonD/>: providing a context that is used in the component (rather than prop drilling)
#[derive(Copy, Clone)]
struct SmallcapsContext(WriteSignal<bool>);
@@ -17,6 +18,7 @@ pub fn App(cx: Scope) -> impl IntoView {
// just some signals to toggle three classes on our <p>
let (red, set_red) = create_signal(cx, false);
let (right, set_right) = create_signal(cx, false);
let (italics, set_italics) = create_signal(cx, false);
let (smallcaps, set_smallcaps) = create_signal(cx, false);
// the newtype pattern isn't *necessary* here but is a good practice
@@ -31,6 +33,7 @@ pub fn App(cx: Scope) -> impl IntoView {
// class: attributes take F: Fn() => bool, and these signals all implement Fn()
class:red=red
class:right=right
class:italics=italics
class:smallcaps=smallcaps
>
"Lorem ipsum sit dolor amet."
@@ -42,8 +45,13 @@ pub fn App(cx: Scope) -> impl IntoView {
// Button B: pass a closure
<ButtonB on_click=move |_| set_right.update(|value| *value = !*value)/>
// Button B: use a regular event listener
// setting an event listener on a component like this applies it
// to each of the top-level elements the component returns
<ButtonC on:click=move |_| set_italics.update(|value| *value = !*value)/>
// Button D gets its setter from context rather than props
<ButtonC/>
<ButtonD/>
</main>
}
}
@@ -53,7 +61,7 @@ pub fn App(cx: Scope) -> impl IntoView {
pub fn ButtonA(
cx: Scope,
/// Signal that will be toggled when the button is clicked.
setter: WriteSignal<bool>
setter: WriteSignal<bool>,
) -> impl IntoView {
view! {
cx,
@@ -70,7 +78,7 @@ pub fn ButtonA(
pub fn ButtonB<F>(
cx: Scope,
/// Callback that will be invoked when the button is clicked.
on_click: F
on_click: F,
) -> impl IntoView
where
F: Fn(MouseEvent) + 'static,
@@ -97,10 +105,22 @@ where
// if Rust ever had named function arguments we could drop this requirement
}
/// Button C is a dummy: it renders a button but doesn't handle
/// its click. Instead, the parent component adds an event listener.
#[component]
pub fn ButtonC(cx: Scope) -> impl IntoView {
view! {
cx,
<button>
"Toggle Italics"
</button>
}
}
/// Button D is very similar to Button A, but instead of passing the setter as a prop
/// we get it from the context
#[component]
pub fn ButtonC(cx: Scope) -> impl IntoView {
pub fn ButtonD(cx: Scope) -> impl IntoView {
let setter = use_context::<SmallcapsContext>(cx).unwrap().0;
view! {

View File

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

View File

@@ -1,14 +1,18 @@
mod api;
use crate::api::*;
use leptos::*;
use leptos_router::*;
use crate::api::{get_contact, get_contacts};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct ExampleContext(i32);
#[component]
pub fn RouterExample(cx: Scope) -> impl IntoView {
log::debug!("rendering <RouterExample/>");
// contexts are passed down through the route tree
provide_context(cx, ExampleContext(0));
view! { cx,
<Router>
<nav>
@@ -59,10 +63,18 @@ pub fn RouterExample(cx: Scope) -> impl IntoView {
pub fn ContactList(cx: Scope) -> impl IntoView {
log::debug!("rendering <ContactList/>");
// contexts are passed down through the route tree
provide_context(cx, ExampleContext(42));
on_cleanup(cx, || {
log!("cleaning up <ContactList/>");
});
let location = use_location(cx);
let contacts = create_resource(cx, move || location.search.get(), get_contacts);
let contacts =
create_resource(cx, move || location.search.get(), get_contacts);
let contacts = move || {
contacts.read().map(|contacts| {
contacts.read(cx).map(|contacts| {
// this data doesn't change frequently so we can use .map().collect() instead of a keyed <For/>
contacts
.into_iter()
@@ -86,21 +98,28 @@ pub fn ContactList(cx: Scope) -> impl IntoView {
}
}
#[derive(Params, PartialEq, Clone, Debug)]
pub struct ContactParams {
id: usize,
}
#[component]
pub fn Contact(cx: Scope) -> impl IntoView {
log::debug!("rendering <Contact/>");
let params = use_params_map(cx);
log::debug!(
"ExampleContext should be Some(42). It is {:?}",
use_context::<ExampleContext>(cx)
);
on_cleanup(cx, || {
log!("cleaning up <Contact/>");
});
let params = use_params::<ContactParams>(cx);
let contact = create_resource(
cx,
move || {
params()
.get("id")
.cloned()
.unwrap_or_default()
.parse::<usize>()
.ok()
},
move || params().map(|params| params.id).ok(),
// any of the following would work (they're identical)
// move |id| async move { get_contact(id).await }
// move |id| get_contact(id),
@@ -108,12 +127,15 @@ pub fn Contact(cx: Scope) -> impl IntoView {
get_contact,
);
let contact_display = move || match contact.read() {
let contact_display = move || match contact.read(cx) {
// None => loading, but will be caught by Suspense fallback
// I'm only doing this explicitly for the example
None => None,
// Some(None) => has loaded and found no contact
Some(None) => Some(view! { cx, <p>"No contact with this ID was found."</p> }.into_any()),
Some(None) => Some(
view! { cx, <p>"No contact with this ID was found."</p> }
.into_any(),
),
// Some(Some) => has loaded and found a contact
Some(Some(contact)) => Some(
view! { cx,
@@ -138,6 +160,16 @@ pub fn Contact(cx: Scope) -> impl IntoView {
#[component]
pub fn About(cx: Scope) -> impl IntoView {
log::debug!("rendering <About/>");
on_cleanup(cx, || {
log!("cleaning up <About/>");
});
log::debug!(
"ExampleContext should be Some(0). It is {:?}",
use_context::<ExampleContext>(cx)
);
// use_navigate allows you to navigate programmatically by calling a function
let navigate = use_navigate(cx);
@@ -159,6 +191,11 @@ pub fn About(cx: Scope) -> impl IntoView {
#[component]
pub fn Settings(cx: Scope) -> impl IntoView {
log::debug!("rendering <Settings/>");
on_cleanup(cx, || {
log!("cleaning up <Settings/>");
});
view! { cx,
<>
<h1>"Settings"</h1>

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

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
#[cfg(feature = "ssr")]
#[actix_web::main]
async fn main() -> std::io::Result<()> {
use actix_files::Files;
use actix_web::*;
use leptos::*;
use leptos_actix::{generate_route_list, LeptosRoutes};
use ssr_modes::app::*;
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(|cx| view! { cx, <App/> });
GetPost::register();
ListPostMetadata::register();
HttpServer::new(move || {
let leptos_options = &conf.leptos_options;
let site_root = &leptos_options.site_root;
App::new()
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.leptos_routes(
leptos_options.to_owned(),
routes.to_owned(),
|cx| view! { cx, <App/> },
)
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
.run()
.await
}
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for pure client-side testing
// see lib.rs for hydration function instead
}

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Cargo Leptos</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- INJECT HEAD -->
</head>
<body>
<!-- INJECT BODY -->
</body>
</html>

View File

@@ -16,12 +16,10 @@ console_error_panic_hook = "0.1.7"
serde = { version = "1.0.152", features = ["derive"] }
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos = { path = "../../leptos", features = ["serde"] }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_meta = { path = "../../meta", default-features = false }
leptos_router = { path = "../../router", default-features = false }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
log = "0.4.17"
simple_logger = "4.0.0"
gloo = { git = "https://github.com/rustwasm/gloo" }
@@ -29,6 +27,7 @@ sqlx = { version = "0.6.2", features = [
"runtime-tokio-rustls",
"sqlite",
], optional = true }
wasm-bindgen = "0.2"
[features]
default = ["ssr"]

View File

@@ -140,7 +140,7 @@ pub fn Todos(cx: Scope) -> impl IntoView {
{move || {
let existing_todos = {
move || {
todos.read()
todos.read(cx)
.map(move |todos| match todos {
Err(e) => {
vec![view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_any()]

View File

@@ -12,13 +12,11 @@ console_log = "0.2.0"
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../leptos", default-features = false, features = [
"serde",
] }
leptos_axum = { path = "../../integrations/axum", default-features = false, optional = true }
leptos_meta = { path = "../../meta", default-features = false }
leptos_router = { path = "../../router", default-features = false }
leptos_reactive = { path = "../../leptos_reactive", default-features = false }
leptos = { path = "../../leptos", features = ["serde"] }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
leptos_reactive = { path = "../../leptos_reactive" }
log = "0.4.17"
simple_logger = "4.0.0"
serde = { version = "1.0.148", features = ["derive"] }
@@ -36,22 +34,26 @@ sqlx = { version = "0.6.2", features = [
], optional = true }
thiserror = "1.0.38"
tracing = "0.1.37"
wasm-bindgen = "0.2"
[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:sqlx", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "dep:leptos_axum"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"dep:sqlx",
"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",
]
denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
[package.metadata.leptos]

View File

@@ -1,8 +1,6 @@
use crate::errors::TodoAppError;
use cfg_if::cfg_if;
use leptos::Errors;
use leptos::*;
use leptos::{Errors, *};
#[cfg(feature = "ssr")]
use leptos_axum::ResponseOptions;
@@ -23,14 +21,12 @@ pub fn ErrorTemplate(
};
// Get Errors from Signal
let errors = errors.get().0;
// Downcast lets us take a type that implements `std::error::Error`
let errors: Vec<TodoAppError> = errors
.get()
.into_iter()
.filter_map(|(_k, v)| v.downcast_ref::<TodoAppError>().cloned())
.filter_map(|(_, v)| v.downcast_ref::<TodoAppError>().cloned())
.collect();
println!("Errors: {errors:#?}");
// Only the response code for the first error is actually sent from the server
// this may be customized by the specific application
@@ -51,7 +47,7 @@ pub fn ErrorTemplate(
// a unique key for each item as a reference
key=|(index, _error)| *index
// renders each item to a view
view= move |error| {
view= move |cx, error| {
let error_string = error.1.to_string();
let error_code= error.1.status_code();
view! {

View File

@@ -159,7 +159,7 @@ pub fn Todos(cx: Scope) -> impl IntoView {
{move || {
let existing_todos = {
move || {
todos.read()
todos.read(cx)
.map(move |todos| match todos {
Err(e) => {
vec![view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_any()]

View File

@@ -0,0 +1,97 @@
[package]
name = "todo_app_sqlite_viz"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
anyhow = "1.0.66"
console_log = "0.2.0"
console_error_panic_hook = "0.1.7"
futures = "0.3.25"
cfg-if = "1.0.0"
leptos = { path = "../../leptos", features = ["serde"] }
leptos_viz = { path = "../../integrations/viz", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
leptos_reactive = { path = "../../leptos_reactive" }
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"] }
viz = { version = "0.4.8", features = ["serve"], optional = true }
tokio = { version = "1.25.0", features = ["full"], optional = true }
http = { version = "0.2.8" }
sqlx = { version = "0.6.2", features = [
"runtime-tokio-rustls",
"sqlite",
], optional = true }
thiserror = "1.0.38"
tracing = "0.1.37"
wasm-bindgen = "0.2"
[features]
default = ["csr"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:viz",
"dep:tokio",
"dep:sqlx",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_viz",
]
[package.metadata.cargo-all-features]
denylist = ["viz", "tokio", "sqlx", "leptos_viz"]
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 = "todo_app_sqlite_viz"
# 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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,9 @@
[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

@@ -0,0 +1,42 @@
# Leptos Todo App Sqlite with Viz
This example creates a basic todo app with a Viz backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server.
## 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

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS todos
(
id INTEGER NOT NULL PRIMARY KEY,
title VARCHAR,
completed BOOLEAN
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,61 @@
use crate::errors::TodoAppError;
use cfg_if::cfg_if;
use leptos::{Errors, *};
#[cfg(feature = "ssr")]
use leptos_viz::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(|(_k, 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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,58 @@
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::{
error_template::{ErrorTemplate, ErrorTemplateProps},
errors::TodoAppError,
};
use http::Uri;
use leptos::{view, Errors, LeptosOptions};
use std::sync::Arc;
use viz::{
handlers::serve, header::HeaderMap, types::RouteInfo, Body, Error, Handler,
Request, RequestExt, Response, ResponseExt, Result,
};
pub async fn file_and_error_handler(req: Request<Body>) -> Result<Response> {
let uri = req.uri().clone();
let headers = req.headers().clone();
let route_info = req.route_info().clone();
let options = &*req.state::<Arc<LeptosOptions>>().ok_or(
Error::Responder(Response::text("missing state type LeptosOptions")),
)?;
let root = &options.site_root;
let resp = get_static_file(uri, &root, headers, route_info).await?;
let status = resp.status();
if status.is_success() || status.is_redirection() {
Ok(resp)
} else {
let mut errors = Errors::default();
errors.insert_with_default_key(TodoAppError::NotFound);
let handler = leptos_viz::render_app_to_stream(
options.to_owned(),
move |cx| view! {cx, <ErrorTemplate outside_errors=errors.clone()/>},
);
handler(req).await
}
}
async fn get_static_file(
uri: Uri,
root: &str,
headers: HeaderMap,
route_info: Arc<RouteInfo>,
) -> Result<Response> {
let mut req = Request::builder()
.uri(uri.clone())
.extension(route_info)
.body(Body::empty())
.unwrap();
*req.headers_mut() = headers;
// This path is relative to the cargo root
serve::Dir::new(root).call(req).await
}
}
}

View File

@@ -0,0 +1,25 @@
use cfg_if::cfg_if;
use leptos::*;
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::*;
#[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

@@ -0,0 +1,80 @@
use cfg_if::cfg_if;
use leptos::*;
// boilerplate to run in different modes
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::fallback::file_and_error_handler;
use crate::todo::*;
use leptos_viz::{generate_route_list, LeptosRoutes};
use std::sync::Arc;
use todo_app_sqlite_viz::*;
use viz::{
types::{State, StateError},
Request, RequestExt, Response, Result, Router, ServiceMaker,
};
//Define a handler to test extractor with state
async fn custom_handler(req: Request) -> Result<Response> {
let id = req.params::<String>()?;
let options = &*req
.state::<Arc<LeptosOptions>>()
.ok_or(StateError::new::<LeptosOptions>())?;
let handler = leptos_viz::render_app_to_stream_with_context(
options.clone(),
move |cx| {
provide_context(cx, id.clone());
},
|cx| view! { cx, <TodoApp/> },
);
handler(req).await
}
#[tokio::main]
async fn main() {
simple_logger::init_with_level(log::Level::Debug)
.expect("couldn't initialize logging");
let conn = db().await.expect("couldn't connect to DB");
/* sqlx::migrate!()
.run(&mut conn)
.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()
.post("/api/:fn_name*", leptos_viz::handle_server_fns)
.get("/special/:id", custom_handler)
.leptos_routes(
leptos_options.clone(),
routes,
|cx| view! { cx, <TodoApp/> },
)
.get("/*", file_and_error_handler)
.with(State(Arc::new(leptos_options)));
// run our app with hyper
// `viz::Server` is a re-export of `hyper::Server`
log!("listening on http://{}", &addr);
viz::Server::bind(&addr)
.serve(ServiceMaker::from(app))
.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

@@ -0,0 +1,220 @@
use crate::error_template::{ErrorTemplate, ErrorTemplateProps};
use cfg_if::cfg_if;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
cfg_if! {
if #[cfg(feature = "ssr")] {
use sqlx::{Connection, SqliteConnection};
// use http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode};
pub async fn db() -> Result<SqliteConnection, ServerFnError> {
SqliteConnection::connect("sqlite:Todos.db").await.map_err(|e| ServerFnError::ServerError(e.to_string()))
}
pub fn register_server_functions() {
_ = GetTodos::register();
_ = AddTodo::register();
_ = DeleteTodo::register();
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
} else {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Todo {
id: u16,
title: String,
completed: bool,
}
}
}
#[server(GetTodos, "/api")]
pub async fn get_todos(cx: Scope) -> Result<Vec<Todo>, ServerFnError> {
// this is just an example of how to access server context injected in the handlers
// http::Request doesn't implement Clone, so more work will be needed to do use_context() on this
let req_parts = use_context::<leptos_viz::RequestParts>(cx);
if let Some(req_parts) = req_parts {
println!("Uri = {:?}", req_parts.uri);
}
use futures::TryStreamExt;
let mut conn = db().await?;
let mut todos = Vec::new();
let mut rows = sqlx::query_as::<_, Todo>("SELECT * FROM todos").fetch(&mut conn);
while let Some(row) = rows
.try_next()
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))?
{
todos.push(row);
}
// Add a random header(because why not)
// let mut res_headers = HeaderMap::new();
// res_headers.insert(SET_COOKIE, HeaderValue::from_str("fizz=buzz").unwrap());
// let res_parts = leptos_viz::ResponseParts {
// headers: res_headers,
// status: Some(StatusCode::IM_A_TEAPOT),
// };
// let res_options_outer = use_context::<leptos_viz::ResponseOptions>(cx);
// if let Some(res_options) = res_options_outer {
// res_options.overwrite(res_parts).await;
// }
Ok(todos)
}
#[server(AddTodo, "/api")]
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
let mut conn = db().await?;
// fake API delay
std::thread::sleep(std::time::Duration::from_millis(1250));
match sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)")
.bind(title)
.execute(&mut conn)
.await
{
Ok(_row) => Ok(()),
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
}
}
#[server(DeleteTodo, "/api")]
pub async fn delete_todo(id: u16) -> Result<(), ServerFnError> {
let mut conn = db().await?;
sqlx::query("DELETE FROM todos WHERE id = $1")
.bind(id)
.execute(&mut conn)
.await
.map(|_| ())
.map_err(|e| ServerFnError::ServerError(e.to_string()))
}
#[component]
pub fn TodoApp(cx: Scope) -> impl IntoView {
//let id = use_context::<String>(cx);
provide_meta_context(cx);
view! {
cx,
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Stylesheet id="leptos" href="/pkg/todo_app_sqlite_viz.css"/>
<Router>
<header>
<h1>"My Tasks"</h1>
</header>
<main>
<Routes>
<Route path="" view=|cx| view! {
cx,
<ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors=errors/>}>
<Todos/>
</ErrorBoundary>
}/> //Route
</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}
<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>
}
}

View File

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

View File

@@ -4,14 +4,14 @@ version = "0.1.0"
edition = "2021"
[dependencies]
leptos = { path = "../../leptos", default-features = false }
leptos = { path = "../../leptos", features = ["csr"] }
log = "0.4"
console_log = "0.2"
console_error_panic_hook = "0.1.7"
uuid = { version = "1", features = ["v4", "js", "serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
web-sys = { version = "0.3", features = ["Storage"] }
web-sys = { version = "0.3.60", features = ["Storage"] }
[dev-dependencies]
wasm-bindgen-test = "0.3.0"

View File

@@ -1,4 +1,4 @@
use leptos::{web_sys::HtmlInputElement, *};
use leptos::{html::Input, leptos_dom::helpers::location_hash, *};
use storage::TodoSerialized;
use uuid::Uuid;
@@ -12,12 +12,15 @@ const STORAGE_KEY: &str = "todos-leptos";
// Basic operations to manipulate the todo list: nothing really interesting here
impl Todos {
pub fn new(cx: Scope) -> Self {
let starting_todos = if let Ok(Some(storage)) = window().local_storage() {
let starting_todos = if let Ok(Some(storage)) = window().local_storage()
{
storage
.get_item(STORAGE_KEY)
.ok()
.flatten()
.and_then(|value| serde_json::from_str::<Vec<TodoSerialized>>(&value).ok())
.and_then(|value| {
serde_json::from_str::<Vec<TodoSerialized>>(&value).ok()
})
.map(|values| {
values
.into_iter()
@@ -89,7 +92,12 @@ impl Todo {
Self::new_with_completed(cx, id, title, false)
}
pub fn new_with_completed(cx: Scope, id: Uuid, title: String, completed: bool) -> Self {
pub fn new_with_completed(
cx: Scope,
id: Uuid,
title: String,
completed: bool,
) -> Self {
// RwSignal combines the getter and setter in one struct, rather than separating
// the getter from the setter. This makes it more convenient in some cases, such
// as when we're putting the signals into a struct and passing it around. There's
@@ -129,22 +137,24 @@ pub fn TodoMVC(cx: Scope) -> impl IntoView {
// Handle the three filter modes: All, Active, and Completed
let (mode, set_mode) = create_signal(cx, Mode::All);
window_event_listener("hashchange", move |_| {
let new_mode = location_hash().map(|hash| route(&hash)).unwrap_or_default();
let new_mode =
location_hash().map(|hash| route(&hash)).unwrap_or_default();
set_mode(new_mode);
});
// Callback to add a todo on pressing the `Enter` key, if the field isn't empty
let input_ref = NodeRef::<Input>::new(cx);
let add_todo = move |ev: web_sys::KeyboardEvent| {
let target = event_target::<HtmlInputElement>(&ev);
let input = input_ref.get().unwrap();
ev.stop_propagation();
let key_code = ev.key_code();
if key_code == ENTER_KEY {
let title = event_target_value(&ev);
let title = input.value();
let title = title.trim();
if !title.is_empty() {
let new = Todo::new(cx, Uuid::new_v4(), title.to_string());
set_todos.update(|t| t.add(new));
target.set_value("");
input.set_value("");
}
}
};
@@ -184,7 +194,8 @@ pub fn TodoMVC(cx: Scope) -> impl IntoView {
.iter()
.map(TodoSerialized::from)
.collect::<Vec<_>>();
let json = serde_json::to_string(&objs).expect("couldn't serialize Todos");
let json =
serde_json::to_string(&objs).expect("couldn't serialize Todos");
if storage.set_item(STORAGE_KEY, &json).is_err() {
log::error!("error while trying to set item in localStorage");
}
@@ -201,6 +212,7 @@ pub fn TodoMVC(cx: Scope) -> impl IntoView {
placeholder="What needs to be done?"
autofocus
on:keydown=add_todo
node_ref=input_ref
/>
</header>
<section
@@ -216,7 +228,7 @@ pub fn TodoMVC(cx: Scope) -> impl IntoView {
<For
each=filtered_todos
key=|todo| todo.id
view=move |todo: Todo| view! { cx, <Todo todo /> }
view=move |cx, todo: Todo| view! { cx, <Todo todo /> }
/>
</ul>
</section>
@@ -262,7 +274,7 @@ pub fn Todo(cx: Scope, todo: Todo) -> impl IntoView {
let set_todos = use_context::<WriteSignal<Todos>>(cx).unwrap();
// this will be filled by _ref=input below
let todo_input = NodeRef::<HtmlElement<Input>>::new(cx);
let todo_input = NodeRef::<Input>::new(cx);
let save = move |value: &str| {
let value = value.trim();

View File

@@ -1,27 +1,33 @@
use crate::Todo;
use leptos::Scope;
use serde::{Deserialize, Serialize};
use leptos::{
signal_prelude::*,
Scope,
};
use serde::{
Deserialize,
Serialize,
};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub struct TodoSerialized {
pub id: Uuid,
pub title: String,
pub completed: bool,
pub id: Uuid,
pub title: String,
pub completed: bool,
}
impl TodoSerialized {
pub fn into_todo(self, cx: Scope) -> Todo {
Todo::new_with_completed(cx, self.id, self.title, self.completed)
}
pub fn into_todo(self, cx: Scope) -> Todo {
Todo::new_with_completed(cx, self.id, self.title, self.completed)
}
}
impl From<&Todo> for TodoSerialized {
fn from(todo: &Todo) -> Self {
Self {
id: todo.id,
title: todo.title.get(),
completed: todo.completed.get(),
}
fn from(todo: &Todo) -> Self {
Self {
id: todo.id,
title: todo.title.get(),
completed: todo.completed.get(),
}
}
}

View File

@@ -13,5 +13,6 @@ futures = "0.3"
leptos = { workspace = true, features = ["ssr"] }
leptos_meta = { workspace = true, features = ["ssr"] }
leptos_router = { workspace = true, features = ["ssr"] }
leptos_integration_utils = { workspace = true }
parking_lot = "0.12.1"
regex = "1.7.0"

View File

@@ -13,9 +13,14 @@ use actix_web::{
web::Bytes,
*,
};
use futures::{Future, StreamExt};
use futures::{Future, Stream, StreamExt};
use http::StatusCode;
use leptos::*;
use leptos::{
leptos_dom::ssr::render_to_stream_with_prefix_undisposed_with_context,
leptos_server::{server_fn_by_path, Payload},
*,
};
use leptos_integration_utils::{build_async_response, html_parts};
use leptos_meta::*;
use leptos_router::*;
use parking_lot::RwLock;
@@ -32,11 +37,19 @@ pub struct ResponseParts {
impl ResponseParts {
/// Insert a header, overwriting any previous value with the same key
pub fn insert_header(&mut self, key: header::HeaderName, value: header::HeaderValue) {
pub fn insert_header(
&mut self,
key: header::HeaderName,
value: header::HeaderValue,
) {
self.headers.insert(key, value);
}
/// Append a header, leaving any header with the same key intact
pub fn append_header(&mut self, key: header::HeaderName, value: header::HeaderValue) {
pub fn append_header(
&mut self,
key: header::HeaderName,
value: header::HeaderValue,
) {
self.headers.append(key, value);
}
}
@@ -60,13 +73,21 @@ impl ResponseOptions {
res_parts.status = Some(status);
}
/// Insert a header, overwriting any previous value with the same key
pub fn insert_header(&self, key: header::HeaderName, value: header::HeaderValue) {
pub fn insert_header(
&self,
key: header::HeaderName,
value: header::HeaderValue,
) {
let mut writeable = self.0.write();
let res_parts = &mut *writeable;
res_parts.headers.insert(key, value);
}
/// Append a header, leaving any header with the same key intact
pub fn append_header(&self, key: header::HeaderName, value: header::HeaderValue) {
pub fn append_header(
&self,
key: header::HeaderName,
value: header::HeaderValue,
) {
let mut writeable = self.0.write();
let res_parts = &mut *writeable;
res_parts.headers.append(key, value);
@@ -77,12 +98,14 @@ impl ResponseOptions {
/// it sets a [StatusCode] of 302 and a [LOCATION](header::LOCATION) header with the provided value.
/// If looking to redirect from the client, `leptos_router::use_navigate()` should be used instead.
pub fn redirect(cx: leptos::Scope, path: &str) {
let response_options = use_context::<ResponseOptions>(cx).unwrap();
response_options.set_status(StatusCode::FOUND);
response_options.insert_header(
header::LOCATION,
header::HeaderValue::from_str(path).expect("Failed to create HeaderValue"),
);
if let Some(response_options) = use_context::<ResponseOptions>(cx) {
response_options.set_status(StatusCode::FOUND);
response_options.insert_header(
header::LOCATION,
header::HeaderValue::from_str(path)
.expect("Failed to create HeaderValue"),
);
}
}
/// An Actix [Route](actix_web::Route) that listens for a `POST` request with
@@ -173,7 +196,8 @@ pub fn handle_server_fns_with_context(
match server_fn(cx, body).await {
Ok(serialized) => {
let res_options = use_context::<ResponseOptions>(cx).unwrap();
let res_options =
use_context::<ResponseOptions>(cx).unwrap();
// clean up the scope, which we only needed to run the server fn
disposer.dispose();
@@ -183,7 +207,8 @@ pub fn handle_server_fns_with_context(
let mut res_parts = res_options.0.write();
if accept_header == Some("application/json")
|| accept_header == Some("application/x-www-form-urlencoded")
|| accept_header
== Some("application/x-www-form-urlencoded")
|| accept_header == Some("application/cbor")
{
res = HttpResponse::Ok();
@@ -221,7 +246,9 @@ pub fn handle_server_fns_with_context(
res.body(Bytes::from(data))
}
Payload::Url(data) => {
res.content_type("application/x-www-form-urlencoded");
res.content_type(
"application/x-www-form-urlencoded",
);
res.body(data)
}
Payload::Json(data) => {
@@ -230,13 +257,15 @@ pub fn handle_server_fns_with_context(
}
}
}
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
Err(e) => HttpResponse::InternalServerError()
.body(e.to_string()),
}
} else {
HttpResponse::BadRequest().body(format!(
"Could not find a server function at the route {:?}. \
\n\nIt's likely that you need to call ServerFn::register() on the \
server function type, somewhere in your `main` function.",
\n\nIt's likely that you need to call \
ServerFn::register() on the server function type, \
somewhere in your `main` function.",
req.path()
))
}
@@ -246,23 +275,25 @@ pub fn handle_server_fns_with_context(
}
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
/// to route it using [leptos_router], serving an HTML stream of your application.
/// to route it using [leptos_router], serving an HTML stream of your application. The stream
/// will include fallback content for any `<Suspense/>` nodes, and be immediately interactive,
/// but requires some client-side JavaScript.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_stream], and includes everything described in
/// the documentation for that function.
/// The HTML stream is rendered using [render_to_stream](leptos::ssr::render_to_stream), and
/// includes everything described in the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
/// use actix_web::{HttpServer, App};
/// use actix_web::{App, HttpServer};
/// use leptos::*;
/// use std::{env,net::SocketAddr};
/// use std::{env, net::SocketAddr};
///
/// #[component]
/// fn MyApp(cx: Scope) -> impl IntoView {
/// view! { cx, <main>"Hello, world!"</main> }
/// view! { cx, <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
@@ -272,11 +303,17 @@ pub fn handle_server_fns_with_context(
/// let addr = conf.leptos_options.site_addr.clone();
/// HttpServer::new(move || {
/// let leptos_options = &conf.leptos_options;
///
///
/// App::new()
/// // {tail:.*} passes the remainder of the URL as the route
/// // the actual routing will be handled by `leptos_router`
/// .route("/{tail:.*}", leptos_actix::render_app_to_stream(leptos_options.to_owned(), |cx| view! { cx, <MyApp/> }))
/// .route(
/// "/{tail:.*}",
/// leptos_actix::render_app_to_stream(
/// leptos_options.to_owned(),
/// |cx| view! { cx, <MyApp/> },
/// ),
/// )
/// })
/// .bind(&addr)?
/// .run()
@@ -301,6 +338,133 @@ where
render_app_to_stream_with_context(options, |_cx| {}, app_fn)
}
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
/// to route it using [leptos_router], serving an in-order HTML stream of your application.
/// This stream will pause at each `<Suspense/>` node and wait for it to resolve befores
/// sending down its HTML. The app will become interactive once it has fully loaded.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_stream_in_order], and includes everything described in
/// the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
/// use actix_web::{App, HttpServer};
/// use leptos::*;
/// use std::{env, net::SocketAddr};
///
/// #[component]
/// fn MyApp(cx: Scope) -> impl IntoView {
/// view! { cx, <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
/// let addr = conf.leptos_options.site_addr.clone();
/// HttpServer::new(move || {
/// let leptos_options = &conf.leptos_options;
///
/// App::new()
/// // {tail:.*} passes the remainder of the URL as the route
/// // the actual routing will be handled by `leptos_router`
/// .route(
/// "/{tail:.*}",
/// leptos_actix::render_app_to_stream_in_order(
/// leptos_options.to_owned(),
/// |cx| view! { cx, <MyApp/> },
/// ),
/// )
/// })
/// .bind(&addr)?
/// .run()
/// .await
/// }
/// # }
/// ```
///
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
pub fn render_app_to_stream_in_order<IV>(
options: LeptosOptions,
app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static,
) -> Route
where
IV: IntoView,
{
render_app_to_stream_in_order_with_context(options, |_cx| {}, app_fn)
}
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
/// to route it using [leptos_router], asynchronously rendering an HTML page after all
/// `async` [Resource](leptos::Resource)s have loaded.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to the apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_string_async], and includes everything described in
/// the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
/// use actix_web::{App, HttpServer};
/// use leptos::*;
/// use std::{env, net::SocketAddr};
///
/// #[component]
/// fn MyApp(cx: Scope) -> impl IntoView {
/// view! { cx, <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
/// let addr = conf.leptos_options.site_addr.clone();
/// HttpServer::new(move || {
/// let leptos_options = &conf.leptos_options;
///
/// App::new()
/// // {tail:.*} passes the remainder of the URL as the route
/// // the actual routing will be handled by `leptos_router`
/// .route(
/// "/{tail:.*}",
/// leptos_actix::render_app_async(
/// leptos_options.to_owned(),
/// |cx| view! { cx, <MyApp/> },
/// ),
/// )
/// })
/// .bind(&addr)?
/// .run()
/// .await
/// }
/// # }
/// ```
///
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
pub fn render_app_async<IV>(
options: LeptosOptions,
app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static,
) -> Route
where
IV: IntoView,
{
render_app_async_with_context(options, |_cx| {}, app_fn)
}
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
/// to route it using [leptos_router], serving an HTML stream of your application.
///
@@ -342,25 +506,115 @@ where
})
}
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
/// to route it using [leptos_router], serving an in-order HTML stream of your application.
///
/// This function allows you to provide additional information to Leptos for your route.
/// It could be used to pass in Path Info, Connection Info, or anything your heart desires.
///
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
pub fn render_app_to_stream_in_order_with_context<IV>(
options: LeptosOptions,
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static,
) -> Route
where
IV: IntoView,
{
web::get().to(move |req: HttpRequest| {
let options = options.clone();
let app_fn = app_fn.clone();
let additional_context = additional_context.clone();
let res_options = ResponseOptions::default();
async move {
let app = {
let app_fn = app_fn.clone();
let res_options = res_options.clone();
move |cx| {
provide_contexts(cx, &req, res_options);
(app_fn)(cx).into_view(cx)
}
};
stream_app_in_order(&options, app, res_options, additional_context)
.await
}
})
}
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
/// to route it using [leptos_router], asynchronously serving the page once all `async`
/// [Resource](leptos::Resource)s have loaded.
///
/// This function allows you to provide additional information to Leptos for your route.
/// It could be used to pass in Path Info, Connection Info, or anything your heart desires.
///
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
pub fn render_app_async_with_context<IV>(
options: LeptosOptions,
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
app_fn: impl Fn(leptos::Scope) -> IV + Clone + 'static,
) -> Route
where
IV: IntoView,
{
web::get().to(move |req: HttpRequest| {
let options = options.clone();
let app_fn = app_fn.clone();
let additional_context = additional_context.clone();
let res_options = ResponseOptions::default();
async move {
let app = {
let app_fn = app_fn.clone();
let res_options = res_options.clone();
move |cx| {
provide_contexts(cx, &req, res_options);
(app_fn)(cx).into_view(cx)
}
};
render_app_async_helper(
&options,
app,
res_options,
additional_context,
)
.await
}
})
}
/// Returns an Actix [Route](actix_web::Route) that listens for a `GET` request and tries
/// to route it using [leptos_router], serving an HTML stream of your application.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_stream], and includes everything described in
/// the documentation for that function.
/// The HTML stream is rendered using [render_to_stream](leptos::ssr::render_to_stream), and
/// includes everything described in the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
/// use actix_web::{HttpServer, App};
/// use actix_web::{App, HttpServer};
/// use leptos::*;
/// use std::{env,net::SocketAddr};
/// use leptos_actix::DataResponse;
/// use std::{env, net::SocketAddr};
///
/// #[component]
/// fn MyApp(cx: Scope, data: &'static str) -> impl IntoView {
/// view! { cx, <main>"Hello, world!"</main> }
/// view! { cx, <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
@@ -370,14 +624,21 @@ where
/// let addr = conf.leptos_options.site_addr.clone();
/// HttpServer::new(move || {
/// let leptos_options = &conf.leptos_options;
///
///
/// App::new()
/// // {tail:.*} passes the remainder of the URL as the route
/// // the actual routing will be handled by `leptos_router`
/// .route("/{tail:.*}", leptos_actix::render_preloaded_data_app(
/// leptos_options.to_owned(),
/// |req| async move { Ok(DataResponse::Data("async func that can preload data")) },
/// |cx, data| view! { cx, <MyApp data/> })
/// .route(
/// "/{tail:.*}",
/// leptos_actix::render_preloaded_data_app(
/// leptos_options.to_owned(),
/// |req| async move {
/// Ok(DataResponse::Data(
/// "async func that can preload data",
/// ))
/// },
/// |cx, data| view! { cx, <MyApp data/> },
/// ),
/// )
/// })
/// .bind(&addr)?
@@ -393,6 +654,9 @@ where
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
#[deprecated = "You can now use `render_app_async` with `create_resource` and \
`<Suspense/>` to achieve async rendering without manually \
preloading data."]
pub fn render_preloaded_data_app<Data, Fut, IV>(
options: LeptosOptions,
data_fn: impl Fn(HttpRequest) -> Fut + Clone + 'static,
@@ -430,7 +694,11 @@ where
})
}
fn provide_contexts(cx: leptos::Scope, req: &HttpRequest, res_options: ResponseOptions) {
fn provide_contexts(
cx: leptos::Scope,
req: &HttpRequest,
res_options: ResponseOptions,
) {
let path = leptos_corrected_path(req);
let integration = ServerIntegration { path };
@@ -457,25 +725,44 @@ async fn stream_app(
res_options: ResponseOptions,
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
) -> HttpResponse<BoxBody> {
let (stream, runtime, scope) = render_to_stream_with_prefix_undisposed_with_context(
app,
move |cx| {
let meta = use_context::<MetaContext>(cx);
let head = meta
.as_ref()
.map(|meta| meta.dehydrate())
.unwrap_or_default();
let body_meta = meta
.as_ref()
.and_then(|meta| meta.body.as_string())
.unwrap_or_default();
format!("{head}</head><body{body_meta}>").into()
},
additional_context,
);
let (stream, runtime, scope) =
render_to_stream_with_prefix_undisposed_with_context(
app,
move |cx| generate_head_metadata(cx).into(),
additional_context,
);
build_stream_response(options, res_options, stream, runtime, scope).await
}
async fn stream_app_in_order(
options: &LeptosOptions,
app: impl FnOnce(leptos::Scope) -> View + 'static,
res_options: ResponseOptions,
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
) -> HttpResponse<BoxBody> {
let (stream, runtime, scope) =
leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
app,
move |cx| {
generate_head_metadata(cx).into()
},
additional_context,
);
build_stream_response(options, res_options, stream, runtime, scope).await
}
async fn build_stream_response(
options: &LeptosOptions,
res_options: ResponseOptions,
stream: impl Stream<Item = String> + 'static,
runtime: RuntimeId,
scope: ScopeId,
) -> HttpResponse {
let cx = leptos::Scope { runtime, id: scope };
let (head, tail) = html_parts(options, use_context::<MetaContext>(cx).as_ref());
let (head, tail) =
html_parts(options, use_context::<MetaContext>(cx).as_ref());
let mut stream = Box::pin(
futures::stream::once(async move { head.clone() })
@@ -493,11 +780,13 @@ async fn stream_app(
let res_options = res_options.0.read();
let (status, mut headers) = (res_options.status, res_options.headers.clone());
let (status, mut headers) =
(res_options.status, res_options.headers.clone());
let status = status.unwrap_or_default();
let complete_stream =
futures::stream::iter([first_chunk.unwrap(), second_chunk.unwrap()]).chain(stream);
futures::stream::iter([first_chunk.unwrap(), second_chunk.unwrap()])
.chain(stream);
let mut res = HttpResponse::Ok()
.content_type("text/html")
.streaming(complete_stream);
@@ -514,71 +803,48 @@ async fn stream_app(
res
}
fn html_parts(options: &LeptosOptions, meta_context: Option<&MetaContext>) -> (String, String) {
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
let output_name = &options.output_name;
let mut wasm_output_name = output_name.clone();
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
wasm_output_name.push_str("_bg");
async fn render_app_async_helper(
options: &LeptosOptions,
app: impl FnOnce(leptos::Scope) -> View + 'static,
res_options: ResponseOptions,
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
) -> HttpResponse<BoxBody> {
let (stream, runtime, scope) =
leptos::ssr::render_to_stream_in_order_with_prefix_undisposed_with_context(
app,
move |_| "".into(),
additional_context,
);
let html = build_async_response(stream, options, runtime, scope).await;
let res_options = res_options.0.read();
let (status, mut headers) =
(res_options.status, res_options.headers.clone());
let status = status.unwrap_or_default();
let mut res = HttpResponse::Ok().content_type("text/html").body(html);
// Add headers manipulated in the response
for (key, value) in headers.drain() {
if let Some(key) = key {
res.headers_mut().append(key, value);
}
}
let site_ip = &options.site_addr.ip().to_string();
let reload_port = options.reload_port;
let pkg_path = &options.site_pkg_dir;
let leptos_autoreload = match std::env::var("LEPTOS_WATCH").is_ok() {
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);
if (msg.all) window.location.reload();
if (msg.css) {{
const link = document.querySelector("link#leptos");
if (link) {{
let href = link.getAttribute('href').split('?')[0];
let newHref = href + '?version=' + new Date().getMilliseconds();
link.setAttribute('href', newHref);
}} else {{
console.warn("Could not find link#leptos");
}}
}};
}};
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
}})()
</script>
"#
),
false => "".to_string(),
};
let html_metadata = meta_context
.and_then(|mc| mc.html.as_string())
.unwrap_or_default();
let head = format!(
r#"<!DOCTYPE html>
<html{html_metadata}>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="modulepreload" href="/{pkg_path}/{output_name}.js">
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin="">
<script type="module">import init, {{ hydrate }} from '/{pkg_path}/{output_name}.js'; init('/{pkg_path}/{wasm_output_name}.wasm').then(hydrate);</script>
{leptos_autoreload}
"#
);
let tail = "</body></html>".to_string();
(head, tail)
// Set status to what is returned in the function
let res_status = res.status_mut();
*res_status = status;
// Return the response
res
}
/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically
/// create routes in Actix's App without having to use wildcard matching or fallbacks. Takes in your root app Element
/// as an argument so it can walk you app tree. This version is tailored to generated Actix compatible paths.
pub fn generate_route_list<IV>(app_fn: impl FnOnce(leptos::Scope) -> IV + 'static) -> Vec<String>
pub fn generate_route_list<IV>(
app_fn: impl FnOnce(leptos::Scope) -> IV + 'static,
) -> Vec<(String, SsrMode)>
where
IV: IntoView + 'static,
{
@@ -586,12 +852,12 @@ where
// Empty strings screw with Actix pathing, they need to be "/"
routes = routes
.iter()
.map(|s| {
.into_iter()
.map(|(s, mode)| {
if s.is_empty() {
return "/".to_string();
return ("/".to_string(), mode);
}
s.to_string()
(s, mode)
})
.collect();
@@ -601,14 +867,14 @@ where
// Match `:some_word` but only capture `some_word` in the groups to replace with `{some_word}`
let capture_re = Regex::new(r":((?:[^.,/]+)+)[^/]?").unwrap();
let routes: Vec<String> = routes
.iter()
.map(|s| wildcard_re.replace_all(s, "{tail:.*}").to_string())
.map(|s| capture_re.replace_all(&s, "{$1}").to_string())
let routes: Vec<(String, SsrMode)> = routes
.into_iter()
.map(|(s, m)| (wildcard_re.replace_all(&s, "{tail:.*}").to_string(), m))
.map(|(s, m)| (capture_re.replace_all(&s, "{$1}").to_string(), m))
.collect();
if routes.is_empty() {
vec!["/".to_string()]
vec![("/".to_string(), Default::default())]
} else {
routes
}
@@ -619,18 +885,22 @@ pub enum DataResponse<T> {
Response(actix_web::dev::Response<BoxBody>),
}
/// This trait allows one to pass a list of routes and a render function to Axum's router, letting us avoid
/// This trait allows one to pass a list of routes and a render function to Actix's router, letting us avoid
/// having to use wildcards or manually define all routes in multiple places.
pub trait LeptosRoutes {
fn leptos_routes<IV>(
self,
options: LeptosOptions,
paths: Vec<String>,
paths: Vec<(String, SsrMode)>,
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
) -> Self
where
IV: IntoView + 'static;
#[deprecated = "You can now use `leptos_routes` and a `<Route \
mode=SsrMode::Async/>`
to achieve async rendering without manually preloading \
data."]
fn leptos_preloaded_data_routes<Data, Fut, IV>(
self,
options: LeptosOptions,
@@ -646,7 +916,7 @@ pub trait LeptosRoutes {
fn leptos_routes_with_context<IV>(
self,
options: LeptosOptions,
paths: Vec<String>,
paths: Vec<(String, SsrMode)>,
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
) -> Self
@@ -658,22 +928,23 @@ pub trait LeptosRoutes {
/// to those paths to Leptos's renderer.
impl<T> LeptosRoutes for actix_web::App<T>
where
T: ServiceFactory<ServiceRequest, Config = (), Error = Error, InitError = ()>,
T: ServiceFactory<
ServiceRequest,
Config = (),
Error = Error,
InitError = (),
>,
{
fn leptos_routes<IV>(
self,
options: LeptosOptions,
paths: Vec<String>,
paths: Vec<(String, SsrMode)>,
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
) -> Self
where
IV: IntoView + 'static,
{
let mut router = self;
for path in paths.iter() {
router = router.route(path, render_app_to_stream(options.clone(), app_fn.clone()));
}
router
self.leptos_routes_with_context(options, paths, |_| {}, app_fn)
}
fn leptos_preloaded_data_routes<Data, Fut, IV>(
@@ -693,7 +964,12 @@ where
for path in paths.iter() {
router = router.route(
path,
render_preloaded_data_app(options.clone(), data_fn.clone(), app_fn.clone()),
#[allow(deprecated)]
render_preloaded_data_app(
options.clone(),
data_fn.clone(),
app_fn.clone(),
),
);
}
router
@@ -702,7 +978,7 @@ where
fn leptos_routes_with_context<IV>(
self,
options: LeptosOptions,
paths: Vec<String>,
paths: Vec<(String, SsrMode)>,
additional_context: impl Fn(leptos::Scope) + 'static + Clone + Send,
app_fn: impl Fn(leptos::Scope) -> IV + Clone + Send + 'static,
) -> Self
@@ -710,14 +986,28 @@ where
IV: IntoView + 'static,
{
let mut router = self;
for path in paths.iter() {
for (path, mode) in paths.iter() {
router = router.route(
path,
render_app_to_stream_with_context(
options.clone(),
additional_context.clone(),
app_fn.clone(),
),
match mode {
SsrMode::OutOfOrder => render_app_to_stream_with_context(
options.clone(),
additional_context.clone(),
app_fn.clone(),
),
SsrMode::InOrder => {
render_app_to_stream_in_order_with_context(
options.clone(),
additional_context.clone(),
app_fn.clone(),
)
}
SsrMode::Async => render_app_async_with_context(
options.clone(),
additional_context.clone(),
app_fn.clone(),
),
},
);
}
router

View File

@@ -16,5 +16,6 @@ leptos = { workspace = true, features = ["ssr"] }
leptos_meta = { workspace = true, features = ["ssr"] }
leptos_router = { workspace = true, features = ["ssr"] }
leptos_config = { workspace = true }
leptos_integration_utils = { workspace = true }
tokio = { version = "1", features = ["full"] }
parking_lot = "0.12.1"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
[package]
name = "leptos_integration_utils"
version = { workspace = true }
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Utilities to help build server integrations for the Leptos web framework."
[dependencies]
futures = "0.3"
leptos = { workspace = true, features = ["ssr"] }
leptos_meta = { workspace = true, features = ["ssr"] }
leptos_router = { workspace = true, features = ["ssr"] }
leptos_config = { workspace = true }

View File

@@ -0,0 +1,100 @@
use futures::{Stream, StreamExt};
use leptos::{use_context, RuntimeId, ScopeId};
use leptos_config::LeptosOptions;
use leptos_meta::MetaContext;
pub fn html_parts(
options: &LeptosOptions,
meta: Option<&MetaContext>,
) -> (String, &'static str) {
let pkg_path = &options.site_pkg_dir;
let output_name = &options.output_name;
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
let mut wasm_output_name = output_name.clone();
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
wasm_output_name.push_str("_bg");
}
let site_ip = &options.site_addr.ip().to_string();
let reload_port = options.reload_port;
let leptos_autoreload = match std::env::var("LEPTOS_WATCH").is_ok() {
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);
if (msg.all) window.location.reload();
if (msg.css) {{
let found = false;
document.querySelectorAll("link").forEach((link) => {{
if (link.getAttribute('href').includes(msg.css)) {{
let newHref = '/' + msg.css + '?version=' + new Date().getMilliseconds();
link.setAttribute('href', newHref);
found = true;
}}
}});
if (!found) console.warn(`CSS hot-reload: Could not find a <link href=/\"${{msg.css}}\"> element`);
}};
}};
ws.onclose = () => console.warn('Live-reload stopped. Manual reload necessary.');
}})()
</script>
"#
),
false => "".to_string(),
};
let html_metadata =
meta.and_then(|mc| mc.html.as_string()).unwrap_or_default();
let head = format!(
r#"<!DOCTYPE html>
<html{html_metadata}>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="modulepreload" href="/{pkg_path}/{output_name}.js">
<link rel="preload" href="/{pkg_path}/{wasm_output_name}.wasm" as="fetch" type="application/wasm" crossorigin="">
<script type="module">import init, {{ hydrate }} from '/{pkg_path}/{output_name}.js'; init('/{pkg_path}/{wasm_output_name}.wasm').then(hydrate);</script>
{leptos_autoreload}
"#
);
let tail = "</body></html>";
(head, tail)
}
pub async fn build_async_response(
stream: impl Stream<Item = String> + 'static,
options: &LeptosOptions,
runtime: RuntimeId,
scope: ScopeId,
) -> String {
let mut buf = String::new();
let mut stream = Box::pin(stream);
while let Some(chunk) = stream.next().await {
buf.push_str(&chunk);
}
let cx = leptos::Scope { runtime, id: scope };
let (head, tail) =
html_parts(options, use_context::<MetaContext>(cx).as_ref());
// in async, we load the meta content *now*, after the suspenses have resolved
let meta = use_context::<MetaContext>(cx);
let head_meta = meta
.as_ref()
.map(|meta| meta.dehydrate())
.unwrap_or_default();
let body_meta = meta
.as_ref()
.and_then(|meta| meta.body.as_string())
.unwrap_or_default();
runtime.dispose();
format!("{head}{head_meta}</head><body{body_meta}>{buf}{tail}")
}

View File

@@ -0,0 +1,21 @@
[package]
name = "leptos_viz"
version = { workspace = true }
edition = "2021"
authors = ["Greg Johnston", "Fangdun Tsai"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "Viz integrations for the Leptos web framework."
[dependencies]
viz = { version = "0.4.8" }
futures = "0.3"
http = "0.2.8"
hyper = "0.14.23"
leptos = { workspace = true, features = ["ssr"] }
leptos_meta = { workspace = true, features = ["ssr"] }
leptos_router = { workspace = true, features = ["ssr"] }
leptos_config = { workspace = true }
leptos_integration_utils = { workspace = true }
tokio = { version = "1", features = ["full"] }
parking_lot = "0.12.1"

1077
integrations/viz/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

View File

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

21
leptos/Makefile.toml Normal file
View File

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

View File

@@ -1,7 +1,9 @@
use crate::Children;
use leptos_dom::{Errors, IntoView};
use leptos_macro::component;
use leptos_reactive::{create_rw_signal, provide_context, RwSignal, Scope};
use leptos_macro::{component, view};
use leptos_reactive::{
create_rw_signal, provide_context, signal_prelude::*, RwSignal, Scope,
};
/// When you render a `Result<_, _>` in your view, in the `Err` case it will
/// render nothing, and search up through the view tree for an `<ErrorBoundary/>`.
@@ -45,8 +47,16 @@ where
// Run children so that they render and execute resources
let children = children(cx);
move || match errors.get().0.is_empty() {
true => children.clone().into_view(cx),
false => fallback(cx, errors).into_view(cx),
move || {
match errors.with(Errors::is_empty) {
true => children.clone().into_view(cx),
false => view! { cx,
<>
{fallback(cx, errors)}
<leptos-error-boundary style="display: none">{children.clone()}</leptos-error-boundary>
</>
}
.into_view(cx),
}
}
}

View File

@@ -30,7 +30,7 @@ use std::hash::Hash;
/// // a unique key for each item
/// key=|counter| counter.id
/// // renders each item to a view
/// view=move |counter: Counter| {
/// view=move |cx, counter: Counter| {
/// view! {
/// cx,
/// <button>"Value: " {move || counter.count.get()}</button>
@@ -54,7 +54,7 @@ pub fn For<IF, I, T, EF, N, KF, K>(
where
IF: Fn() -> I + 'static,
I: IntoIterator<Item = T>,
EF: Fn(T) -> N + 'static,
EF: Fn(Scope, T) -> N + 'static,
N: IntoView,
KF: Fn(&T) -> K + 'static,
K: Eq + Hash + 'static,

View File

@@ -34,6 +34,8 @@
//! communication via contexts, and the `<For/>` component for efficient keyed list updates.
//! - [`counters_stable`](https://github.com/leptos-rs/leptos/tree/main/examples/counters_stable) adapts the `counters` example
//! to show how to use Leptos with `stable` Rust.
//! - [`error_boundary`](https://github.com/leptos-rs/leptos/tree/main/examples/error_boundary) shows how to use
//! `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
@@ -56,8 +58,9 @@
//! 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
//! a fully-functional that works as intended even before WASM has loaded and begun to run.
//! - [`todo_app_sqlite`](https://github.com/leptos-rs/leptos/tree/main/examples/todo_app_sqlite) and
//! [`todo_app_sqlite_axum`](https://github.com/leptos-rs/leptos/tree/main/examples/todo_app_sqlite_axum)
//! - [`todo_app_sqlite`](https://github.com/leptos-rs/leptos/tree/main/examples/todo_app_sqlite),
//! [`todo_app_sqlite_axum`](https://github.com/leptos-rs/leptos/tree/main/examples/todo_app_sqlite_axum), and
//! [`todo_app_sqlite_viz`](https://github.com/leptos-rs/leptos/tree/main/examples/todo_app_sqlite_viz)
//! show how to build a full-stack app using server functions and database connections.
//! - [`tailwind`](https://github.com/leptos-rs/leptos/tree/main/examples/tailwind) shows how to integrate
//! TailwindCSS with `cargo-leptos`.
@@ -130,7 +133,7 @@
//!
//! #[component]
//! fn SimpleCounter(cx: Scope, initial_value: i32) -> impl IntoView {
//! todo!()
//! todo!()
//! }
//!
//! pub fn main() {
@@ -139,16 +142,32 @@
//! # }
//! ```
pub use leptos_config::*;
pub use leptos_dom;
pub use leptos_dom::wasm_bindgen::{JsCast, UnwrapThrowExt};
pub use leptos_dom::*;
pub use leptos_config::{self, get_configuration, LeptosOptions};
#[cfg(not(all(
target_arch = "wasm32",
any(feature = "csr", feature = "hydrate")
)))]
/// Utilities for server-side rendering HTML.
pub mod ssr {
pub use leptos_dom::{ssr::*, ssr_in_order::*};
}
pub use leptos_dom::{
self, create_node_ref, debug_warn, document, error, ev,
helpers::{
event_target, event_target_checked, event_target_value,
request_animation_frame, request_idle_callback, set_interval,
set_timeout, window_event_listener,
},
html, log, math, mount_to, mount_to_body, svg, warn, window, Attribute,
Class, Errors, Fragment, HtmlElement, IntoAttribute, IntoClass,
IntoProperty, IntoView, NodeRef, Property, View,
};
pub use leptos_macro::*;
pub use leptos_reactive::*;
pub use leptos_server;
pub use leptos_server::*;
pub use tracing;
pub use leptos_server::{
self, create_action, create_multi_action, create_server_action,
create_server_multi_action, Action, MultiAction, ServerFn, ServerFnError,
};
pub use typed_builder;
mod error_boundary;
pub use error_boundary::*;
@@ -159,10 +178,11 @@ pub use show::*;
mod suspense;
pub use suspense::*;
mod transition;
#[cfg(debug_assertions)]
#[doc(hidden)]
pub use tracing;
pub use transition::*;
pub use leptos_reactive::debug_warn;
extern crate self as leptos;
/// The most common type for the `children` property on components,
@@ -178,23 +198,20 @@ pub type ChildrenFn = Box<dyn Fn(Scope) -> Fragment>;
pub type ChildrenFnMut = Box<dyn FnMut(Scope) -> Fragment>;
/// A type for taking anything that implements [`IntoAttribute`].
/// Very usefull inside components.
///
/// ## Example
/// ```rust
/// use leptos::*;
///
/// #[component]
/// pub fn MyHeading(
/// cx: Scope,
/// text: String,
/// #[prop(optional, into)]
/// class: Option<AttributeValue>
/// cx: Scope,
/// text: String,
/// #[prop(optional, into)] class: Option<AttributeValue>,
/// ) -> impl IntoView {
/// view!{
/// cx,
/// <h1 class=class>{text}</h1>
/// }
/// view! {
/// cx,
/// <h1 class=class>{text}</h1>
/// }
/// }
/// ```
pub type AttributeValue = Box<dyn IntoAttribute>;

View File

@@ -1,6 +1,6 @@
use leptos::component;
use leptos_dom::{Fragment, IntoView};
use leptos_reactive::{create_memo, Scope};
use leptos_reactive::{create_memo, signal_prelude::*, Scope};
/// A component that will show its children when the `when` condition is `true`,
/// and show the fallback when it is `false`, without rerendering every time

View File

@@ -1,6 +1,5 @@
use cfg_if::cfg_if;
use leptos_dom::HydrationCtx;
use leptos_dom::{DynChild, Fragment, IntoView};
use leptos_dom::{DynChild, Fragment, HydrationCtx, IntoView};
use leptos_macro::component;
use leptos_reactive::{provide_context, Scope, SuspenseContext};
use std::rc::Rc;
@@ -29,7 +28,7 @@ use std::rc::Rc;
/// <div>
/// <Suspense fallback=move || view! { cx, <p>"Loading (Suspense Fallback)..."</p> }>
/// {move || {
/// cats.read().map(|data| match data {
/// cats.read(cx).map(|data| match data {
/// None => view! { cx, <pre>"Error"</pre> }.into_any(),
/// Some(cats) => view! { cx,
/// <div>{
@@ -63,8 +62,6 @@ where
F: Fn() -> E + 'static,
E: IntoView,
{
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
let id_before_suspense = HydrationCtx::peek();
let context = SuspenseContext::new(cx);
// provide this SuspenseContext to any resources below it
@@ -72,7 +69,8 @@ where
let orig_child = Rc::new(children);
let current_id = HydrationCtx::peek();
let before_me = HydrationCtx::peek();
let current_id = HydrationCtx::next_component();
let child = DynChild::new({
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
@@ -86,13 +84,16 @@ where
fallback().into_view(cx)
}
} else {
use leptos_reactive::signal_prelude::*;
// run the child; we'll probably throw this away, but it will register resource reads
let child = orig_child(cx).into_view(cx);
let after_original_child = HydrationCtx::id();
let initial = {
// no resources were read under this, so just return the child
if context.pending_resources.get() == 0 {
child.clone()
child
}
// show the fallback, but also prepare to stream HTML
else {
@@ -100,10 +101,11 @@ where
cx.register_suspense(
context,
&id_before_suspense.to_string(),
&current_id.to_string(),
// out-of-order streaming
{
let current_id = current_id.clone();
let orig_child = Rc::clone(&orig_child);
move || {
HydrationCtx::continue_from(current_id.clone());
DynChild::new(move || orig_child(cx))
@@ -111,6 +113,16 @@ where
.render_to_string(cx)
.to_string()
}
},
// in-order streaming
{
let current_id = current_id.clone();
move || {
HydrationCtx::continue_from(current_id.clone());
DynChild::new(move || orig_child(cx))
.into_view(cx)
.into_stream_chunks(cx)
}
}
);
@@ -119,8 +131,7 @@ where
}
};
HydrationCtx::continue_from(current_id.clone());
HydrationCtx::continue_from(after_original_child);
initial
}
}
@@ -132,5 +143,7 @@ where
_ => unreachable!(),
};
HydrationCtx::continue_from(before_me);
leptos_dom::View::Suspense(current_id, core_component)
}

View File

@@ -19,7 +19,9 @@ use std::{cell::RefCell, rc::Rc};
/// # use leptos::*;
/// # if false {
/// # run_scope(create_runtime(), |cx| {
/// async fn fetch_cats(how_many: u32) -> Option<Vec<String>> { Some(vec![]) }
/// async fn fetch_cats(how_many: u32) -> Option<Vec<String>> {
/// Some(vec![])
/// }
///
/// let (cat_count, set_cat_count) = create_signal::<u32>(cx, 1);
/// let (pending, set_pending) = create_signal(cx, false);
@@ -33,7 +35,7 @@ use std::{cell::RefCell, rc::Rc};
/// set_pending=set_pending.into()
/// >
/// {move || {
/// cats.read().map(|data| match data {
/// cats.read(cx).map(|data| match data {
/// None => view! { cx, <pre>"Error"</pre> }.into_any(),
/// Some(cats) => view! { cx,
/// <div>{
@@ -73,6 +75,14 @@ where
E: IntoView,
{
let prev_children = std::rc::Rc::new(RefCell::new(None::<Vec<View>>));
#[cfg(not(feature = "hydrate"))]
let first_run = std::cell::Cell::new(true);
// in hydration mode, "first" run is on the server
#[cfg(feature = "hydrate")]
let first_run = std::cell::Cell::new(false);
crate::Suspense(
cx,
crate::SuspenseProps::builder()
@@ -91,7 +101,12 @@ where
})
.children(Box::new(move |cx| {
let frag = children(cx);
*prev_children.borrow_mut() = Some(frag.nodes.clone());
if !first_run.get() {
*prev_children.borrow_mut() = Some(frag.nodes.clone());
}
first_run.set(false);
if let Some(set_pending) = &set_pending {
set_pending.set(false);
}

View File

@@ -16,7 +16,11 @@ fn simple_ssr_test() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<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>"
"<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>"
);
});
}
@@ -50,7 +54,21 @@ fn ssr_test_with_components() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<div id=\"_0-1\" class=\"counters\"><!--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><!--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><!--hk=_0-1-5-0c|leptos-counter-end--></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><!\
--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><!\
--hk=_0-1-5-0c|leptos-counter-end--></div>"
);
});
}
@@ -84,7 +102,22 @@ fn ssr_test_with_snake_case_components() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<div id=\"_0-1\" class=\"counters\"><!--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><!--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><!--hk=_0-1-5-0c|leptos-snake-case-counter-end--></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><!\
--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><!\
--hk=_0-1-5-0c|leptos-snake-case-counter-end--></div>"
);
});
}
@@ -103,7 +136,7 @@ fn test_classes() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<div id=\"_0-1\" class=\"my big red car\"></div>"
"<div class=\"my big red car\" id=\"_0-1\"></div>"
);
});
}
@@ -114,7 +147,7 @@ fn ssr_with_styles() {
use leptos::*;
_ = create_scope(create_runtime(), |cx| {
let (value, set_value) = create_signal(cx, 0);
let (_, set_value) = create_signal(cx, 0);
let styles = "myclass";
let rendered = view! {
cx, class = styles,
@@ -125,7 +158,8 @@ fn ssr_with_styles() {
assert_eq!(
rendered.into_view(cx).render_to_string(cx),
"<div id=\"_0-1\" class=\" myclass\"><button id=\"_0-2\" class=\"btn myclass\">-1</button></div>"
"<div class=\"myclass\" id=\"_0-1\"><button class=\"btn myclass\" \
id=\"_0-2\">-1</button></div>"
);
});
}
@@ -136,7 +170,7 @@ fn ssr_option() {
use leptos::*;
_ = create_scope(create_runtime(), |cx| {
let (value, set_value) = create_signal(cx, 0);
let (_, _) = create_signal(cx, 0);
let rendered = view! {
cx,
<option/>

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