mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-28 07:52:34 -05:00
Compare commits
180 Commits
v0.5.0-alp
...
islands
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8b559e51 | ||
|
|
db4ce00f98 | ||
|
|
9edcc5f43b | ||
|
|
ea2cda3d17 | ||
|
|
1d5252b97e | ||
|
|
d175249d00 | ||
|
|
35a9d84734 | ||
|
|
c8a5434c9b | ||
|
|
ac00ea4ffe | ||
|
|
59b708db9f | ||
|
|
722f6913d1 | ||
|
|
aabcfb7ac3 | ||
|
|
e691ae391f | ||
|
|
7c3fd1d67d | ||
|
|
88ee228389 | ||
|
|
02b0ac15a3 | ||
|
|
83edcfe29d | ||
|
|
b288b7d838 | ||
|
|
a1a78f165a | ||
|
|
1f3453e94b | ||
|
|
e8b35ba284 | ||
|
|
02275d4fc0 | ||
|
|
18e44be19a | ||
|
|
0bd4e5269a | ||
|
|
88405bfd10 | ||
|
|
80bc9a4e10 | ||
|
|
b4c3068ddd | ||
|
|
ac5406b8a6 | ||
|
|
8f38a8a5f5 | ||
|
|
bd7ff6a3fc | ||
|
|
c2d9696494 | ||
|
|
35893d4eb9 | ||
|
|
c3959d1de8 | ||
|
|
76c9d8233a | ||
|
|
2988fc1c36 | ||
|
|
e7662ae940 | ||
|
|
674fcd2ade | ||
|
|
5beab9f3cc | ||
|
|
49be4a219d | ||
|
|
f92aebdf01 | ||
|
|
6ba7a8e235 | ||
|
|
10e3106760 | ||
|
|
63dee1c93f | ||
|
|
fca4215fdb | ||
|
|
72cde5c355 | ||
|
|
0bf1f11638 | ||
|
|
2ea4531313 | ||
|
|
bd2acfc530 | ||
|
|
42ff663622 | ||
|
|
8ffd5c69ab | ||
|
|
90ce31edf8 | ||
|
|
e7160092f6 | ||
|
|
c6bd7a40e7 | ||
|
|
d544a678b1 | ||
|
|
4b96a71da5 | ||
|
|
6ab95d8a16 | ||
|
|
db51f6d2b4 | ||
|
|
964a26b5d3 | ||
|
|
1871d2bc6d | ||
|
|
fc82788bcc | ||
|
|
dba5c444ae | ||
|
|
95f285f126 | ||
|
|
5865ea4048 | ||
|
|
121c312ba3 | ||
|
|
19967d1dd5 | ||
|
|
42a1395ffd | ||
|
|
421715f923 | ||
|
|
0eba690993 | ||
|
|
2a971b5fd3 | ||
|
|
8701bd88bd | ||
|
|
8ccc731e91 | ||
|
|
b9bb999b6a | ||
|
|
d7080bff96 | ||
|
|
52eea6222b | ||
|
|
c1c469fd08 | ||
|
|
42d18d4b54 | ||
|
|
e69d55190e | ||
|
|
431f5398f9 | ||
|
|
3223182141 | ||
|
|
815acee19b | ||
|
|
270622ce86 | ||
|
|
de97e2dc12 | ||
|
|
3dd12bf416 | ||
|
|
59d3450d5b | ||
|
|
02d0849a34 | ||
|
|
41f8d66565 | ||
|
|
8da6710e44 | ||
|
|
38a68926ca | ||
|
|
895c8765ed | ||
|
|
ddd797f853 | ||
|
|
39dd204cda | ||
|
|
90041a9e99 | ||
|
|
d3a6d59970 | ||
|
|
314c803e4d | ||
|
|
f5028e200e | ||
|
|
fd15859ee4 | ||
|
|
d4ede63b3a | ||
|
|
b7c4a9d5c7 | ||
|
|
c780924c3d | ||
|
|
d2e8981e94 | ||
|
|
449a14bf16 | ||
|
|
5cc4c977ad | ||
|
|
67ee6d5dbf | ||
|
|
919ab91cb0 | ||
|
|
3f0c908479 | ||
|
|
a7cf566700 | ||
|
|
a30356da12 | ||
|
|
c612c2c0a3 | ||
|
|
370d8a951a | ||
|
|
b3d75125d4 | ||
|
|
b12b397f15 | ||
|
|
39d9b806ff | ||
|
|
02c4bb31bc | ||
|
|
52f0ce4b48 | ||
|
|
b8d30676f8 | ||
|
|
e7b52c0076 | ||
|
|
8cc3ece052 | ||
|
|
a19e83818d | ||
|
|
e92029d36c | ||
|
|
4a9d959488 | ||
|
|
9fd9d94783 | ||
|
|
241ca5e4dd | ||
|
|
39e81136c5 | ||
|
|
0ebf63f8af | ||
|
|
0b532735fc | ||
|
|
40998f62c3 | ||
|
|
4df6057393 | ||
|
|
18deb398ca | ||
|
|
d9abebb4be | ||
|
|
a480db8b77 | ||
|
|
1f26b68d45 | ||
|
|
937501c61b | ||
|
|
5523fb86fb | ||
|
|
7dcfcf8ca8 | ||
|
|
087c68569a | ||
|
|
6abfdd2345 | ||
|
|
cddd784e8d | ||
|
|
f6978217fb | ||
|
|
aa58cedc15 | ||
|
|
a0b0d72d19 | ||
|
|
fa8d0945e0 | ||
|
|
3ed49381e3 | ||
|
|
8ec3fb95f0 | ||
|
|
cc11430d16 | ||
|
|
0b650ee2dc | ||
|
|
4def35cb45 | ||
|
|
0e56f27e0d | ||
|
|
bd8983f462 | ||
|
|
7ef635d9cf | ||
|
|
19ea6fae6a | ||
|
|
651a111db9 | ||
|
|
3a98bdb3c2 | ||
|
|
f01b982cff | ||
|
|
69dd96f76f | ||
|
|
329ae08e60 | ||
|
|
1e13ad8fee | ||
|
|
e0c9a9523a | ||
|
|
0726a3034d | ||
|
|
a88d047eff | ||
|
|
4001561987 | ||
|
|
2f860b37bd | ||
|
|
b86009b9d0 | ||
|
|
54733e1b34 | ||
|
|
56f01888b7 | ||
|
|
8320f16716 | ||
|
|
0b16e5992d | ||
|
|
248beb4a55 | ||
|
|
c9f608d030 | ||
|
|
f837d3e6a2 | ||
|
|
8847d5fc42 | ||
|
|
7819a6fac0 | ||
|
|
c199185808 | ||
|
|
e0b5738606 | ||
|
|
f3e3880a57 | ||
|
|
d44b90c16d | ||
|
|
cc32a3e863 | ||
|
|
5740c9b76b | ||
|
|
80fa6ad3eb | ||
|
|
7bc1ad2b4f | ||
|
|
82a2fe7cbe |
37
.github/workflows/check-stable.yml
vendored
37
.github/workflows/check-stable.yml
vendored
@@ -11,8 +11,45 @@ env:
|
||||
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Detect Changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
source_changed: ${{ steps.set-source-changed.outputs.source_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get source files that changed
|
||||
id: changed-source
|
||||
uses: tj-actions/changed-files@v36
|
||||
with:
|
||||
files: |
|
||||
integrations
|
||||
leptos
|
||||
leptos_config
|
||||
leptos_dom
|
||||
leptos_hot_reload
|
||||
leptos_macro
|
||||
leptos_reactive
|
||||
leptos_server
|
||||
meta
|
||||
router
|
||||
server_fn
|
||||
server_fn_macro
|
||||
|
||||
- name: List source files that changed
|
||||
run: echo '${{ steps.changed-source.outputs.all_changed_files }}'
|
||||
|
||||
- name: Set source_changed
|
||||
id: set-source-changed
|
||||
run: |
|
||||
echo "source_changed=${{ steps.changed-source.outputs.any_changed }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test:
|
||||
name: Check examples ${{ matrix.os }} (using rustc ${{ matrix.rust }})
|
||||
needs: [setup]
|
||||
if: needs.setup.outputs.source_changed == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
5
.github/workflows/run-cargo-make-task.yml
vendored
5
.github/workflows/run-cargo-make-task.yml
vendored
@@ -84,6 +84,11 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install Chrome Webriver
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install chromium-chromedriver
|
||||
|
||||
# Run Cargo Make Task
|
||||
- name: ${{ inputs.cargo_make_task }}
|
||||
run: |
|
||||
|
||||
28
Cargo.toml
28
Cargo.toml
@@ -26,22 +26,22 @@ members = [
|
||||
exclude = ["benchmarks", "examples"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.0-alpha"
|
||||
version = "0.5.0-beta"
|
||||
|
||||
[workspace.dependencies]
|
||||
leptos = { path = "./leptos", version = "0.5.0-alpha" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.5.0-alpha" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.5.0-alpha" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.5.0-alpha" }
|
||||
leptos_reactive = { path = "./leptos_reactive", version = "0.5.0-alpha" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.5.0-alpha" }
|
||||
server_fn = { path = "./server_fn", version = "0.5.0-alpha" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.5.0-alpha" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.5.0-alpha" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.5.0-alpha" }
|
||||
leptos_router = { path = "./router", version = "0.5.0-alpha" }
|
||||
leptos_meta = { path = "./meta", version = "0.5.0-alpha" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.5.0-alpha" }
|
||||
leptos = { path = "./leptos", version = "0.5.0-beta" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.5.0-beta" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.5.0-beta" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.5.0-beta" }
|
||||
leptos_reactive = { path = "./leptos_reactive", version = "0.5.0-beta" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.5.0-beta" }
|
||||
server_fn = { path = "./server_fn", version = "0.5.0-beta" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.5.0-beta" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.5.0-beta" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.5.0-beta" }
|
||||
leptos_router = { path = "./router", version = "0.5.0-beta" }
|
||||
leptos_meta = { path = "./meta", version = "0.5.0-beta" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.5.0-beta" }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -88,8 +88,6 @@ targets = ["wasm32-unknown-unknown"]
|
||||
|
||||
The `nightly` feature enables the function call syntax for accessing and setting signals, as opposed to `.get()` and `.set()`. This leads to a consistent mental model in which accessing a reactive value of any kind (a signal, memo, or derived signal) is always represented as a function call. This is only possible with nightly Rust and the `nightly` feature.
|
||||
|
||||
> Note: The `nightly` feature is present on the main branch version right now, but not in 0.3.x. For 0.3.x, nightly is the default and `stable` has a special feature.
|
||||
|
||||
## `cargo-leptos`
|
||||
|
||||
[`cargo-leptos`](https://github.com/leptos-rs/cargo-leptos) is a build tool that's designed to make it easy to build apps that run on both the client and the server, with seamless integration. The best way to get started with a real Leptos project right now is to use `cargo-leptos` and our starter templates for [Actix](https://github.com/leptos-rs/start) or [Axum](https://github.com/leptos-rs/start-axum).
|
||||
|
||||
@@ -5,9 +5,9 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
l021 = { package = "leptos", version = "0.2.1" }
|
||||
leptos = { path = "../leptos", features = ["ssr"] }
|
||||
leptos = { path = "../leptos", features = ["ssr", "nightly", "islands"] }
|
||||
sycamore = { version = "0.8", features = ["ssr"] }
|
||||
yew = { git = "https://github.com/yewstack/yew", features = ["ssr"] }
|
||||
yew = { version = "0.20", features = ["ssr"] }
|
||||
tokio-test = "0.4"
|
||||
miniserde = "0.1"
|
||||
gloo = "0.8"
|
||||
|
||||
@@ -17,4 +17,4 @@ understand Leptos.
|
||||
|
||||
You can find more detailed docs for each part of the API at [Docs.rs](https://docs.rs/leptos/latest/leptos/).
|
||||
|
||||
**The guide is a work in progress.**
|
||||
> The source code for the book is available [here](https://github.com/leptos-rs/leptos/tree/main/docs/book). PRs for typos or clarification are always welcome.
|
||||
|
||||
@@ -26,7 +26,7 @@ cargo init leptos-tutorial
|
||||
cargo add leptos --features=csr,nightly
|
||||
```
|
||||
|
||||
Or you can leave off `nighly` if you're using stable Rust
|
||||
Or you can leave off `nightly` if you're using stable Rust
|
||||
|
||||
```bash
|
||||
cargo add leptos --features=csr
|
||||
|
||||
@@ -43,5 +43,6 @@
|
||||
- [Responses and Redirects](./server/27_response.md)
|
||||
- [Progressive Enhancement and Graceful Degradation](./progressive_enhancement/README.md)
|
||||
- [`<ActionForm/>`s](./progressive_enhancement/action_form.md)
|
||||
- [Deployment]()
|
||||
- [Deployment](./deployment.md)
|
||||
- [Appendix: How Does the Reactive System Work?](./appendix_reactive_graph.md)
|
||||
- [Appendix: Optimizing WASM Binary Size](./appendix_binary_size.md)
|
||||
|
||||
243
docs/book/src/appendix_reactive_graph.md
Normal file
243
docs/book/src/appendix_reactive_graph.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Appendix: How does the Reactive System Work?
|
||||
|
||||
You don’t need to know very much about how the reactive system actually works in order to use the library successfully. But it’s always useful to understand what’s going on behind the scenes once you start working with the framework at an advanced level.
|
||||
|
||||
The reactive primitives you use are divided into three sets:
|
||||
|
||||
- **Signals** (`ReadSignal`/`WriteSignal`, `RwSignal`, `Resource`, `Trigger`) Values you can actively change to trigger reactive updates.
|
||||
- **Computations** (`Memo`s) Values that depend on signals (or other computations) and derive a new reactive value through some pure computation.
|
||||
- **Effects** Observers that listen to changes in some signals or computations and run a function, causing some side effect.
|
||||
|
||||
Derived signals are a kind of non-primitve computation: as plain closures, they simply allow you to refactor some repeated signal-based computation into a reusable function that can be called in multiple places, but they are not represented in the reactive system itself.
|
||||
|
||||
All the other primitives actually exist in the reactive system as nodes in a reactive graph.
|
||||
|
||||
Most of the work of the reactive system consists of propagating changes from signals to effects, possibly through some intervening memos.
|
||||
|
||||
The assumption of the reactive system is that effects (like rendering to the DOM or making a network request) are orders of magnitude more expensive than things like updating a Rust data structure inside your app.
|
||||
|
||||
So the **primary goal** of the reactive system is to **run effects as infrequently as possible**.
|
||||
|
||||
Leptos does this through the construction of a reactive graph.
|
||||
|
||||
> Leptos’s current reactive system is based heavily on the [Reactively](https://github.com/modderme123/reactively) library for JavaScript. You can read Milo’s article “[Super-Charging Fine-Grained Reactivity](https://dev.to/modderme123/super-charging-fine-grained-reactive-performance-47ph)” for an excellent account of its algorithm, as well as fine-grained reactivity in general—including some beautiful diagrams!
|
||||
|
||||
## The Reactive Graph
|
||||
|
||||
Signals, memos, and effects all share three characteristics:
|
||||
|
||||
- **Value** They have a current value: either the signal’s value, or (for memos and effects) the value returned by the previous run, if any.
|
||||
- **Sources** Any other reactive primitives they depend on. (For signals, this is an empty set.)
|
||||
- **Subscribers** Any other reactive primitives that depend on them. (For effects, this is an empty set.)
|
||||
|
||||
In reality then, signals, memos, and effects are just conventional names for one generic concept of a “node” in a reactive graph. Signals are always “root nodes,” with no sources/parents. Effects are always “leaf nodes,” with no subscribers. Memos typically have both sources and subscribers.
|
||||
|
||||
### Simple Dependencies
|
||||
|
||||
So imagine the following code:
|
||||
|
||||
```rust
|
||||
// A
|
||||
let (name, set_name) = create_signal("Alice");
|
||||
|
||||
// B
|
||||
let name_upper = create_memo(move |_| name.with(|n| n.to_uppercase()));
|
||||
|
||||
// C
|
||||
create_effect(move |_| {
|
||||
log!("{}", name_upper());
|
||||
});
|
||||
|
||||
set_name("Bob");
|
||||
```
|
||||
|
||||
You can easily imagine the reactive graph here: `name` is the only signal/origin node, the `create_effect` is the only effect/terminal node, and there’s one intervening memo.
|
||||
|
||||
```
|
||||
A (name)
|
||||
|
|
||||
B (name_upper)
|
||||
|
|
||||
C (the effect)
|
||||
```
|
||||
|
||||
### Splitting Branches
|
||||
|
||||
Let’s make it a little more complex.
|
||||
|
||||
```rust
|
||||
// A
|
||||
let (name, set_name) = create_signal("Alice");
|
||||
|
||||
// B
|
||||
let name_upper = create_memo(move |_| name.with(|n| n.to_uppercase()));
|
||||
|
||||
// C
|
||||
let name_len = create_memo(move |_| name.len());
|
||||
|
||||
// D
|
||||
create_effect(move |_| {
|
||||
log!("len = {}", name_len());
|
||||
});
|
||||
|
||||
// E
|
||||
create_effect(move |_| {
|
||||
log!("name = {}", name_upper());
|
||||
});
|
||||
```
|
||||
|
||||
This is also pretty straightforward: a signal source signal (`name`/`A`) divides into two parallel tracks: `name_upper`/`B` and `name_len`/`C`, each of which has an effect that depends on it.
|
||||
|
||||
```
|
||||
__A__
|
||||
| |
|
||||
B C
|
||||
| |
|
||||
D E
|
||||
```
|
||||
|
||||
Now let’s update the signal.
|
||||
|
||||
```rust
|
||||
set_name("Bob");
|
||||
```
|
||||
|
||||
We immediately log
|
||||
|
||||
```
|
||||
len = 3
|
||||
name = BOB
|
||||
```
|
||||
|
||||
Let’s do it again.
|
||||
|
||||
```rust
|
||||
set_name("Tim");
|
||||
```
|
||||
|
||||
The log should shows
|
||||
|
||||
```
|
||||
name = TIM
|
||||
```
|
||||
|
||||
`len = 3` does not log again.
|
||||
|
||||
Remember: the goal of the reactive system is to run effects as infrequently as possible. Changing `name` from `"Bob"` to `"Tim"` will cause each of the memos to re-run. But they will only notify their subscribers if their value has actually changed. `"BOB"` and `"TIM"` are different, so that effect runs again. But both names have the length `3`, so they do not run again.
|
||||
|
||||
### Reuniting Branches
|
||||
|
||||
One more example, of what’s sometimes called **the diamond problem**.
|
||||
|
||||
```rust
|
||||
// A
|
||||
let (name, set_name) = create_signal("Alice");
|
||||
|
||||
// B
|
||||
let name_upper = create_memo(move |_| name.with(|n| n.to_uppercase()));
|
||||
|
||||
// C
|
||||
let name_len = create_memo(move |_| name.len());
|
||||
|
||||
// D
|
||||
create_effect(move |_| {
|
||||
log!("{} is {} characters long", name_upper(), name_len());
|
||||
});
|
||||
```
|
||||
|
||||
What does the graph look like for this?
|
||||
|
||||
```
|
||||
__A__
|
||||
| |
|
||||
B C
|
||||
| |
|
||||
|__D__|
|
||||
```
|
||||
|
||||
You can see why it's called the “diamond problem.” If I’d connected the nodes with straight lines instead of bad ASCII art, it would form a diamond: two memos, each of which depend on a signal, which feed into the same effect.
|
||||
|
||||
A naive, push-based reactive implementation would cause this effect to run twice, which would be bad. (Remember, our goal is to run effects as infrequently as we can.) For example, you could implement a reactive system such that signals and memos immediately propagate their changes all the way down the graph, through each dependency, essentially traversing the graph depth-first. In other words, updating `A` would notify `B`, which would notify `D`; then `A` would notify `C`, which would notify `D` again. This is both inefficient (`D` runs twice) and glitchy (`D` actually runs with the incorrect value for the second memo during its first run.)
|
||||
|
||||
## Solving the Diamond Problem
|
||||
|
||||
Any reactive implementation worth its salt is dedicated to solving this issue. There are a number of different approaches (again, [see Milo’s article](https://dev.to/modderme123/super-charging-fine-grained-reactive-performance-47ph) for an excellent overview).
|
||||
|
||||
Here’s how ours works, in brief.
|
||||
|
||||
A reactive node is always in one of three states:
|
||||
|
||||
- `Clean`: it is known not to have changed
|
||||
- `Check`: it is possible it has changed
|
||||
- `Dirty`: it has definitely changed
|
||||
|
||||
Updating a signal `Dirty` marks that signal `Dirty`, and marks all its descendants `Check`, recursively. Any of its descendants that are effects are added to a queue to be re-run.
|
||||
|
||||
```
|
||||
____A (DIRTY)___
|
||||
| |
|
||||
B (CHECK) C (CHECK)
|
||||
| |
|
||||
|____D (CHECK)__|
|
||||
```
|
||||
|
||||
Now those effects are run. (All of the effects will be marked `Check` at this point.) Before re-running its computation, the effect checks its parents to see if they are dirty. So
|
||||
|
||||
- So `D` goes to `B` and checks if it is `Dirty`.
|
||||
- But `B` is also marked `Check`. So `B` does the same thing:
|
||||
- `B` goes to `A`, and finds that it is `Dirty`.
|
||||
- This means `B` needs to re-run, because one of its sources has changed.
|
||||
- `B` re-runs, generating a new value, and marks itself `Clean`
|
||||
- Because `B` is a memo, it then checks its prior value against the new value.
|
||||
- If they are the same, `B` returns "no change." Otherwise, it returns "yes, I changed."
|
||||
- If `B` returned “yes, I changed,” `D` knows that it definitely needs to run and re-runs immediately before checking any other sources.
|
||||
- If `B` returned “no, I didn’t change,” `D` continues on to check `C` (see process above for `B`.)
|
||||
- If neither `B` nor `C` has changed, the effect does not need to re-run.
|
||||
- If either `B` or `C` did change, the effect now re-runs.
|
||||
|
||||
Because the effect is only marked `Check` once and only queued once, it only runs once.
|
||||
|
||||
If the naive version was a “push-based” reactive system, simply pushing reactive changes all the way down the graph and therefore running the effect twice, this version could be called “push-pull.” It pushes the `Check` status all the way down the graph, but then “pulls” its way back up. In fact, for large graphs it may end up bouncing back up and down and left and right on the graph as it tries to determine exactly which nodes need to re-run.
|
||||
|
||||
**Note this important trade-off**: Push-based reactivity propagates signal changes more quickly, at the expense of over-re-running memos and effects. Remember: the reactive system is designed to minimize how often you re-run effects, on the (accurate) assumption that side effects are orders of magnitude more expensive than this kind of cache-friendly graph traversal happening entirely inside the library’s Rust code. The measurement of a good reactive system is not how quickly it propagates changes, but how quickly it propagates changes _without over-notifying_.
|
||||
|
||||
## Memos vs. Signals
|
||||
|
||||
Note that signals always notify their children; i.e., a signal is always marked `Dirty` when it updates, even if its new value is the same as the old value. Otherwise, we’d have to require `PartialEq` on signals, and this is actually quite an expensive check on some types. (For example, add an unnecessary equality check to something like `some_vec_signal.update(|n| n.pop())` when it’s clear that it has in fact changed.)
|
||||
|
||||
Memos, on the other hand, check whether they change before notifying their children. They only run their calculation once, no matter how many times you `.get()` the result, but they run whenever their signal sources change. This means that if the memo’s computation is _very_ expensive, you may actually want to memoize its inputs as well, so that the memo only re-calculates when it is sure its inputs have changed.
|
||||
|
||||
## Memos vs. Derived Signals
|
||||
|
||||
All of this is cool, and memos are pretty great. But most actual applications have reactive graphs that are quite shallow and quite wide: you might have 100 source signals and 500 effects, but no memos or, in rare case, three or four memos between the signal and the effect. Memos are extremely good at what they do: limiting how often they notify their subscribers that they have changed. But as this description of the reactive system should show, they come with overhead in two forms:
|
||||
|
||||
1. A `PartialEq` check, which may or may not be expensive.
|
||||
2. Added memory cost of storing another node in the reactive system.
|
||||
3. Added computational cost of reactive graph traversal.
|
||||
|
||||
In cases in which the computation itself is cheaper than this reactive work, you should avoid “over-wrapping” with memos and simply use derived signals. Here’s a great example in which you should never use a memo:
|
||||
|
||||
```rust
|
||||
let (a, set_a) = create_signal(1);
|
||||
// none of these make sense as memos
|
||||
let b = move || a() + 2;
|
||||
let c = move || b() % 2 == 0;
|
||||
let d = move || if c() { "even" } else { "odd" };
|
||||
|
||||
set_a(2);
|
||||
set_a(3);
|
||||
set_a(5);
|
||||
```
|
||||
|
||||
Even though memoizing would technically save an extra calculation of `d` between setting `a` to `3` and `5`, these calculations are themselves cheaper than the reactive algorithm.
|
||||
|
||||
At the very most, you might consider memoizing the final node before running some expensive side effect:
|
||||
|
||||
```rust
|
||||
let text = create_memo(move |_| {
|
||||
d()
|
||||
});
|
||||
create_effect(move |_| {
|
||||
engrave_text_into_bar_of_gold(&text());
|
||||
});
|
||||
```
|
||||
@@ -69,6 +69,34 @@ Every time one of the resources is reloading, the `"Loading..."` fallback will s
|
||||
|
||||
This inversion of the flow of control makes it easier to add or remove individual resources, as you don’t need to handle the matching yourself. It also unlocks some massive performance improvements during server-side rendering, which we’ll talk about during a later chapter.
|
||||
|
||||
## `<Await/>`
|
||||
|
||||
In you’re simply trying to wait for some `Future` to resolve before rendering, you may find the `<Await/>` component helpful in reducing boilerplate. `<Await/>` essentially combines a resource with the source argument `|| ()` with a `<Suspense/>` with no fallback.
|
||||
|
||||
In other words:
|
||||
|
||||
1. It only polls the `Future` once, and does not respond to any reactive changes.
|
||||
2. It does not render anything until the `Future` resolves.
|
||||
3. After the `Future` resolves, its binds its data to whatever variable name you choose and then renders its children with that variable in scope.
|
||||
|
||||
```rust
|
||||
async fn fetch_monkeys(monkey: i32) -> i32 {
|
||||
// maybe this didn't need to be async
|
||||
monkey * 2
|
||||
}
|
||||
view! {
|
||||
<Await
|
||||
// `future` provides the `Future` to be resolved
|
||||
future=|| fetch_monkeys(3)
|
||||
// the data is bound to whatever variable name you provide
|
||||
bind:data
|
||||
>
|
||||
// you receive the data by reference and can use it in your view here
|
||||
<p>{*data} " little monkeys, jumping on the bed."</p>
|
||||
</Await>
|
||||
}
|
||||
```
|
||||
|
||||
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/11-suspense-907niv?file=%2Fsrc%2Fmain.rs)
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/11-suspense-907niv?file=%2Fsrc%2Fmain.rs" width="100%" height="1000px" style="max-height: 100vh"></iframe>
|
||||
|
||||
74
docs/book/src/deployment.md
Normal file
74
docs/book/src/deployment.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Deployment
|
||||
|
||||
There are as many ways to deploy a web application as there are developers, let alone applications. But there are a couple useful tips to keep in mind when deploying an app.
|
||||
|
||||
## General Advice
|
||||
|
||||
1. Remember: Always deploy Rust apps built in `--release` mode, not debug mode. This has a huge effect on both performance and binary size.
|
||||
2. Test locally in release mode as well. The framework applies certain optimizations in release mode that it does not apply in debug mode, so it’s possible for bugs to surface at this point. (If your app behaves differently or you do encounter a bug, it’s likely a framework-level bug and you should open a GitHub issue with a reproduction.)
|
||||
|
||||
> We asked users to submit their deployment setups to help with this chapter. I’ll quote from them below, but you can read the full thread [here](https://github.com/leptos-rs/leptos/issues/1152).
|
||||
|
||||
## Deploying a Client-Side-Rendered App
|
||||
|
||||
If you’ve been building an app that only uses client-side rendering, working with Trunk as a dev server and build tool, the process is quite easy.
|
||||
|
||||
```bash
|
||||
trunk build --release
|
||||
```
|
||||
|
||||
`trunk build` will create a number of build artifacts in a `dist/` directory. Publishing `dist` somewhere online should be all you need to deploy your app. This should work very similarly to deploying any JavaScript application.
|
||||
|
||||
> Read more: [Deploying to Vercel with GitHub Actions](https://github.com/leptos-rs/leptos/issues/1152#issuecomment-1577861900).
|
||||
|
||||
## Deploying a Full-Stack App
|
||||
|
||||
The most popular way for people to deploy full-stack apps built with `cargo-leptos` is to use a cloud hosting service that supports deployment via a Docker build. Here’s a sample `Dockerfile`, which is based on the one we use to deploy the Leptos website.
|
||||
|
||||
```dockerfile
|
||||
# Get started with a build env with Rust nightly
|
||||
FROM rustlang/rust:nightly-bullseye as builder
|
||||
|
||||
# If you’re using stable, use this instead
|
||||
# FROM rust:1.70-bullseye as builder
|
||||
|
||||
# Install cargo-binstall, which makes it easier to install other
|
||||
# cargo extensions like cargo-leptos
|
||||
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||
RUN cp cargo-binstall /usr/local/cargo/bin
|
||||
|
||||
# Install cargo-leptos
|
||||
RUN cargo binstall cargo-leptos -y
|
||||
|
||||
# Add the WASM target
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Make an /app dir, which everything will eventually live in
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN cargo leptos build --release -vv
|
||||
|
||||
FROM rustlang/rust:nightly-bullseye as runner
|
||||
# Copy the server binary to the /app directory
|
||||
COPY --from=builder /app/target/server/release/leptos_website /app/
|
||||
# /target/site contains our JS/WASM/CSS, etc.
|
||||
COPY --from=builder /app/target/site /app/site
|
||||
# Copy Cargo.toml if it’s needed at runtime
|
||||
COPY --from=builder /app/Cargo.toml /app/
|
||||
WORKDIR /app
|
||||
|
||||
# Set any required env variables and
|
||||
ENV RUST_LOG="info"
|
||||
ENV APP_ENVIRONMENT="production"
|
||||
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
|
||||
ENV LEPTOS_SITE_ROOT="site"
|
||||
EXPOSE 8080
|
||||
# Run the server
|
||||
CMD ["/app/leptos_website"]
|
||||
```
|
||||
|
||||
> Read more: [`gnu` and `musl` build files for Leptos apps](https://github.com/leptos-rs/leptos/issues/1152#issuecomment-1634916088).
|
||||
@@ -109,6 +109,34 @@ create_effect(move |prev_value| {
|
||||
|
||||
Every time `count` is updated, this effect wil rerun. This is what allows reactive, fine-grained updates to the DOM.
|
||||
|
||||
## Explicit, Cancelable Tracking with `watch`
|
||||
|
||||
In addition to `create_effect`, Leptos provides a [`watch`](https://docs.rs/leptos_reactive/latest/leptos_reactive/fn.watch.html) function, which can be used for two main purposes:
|
||||
|
||||
1. Separating tracking and responding to changes by explicitly passing in a set of values to track.
|
||||
2. Canceling tracking by calling a stop function.
|
||||
|
||||
Like `create_resource`, `watch` takes a first argument, which is reactively tracked, and a second, which is not. Whenever a reactive value in its `deps` argument is changed, the `callback` is run. `watch` returns a function that can be called to stop tracking the dependencies.
|
||||
|
||||
```rust
|
||||
let (num, set_num) = create_signal(0);
|
||||
|
||||
let stop = watch(
|
||||
|
||||
move || num.get(),
|
||||
move |num, prev_num, _| {
|
||||
log::debug!("Number: {}; Prev: {:?}", num, prev_num);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
set_num.set(1); // > "Number: 1; Prev: Some(0)"
|
||||
|
||||
stop(); // stop watching
|
||||
|
||||
set_num.set(2); // (nothing happens)
|
||||
```
|
||||
|
||||
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D)
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/serene-thompson-40974n?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D" width="100%" height="1000px" style="max-height: 100vh"></iframe>
|
||||
|
||||
@@ -94,7 +94,7 @@ The `view` is a function that returns a view. Any component with no props works
|
||||
</Routes>
|
||||
```
|
||||
|
||||
> `view` takes a `Fn(Scope) -> impl IntoView`. If a component has no props, it is a function that takes `Scope` and returns `impl IntoView`, so it can be passed directly into the `view`. In this case, `view=Home` is just a shorthand for `|cx| view! { cx, <Home/> }`.
|
||||
> `view` takes a `Fn() -> impl IntoView`. If a component has no props, it can be passed directly into the `view`. In this case, `view=Home` is just a shorthand for `|| view! { <Home/> }`.
|
||||
|
||||
Now if you navigate to `/` or to `/users` you’ll get the home page or the `<Users/>`. If you go to `/users/3` or `/blahblah` you’ll get a user profile or your 404 page (`<NotFound/>`). On every navigation, the router determines which `<Route/>` should be matched, and therefore what content should be displayed where the `<Routes/>` component is defined.
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ You can easily define this with nested routes
|
||||
<Routes>
|
||||
<Route path="/contacts" view=ContactList>
|
||||
<Route path=":id" view=ContactInfo/>
|
||||
<Route path="" view=|cx| view! { cx,
|
||||
<Route path="" view=|| view! {
|
||||
<p>"Select a contact to view more info."</p>
|
||||
}/>
|
||||
</Route>
|
||||
@@ -205,7 +205,7 @@ fn App() -> impl IntoView {
|
||||
>
|
||||
// if no id specified, fall back
|
||||
<Route path=":id" view=ContactInfo>
|
||||
<Route path="" view=|cx| view! { cx,
|
||||
<Route path="" view=|| view! {
|
||||
<div class="tab">
|
||||
"(Contact Info)"
|
||||
</div>
|
||||
|
||||
@@ -120,7 +120,7 @@ fn App() -> impl IntoView {
|
||||
>
|
||||
// if no id specified, fall back
|
||||
<Route path=":id" view=ContactInfo>
|
||||
<Route path="" view=|cx| view! { cx,
|
||||
<Route path="" view=|| view! {
|
||||
<div class="tab">
|
||||
"(Contact Info)"
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,21 @@ The router also provides an [`<A>`](https://docs.rs/leptos_router/latest/leptos_
|
||||
1. Correctly resolves relative nested routes. Relative routing with ordinary `<a>` tags can be tricky. For example, if you have a route like `/post/:id`, `<A href="1">` will generate the correct relative route, but `<a href="1">` likely will not (depending on where it appears in your view.) `<A/>` resolves routes relative to the path of the nested route within which it appears.
|
||||
2. Sets the `aria-current` attribute to `page` if this link is the active link (i.e., it’s a link to the page you’re on). This is helpful for accessibility and for styling. For example, if you want to set the link a different color if it’s a link to the page you’re currently on, you can match this attribute with a CSS selector.
|
||||
|
||||
## Navigating Programmatically
|
||||
|
||||
Your most-used methods of navigating between pages should be with `<a>` and `<form>` elements or with the enhanced `<A/>` and `<Form/>` components. Using links and forms to navigate is the best solution for accessibility and graceful degradation.
|
||||
|
||||
On occasion, though, you’ll want to navigate programmatically, i.e., call a function that can navigate to a new page. In that case, you should use the [`use_navigate`](https://docs.rs/leptos_router/latest/leptos_router/fn.use_navigate.html) function.
|
||||
|
||||
```rust
|
||||
let navigate = leptos_router::use_navigate();
|
||||
navigate("/somewhere", Default::default());
|
||||
```
|
||||
|
||||
> You should almost never do something like `<button on:click=move |_| navigate(/* ... */)>`. Any `on:click` that navigates should be an `<a>`, for reasons of accessibility.
|
||||
|
||||
The second argument here is a set of [`NavigateOptions`](https://docs.rs/leptos_router/latest/leptos_router/struct.NavigateOptions.html), which includes options to resolve the navigation relative to the current route as the `<A/>` component does, replace it in the navigation stack, include some navigation state, and maintain the current scroll state on navigation.
|
||||
|
||||
> Once again, this is the same example. Check out the relative `<A/>` components, and take a look at the CSS in `index.html` to see the ARIA-based styling.
|
||||
|
||||
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-fy4tjv?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D)
|
||||
@@ -58,7 +73,7 @@ fn App() -> impl IntoView {
|
||||
>
|
||||
// if no id specified, fall back
|
||||
<Route path=":id" view=ContactInfo>
|
||||
<Route path="" view=|cx| view! { cx,
|
||||
<Route path="" view=|| view! {
|
||||
<div class="tab">
|
||||
"(Contact Info)"
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,21 @@ pub async fn axum_extract() -> Result<String, ServerFnError> {
|
||||
|
||||
These are relatively simple examples accessing basic data from the server. But you can use extractors to access things like headers, cookies, database connection pools, and more, using the exact same `extract()` pattern.
|
||||
|
||||
> Note: For now, the Axum `extract` function only supports extractors for which the state is `()`, i.e., you can't yet use it to extract `State(_)`. You can access `State(_)` by using a custom handler that extracts the state and then provides it via context. [Click here for an example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/main.rs#L91-L92).
|
||||
The Axum `extract` function only supports extractors for which the state is `()`. If you need an extractor that uses `State`, you should use [`extract_with_state`](https://docs.rs/leptos_axum/latest/leptos_axum/fn.extract_with_state.html). This requires you to provide the state. You can do this by extending the existing `LeptosOptions` state using the Axum `FromRef` pattern, which providing the state as context during render and server functions with custom handlers.
|
||||
|
||||
```rust
|
||||
use axum::extract::FromRef;
|
||||
|
||||
/// Derive FromRef to allow multiple items in state, using Axum’s
|
||||
/// SubStates pattern.
|
||||
#[derive(FromRef, Debug, Clone)]
|
||||
pub struct AppState{
|
||||
pub leptos_options: LeptosOptions,
|
||||
pub pool: SqlitePool
|
||||
}
|
||||
```
|
||||
|
||||
[Click here for an example of providing context in custom handlers](https://github.com/leptos-rs/leptos/blob/19ea6fae6aec2a493d79cc86612622d219e6eebb/examples/session_auth_axum/src/main.rs#L24-L44).
|
||||
|
||||
## A Note about Data-Loading Patterns
|
||||
|
||||
|
||||
@@ -53,87 +53,38 @@ pub fn TodoApp() -> impl IntoView {
|
||||
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`
|
||||
## 2. Test components with end-to-end (`e2e`) testing
|
||||
|
||||
[`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.
|
||||
Our [`examples`](https://github.com/leptos-rs/leptos/tree/main/examples) directory has several examples with extensive end-to-end testing, using different testing tools.
|
||||
|
||||
To use this testing utility, you need to add `wasm-bindgen-test` to your `Cargo.toml`:
|
||||
The easiest way to see how to use these is to take a look at the test examples themselves:
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.0"
|
||||
```
|
||||
### `wasm-bindgen-test` with [`counter`](https://github.com/leptos-rs/leptos/blob/main/examples/counter/tests/web.rs)
|
||||
|
||||
You should create tests in a separate `tests` directory. You can then run your tests in the browser of your choice:
|
||||
This is a fairly simple manual testing setup that uses the [`wasm-pack test`](https://rustwasm.github.io/wasm-pack/book/commands/test.html) command.
|
||||
|
||||
```bash
|
||||
wasm-pack test --firefox
|
||||
```
|
||||
#### Sample Test
|
||||
|
||||
> 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, here’s a test [for the
|
||||
`counter` example](https://github.com/leptos-rs/leptos/blob/main/examples/counter/tests/web.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);
|
||||
```
|
||||
|
||||
I’m 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]
|
||||
````rust
|
||||
#[wasm_bindgen_test]
|
||||
fn clear() {
|
||||
let document = leptos::document();
|
||||
let test_wrapper = document.create_element("section").unwrap();
|
||||
document.body().unwrap().append_child(&test_wrapper);
|
||||
let _ = 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(),
|
||||
|| view! { <SimpleCounter initial_value=10 step=1/> },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
We’ll use some manual DOM operations to grab the `<div>` that wraps
|
||||
the whole component, as well as the `clear` button.
|
||||
let div = test_wrapper.query_selector("div").unwrap().unwrap();
|
||||
let clear = test_wrapper
|
||||
.query_selector("button")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlElement>();
|
||||
|
||||
```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 element’s
|
||||
`outerHTML` against our expectations.
|
||||
clear.click();
|
||||
|
||||
```rust
|
||||
assert_eq!(
|
||||
@@ -157,24 +108,112 @@ assert_eq!(
|
||||
.outer_html()
|
||||
})
|
||||
);
|
||||
```
|
||||
````
|
||||
|
||||
That test involved us manually replicating the `view` that’s 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: I’ll just test
|
||||
the wrapper’s `innerHTML` against another comparison case.
|
||||
### [`wasm-bindgen-test` with `counters_stable`](https://github.com/leptos-rs/leptos/tree/main/examples/counters_stable/tests/web)
|
||||
|
||||
This more developed test suite uses a system of fixtures to refactor the manual DOM manipulation of the `counter` tests and easily test a wide range of cases.
|
||||
|
||||
#### Sample Test
|
||||
|
||||
```rust
|
||||
assert_eq!(test_wrapper.inner_html(), {
|
||||
let comparison_wrapper = document.create_element("section").unwrap();
|
||||
leptos::mount_to(
|
||||
comparison_wrapper.clone().unchecked_into(),
|
||||
|| view! { <SimpleCounter initial_value=0 step=1/>},
|
||||
);
|
||||
comparison_wrapper.inner_html()
|
||||
use super::*;
|
||||
use crate::counters_page as ui;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn should_increase_the_total_count() {
|
||||
// Given
|
||||
ui::view_counters();
|
||||
ui::add_counter();
|
||||
|
||||
// When
|
||||
ui::increment_counter(1);
|
||||
ui::increment_counter(1);
|
||||
ui::increment_counter(1);
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::total(), 3);
|
||||
}
|
||||
```
|
||||
|
||||
### [Playwright with `counters_stable`](https://github.com/leptos-rs/leptos/tree/main/examples/counters_stable/e2e)
|
||||
|
||||
These tests use the common JavaScript testing tool Playwright to run end-to-end tests on the same example, using a library and testing approach familiar to may who have done frontend development before.
|
||||
|
||||
#### Sample Test
|
||||
|
||||
```js
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
test.describe("Increment Count", () => {
|
||||
test("should increase the total count", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
await ui.goto();
|
||||
await ui.addCounter();
|
||||
|
||||
await ui.incrementCount();
|
||||
await ui.incrementCount();
|
||||
await ui.incrementCount();
|
||||
|
||||
await expect(ui.total).toHaveText("3");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
This is only a very limited introduction to testing. But I hope it’s useful as you begin to build applications.
|
||||
### [Gherkin/Cucumber Tests with `todo_app_sqlite`](https://github.com/leptos-rs/leptos/blob/main/examples/todo_app_sqlite/e2e/README.md)
|
||||
|
||||
> 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).
|
||||
You can integrate any testing tool you’d like into this flow. This example uses Cucumber, a testing framework based on natural language.
|
||||
|
||||
```
|
||||
@add_todo
|
||||
Feature: Add Todo
|
||||
|
||||
Background:
|
||||
Given I see the app
|
||||
|
||||
@add_todo-see
|
||||
Scenario: Should see the todo
|
||||
Given I set the todo as Buy Bread
|
||||
When I click the Add button
|
||||
Then I see the todo named Buy Bread
|
||||
|
||||
# @allow.skipped
|
||||
@add_todo-style
|
||||
Scenario: Should see the pending todo
|
||||
When I add a todo as Buy Oranges
|
||||
Then I see the pending todo
|
||||
```
|
||||
|
||||
The definitions for these actions are defined in Rust code.
|
||||
|
||||
```rust
|
||||
use crate::fixtures::{action, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::{given, when};
|
||||
|
||||
#[given("I see the app")]
|
||||
#[when("I open the app")]
|
||||
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::goto_path(client, "").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(regex = "^I add a todo as (.*)$")]
|
||||
#[when(regex = "^I add a todo as (.*)$")]
|
||||
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::add_todo(client, text.as_str()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// etc.
|
||||
```
|
||||
|
||||
### Learning More
|
||||
|
||||
Feel free to check out the CI setup in the Leptos repo to learn more about how to use these tools in your own application. All of these testing methods are run regularly against actual Leptos example apps.
|
||||
|
||||
@@ -144,10 +144,27 @@ let double_count = move || count() * 2;
|
||||
Derived signals let you create reactive computed values that can be used in multiple
|
||||
places in your application with minimal overhead.
|
||||
|
||||
> Note: Using a derived signal like this means that the calculation runs once per
|
||||
> signal change per place we access `double_count`; in other words, twice. This is a
|
||||
> very cheap calculation, so that’s fine. We’ll look at memos in a later chapter, which
|
||||
> are designed to solve this problem for expensive calculations.
|
||||
Note: Using a derived signal like this means that the calculation runs once per
|
||||
signal change per place we access `double_count`; in other words, twice. This is a
|
||||
very cheap calculation, so that’s fine. We’ll look at memos in a later chapter, which
|
||||
are designed to solve this problem for expensive calculations.
|
||||
|
||||
> #### Advanced Topic: Injecting Raw HTML
|
||||
>
|
||||
> The `view` macro provides support for an additional attribute, `inner_html`, which
|
||||
> can be used to directly set the HTML contents of any element, wiping out any other
|
||||
> children you’ve given it. Note that this does _not_ escape the HTML you provide. You
|
||||
> should make sure that it only contains trusted input or that any HTML entities are
|
||||
> escaped, to prevent cross-site scripting (XSS) attacks.
|
||||
>
|
||||
> ```rust
|
||||
> let html = "<p>This HTML will be injected.</p>";
|
||||
> view! {
|
||||
> <div inner_html=html/>
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> [Click here for the full `view` macros docs](https://docs.rs/leptos/latest/leptos/macro.view.html).
|
||||
|
||||
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/2-dynamic-attribute-pqyvzl?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A2%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A2%7D%5D)
|
||||
|
||||
|
||||
@@ -224,11 +224,10 @@ This generic can also be specified inline:
|
||||
```rust
|
||||
#[component]
|
||||
fn ProgressBar<F: Fn() -> i32 + 'static>(
|
||||
cx: Scope,
|
||||
#[prop(default = 100)] max: u16,
|
||||
progress: F,
|
||||
) -> impl IntoView {
|
||||
view! { cx,
|
||||
view! {
|
||||
<progress
|
||||
max=max
|
||||
value=progress
|
||||
@@ -294,11 +293,10 @@ Note that you can’t specify optional generic props for a component. Let’s se
|
||||
```rust,compile_fail
|
||||
#[component]
|
||||
fn ProgressBar<F: Fn() -> i32 + 'static>(
|
||||
cx: Scope,
|
||||
#[prop(optional)] progress: Option<F>,
|
||||
) -> impl IntoView {
|
||||
progress.map(|progress| {
|
||||
view! { cx,
|
||||
view! {
|
||||
<progress
|
||||
max=100
|
||||
value=progress
|
||||
@@ -308,8 +306,8 @@ fn ProgressBar<F: Fn() -> i32 + 'static>(
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
view! { cx,
|
||||
pub fn App() -> impl IntoView {
|
||||
view! {
|
||||
<ProgressBar/>
|
||||
}
|
||||
}
|
||||
@@ -337,11 +335,10 @@ However, you can get around this by providing a concrete type using `Box<dyn _>`
|
||||
```rust
|
||||
#[component]
|
||||
fn ProgressBar(
|
||||
cx: Scope,
|
||||
#[prop(optional)] progress: Option<Box<dyn Fn() -> i32>>,
|
||||
) -> impl IntoView {
|
||||
progress.map(|progress| {
|
||||
view! { cx,
|
||||
view! {
|
||||
<progress
|
||||
max=100
|
||||
value=progress
|
||||
@@ -351,8 +348,8 @@ fn ProgressBar(
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App(cx: Scope) -> impl IntoView {
|
||||
view! { cx,
|
||||
pub fn App() -> impl IntoView {
|
||||
view! {
|
||||
<ProgressBar/>
|
||||
}
|
||||
}
|
||||
@@ -397,6 +394,24 @@ type, and each of the fields used to add props. It can be a little hard to
|
||||
understand how powerful this is until you hover over the component name or props
|
||||
and see the power of the `#[component]` macro combined with rust-analyzer here.
|
||||
|
||||
> #### Advanced Topic: `#[component(transparent)]`
|
||||
>
|
||||
> All Leptos components return `-> impl IntoView`. Some, though, need to return
|
||||
> some data directly without any additional wrapping. These can be marked with
|
||||
> `#[component(transparent)]`, in which case they return exactly the value they
|
||||
> return, without the rendering system transforming them in any way.
|
||||
>
|
||||
> This is mostly used in two situations:
|
||||
>
|
||||
> 1. Creating wrappers around `<Suspense/>` or `<Transition/>`, which return a
|
||||
> transparent suspense structure to integrate with SSR and hydration properly.
|
||||
> 2. Refactoring `<Route/>` definitions for `leptos_router` out into separate
|
||||
> components, because `<Route/>` is a transparent component that returns a
|
||||
> `RouteDefinition` struct rather than a view.
|
||||
>
|
||||
> In general, you should not need to use transparent components unless you are
|
||||
> creating custom wrapping components that fall into one of these two categories.
|
||||
|
||||
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/3-components-50t2e7?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A7%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A7%7D%5D)
|
||||
|
||||
<iframe src="https://codesandbox.io/p/sandbox/3-components-50t2e7?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A7%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A7%7D%5D" width="100%" height="1000px" style="max-height: 100vh"></iframe>
|
||||
|
||||
@@ -19,7 +19,8 @@ There are two important things to remember:
|
||||
2. The `value` _attribute_ only sets the initial value of the input, i.e., it
|
||||
only updates the input up to the point that you begin typing. The `value`
|
||||
_property_ continues updating the input after that. You usually want to set
|
||||
`prop:value` for this reason.
|
||||
`prop:value` for this reason. (The same is true for `checked` and `prop:checked`
|
||||
on an `<input type="checkbox">`.)
|
||||
|
||||
```rust
|
||||
let (name, set_name) = create_signal("Controlled".to_string());
|
||||
@@ -42,6 +43,33 @@ view! {
|
||||
}
|
||||
```
|
||||
|
||||
> #### Why do you need `prop:value`?
|
||||
>
|
||||
> Web browsers are the most ubiquitous and stable platform for rendering graphical user interfaces in existence. They have also maintained an incredible backwards compatibility over their three decades of existence. Inevitably, this means there are some quirks.
|
||||
>
|
||||
> One odd quirk is that there is a distinction between HTML attributes and DOM element properties, i.e., between something called an “attribute” which is parsed from HTML and can be set on a DOM element with `.setAttribute()`, and something called a “property” which is a field of the JavaScript class representation of that parsed HTML element.
|
||||
>
|
||||
> In the case of an `<input value=...>`, setting the `value` *attribute* is defined as setting the initial value for the input, and setting `value` *property* sets its current value. It maybe easiest to understand this by opening `about:blank` and running the following JavaScript in the browser console, line by line:
|
||||
>
|
||||
> ```js
|
||||
> // create an input and append it to the DOM
|
||||
> const el = document.createElement("input")
|
||||
> document.body.appendChild(el)
|
||||
>
|
||||
> el.setAttribute("value", "test") // updates the input
|
||||
> el.setAttribute("value", "another test") // updates the input again
|
||||
>
|
||||
> // now go and type into the input: delete some characters, etc.
|
||||
>
|
||||
> el.setAttribute("value", "one more time?")
|
||||
> // nothing should have changed. setting the "initial value" does nothing now
|
||||
>
|
||||
> // however...
|
||||
> el.value = "But this works"
|
||||
> ```
|
||||
>
|
||||
> Many other frontend frameworks conflate attributes and properties, or create a special case for inputs that sets the value correctly. Maybe Leptos should do this too; but for now, I prefer giving users the maximum amount of control over whether they’re setting an attribute or a property, and doing my best to educate people about the actual underlying browser behavior rather than obscuring it.
|
||||
|
||||
## Uncontrolled Inputs
|
||||
|
||||
In an "uncontrolled input," the browser controls the state of the input element.
|
||||
|
||||
@@ -208,7 +208,8 @@ view! {
|
||||
|
||||
`<Show/>` memoizes the `when` condition, so it only renders its `<Small/>` once,
|
||||
continuing to show the same component until `value` is greater than five;
|
||||
then it renders `<Big/>` once, continuing to show it indefinitely.
|
||||
then it renders `<Big/>` once, continuing to show it indefinitely or until `value`
|
||||
goes below five and then renders `<Small/>` again.
|
||||
|
||||
This is a helpful tool to avoid rerendering when using dynamic `if` expressions.
|
||||
As always, there's some overhead: for a very simple node (like updating a single
|
||||
|
||||
@@ -5,34 +5,35 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
|
||||
CARGO_MAKE_CARGO_BUILD_TEST_FLAGS = ""
|
||||
CARGO_MAKE_WORKSPACE_EMULATION = true
|
||||
CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = [
|
||||
"animated_show",
|
||||
"counter",
|
||||
"counter_isomorphic",
|
||||
"counters",
|
||||
"counters_stable",
|
||||
"counter_url_query,
|
||||
"counter_without_macros",
|
||||
"error_boundary",
|
||||
"errors_axum",
|
||||
"fetch",
|
||||
"hackernews",
|
||||
"hackernews_axum",
|
||||
"js-framework-benchmark",
|
||||
"leptos-tailwind-axum",
|
||||
"login_with_token_csr_only",
|
||||
"parent_child",
|
||||
"router",
|
||||
"session_auth_axum",
|
||||
"slots",
|
||||
"ssr_modes",
|
||||
"ssr_modes_axum",
|
||||
"tailwind",
|
||||
"tailwind_csr_trunk",
|
||||
"timer",
|
||||
"todo_app_sqlite",
|
||||
"todo_app_sqlite_axum",
|
||||
"todo_app_sqlite_viz",
|
||||
"todomvc",
|
||||
"animated_show",
|
||||
"counter",
|
||||
"counter_isomorphic",
|
||||
"counters",
|
||||
"counters_stable",
|
||||
"counter_url_query",
|
||||
"counter_without_macros",
|
||||
"error_boundary",
|
||||
"errors_axum",
|
||||
"fetch",
|
||||
"hackernews",
|
||||
"hackernews_axum",
|
||||
"js-framework-benchmark",
|
||||
"leptos-tailwind-axum",
|
||||
"login_with_token_csr_only",
|
||||
"parent_child",
|
||||
"router",
|
||||
"session_auth_axum",
|
||||
"slots",
|
||||
"ssr_modes",
|
||||
"ssr_modes_axum",
|
||||
"suspense_tests",
|
||||
"tailwind",
|
||||
"tailwind_csr_trunk",
|
||||
"timer",
|
||||
"todo_app_sqlite",
|
||||
"todo_app_sqlite_axum",
|
||||
"todo_app_sqlite_viz",
|
||||
"todomvc",
|
||||
]
|
||||
|
||||
[tasks.gen-members]
|
||||
@@ -48,9 +49,9 @@ jq -R -s -c 'split("\n")[:-1]')
|
||||
echo "CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = $examples"
|
||||
'''
|
||||
|
||||
[tasks.test-info]
|
||||
[tasks.test-runner-report]
|
||||
workspace = false
|
||||
description = "report ci test runners for each example - Option [all]"
|
||||
description = "report ci test runners for each example - OPTION: [all]"
|
||||
script = '''
|
||||
BOLD="\e[1m"
|
||||
GREEN="\e[0;32m"
|
||||
@@ -59,63 +60,53 @@ YELLOW="\e[0;33m"
|
||||
RESET="\e[0m"
|
||||
|
||||
echo
|
||||
echo "${YELLOW}CI test runners by example...${RESET}"
|
||||
echo "${YELLOW}Test Runner Report${RESET}"
|
||||
echo "${ITALIC}Pass the option \"all\" to show all the examples${RESET}"
|
||||
echo
|
||||
|
||||
examples=$(ls |
|
||||
grep -v README.md |
|
||||
grep -v Makefile.toml |
|
||||
grep -v cargo-make |
|
||||
grep -v gtk |
|
||||
sort -u |
|
||||
awk '{print $0 ", "}')
|
||||
makefile_paths=$(find . -name Makefile.toml -not -path '*/target/*' |
|
||||
sed 's%./%%' |
|
||||
sed 's%/Makefile.toml%%' |
|
||||
grep -v Makefile.toml |
|
||||
sort -u)
|
||||
|
||||
example_root_dir=$(pwd)
|
||||
start_path=$(pwd)
|
||||
|
||||
for example_dir in $examples
|
||||
do
|
||||
clean_name=$(echo $example_dir | sed 's%,%%')
|
||||
cd $clean_name
|
||||
|
||||
c_tests=$(grep -rl --fixed-strings "#[test]" | wc -l)
|
||||
rs_tests=$(grep -rl --fixed-strings "#[rstest]" | wc -l)
|
||||
w_configs=$(grep -rl "\/wasm-test.toml\"" | wc -l)
|
||||
pw_configs=$(grep -rl "\/playwright-test.toml\"" | wc -l)
|
||||
cl_configs=$(grep -rl "\/cargo-leptos-test.toml\"" | wc -l)
|
||||
for path in $makefile_paths; do
|
||||
cd $path
|
||||
|
||||
test_runner=
|
||||
|
||||
if [ $c_tests -gt 0 ]; then
|
||||
test_count=$(grep -rl -E "#\[(test|rstest)\]" | wc -l)
|
||||
if [ $test_count -gt 0 ]; then
|
||||
test_runner="-C"
|
||||
fi
|
||||
|
||||
if [ $rs_tests -gt 0 ]; then
|
||||
test_runner=$test_runner"-R"
|
||||
fi
|
||||
while read -r line; do
|
||||
case $line in
|
||||
*"wasm-test.toml"*)
|
||||
test_runner=$test_runner"-W"
|
||||
;;
|
||||
*"playwright-test.toml"*)
|
||||
test_runner=$test_runner"-P"
|
||||
;;
|
||||
*"cargo-leptos-test.toml"*)
|
||||
test_runner=$test_runner"-L"
|
||||
;;
|
||||
esac
|
||||
done <"./Makefile.toml"
|
||||
|
||||
if [ $w_configs -gt 0 ]; then
|
||||
test_runner=$test_runner"-W"
|
||||
fi
|
||||
|
||||
if [ $pw_configs -gt 0 ]; then
|
||||
test_runner=$test_runner"-P"
|
||||
fi
|
||||
|
||||
if [ $cl_configs -gt 0 ]; then
|
||||
test_runner=$test_runner"-L"
|
||||
fi
|
||||
|
||||
if [ ! -z "$1" ]; then
|
||||
# Show all examples
|
||||
echo "$clean_name ${BOLD}${test_runner}${RESET}"
|
||||
echo "$path ${BOLD}${test_runner}${RESET}"
|
||||
elif [ ! -z $test_runner ]; then
|
||||
# Filter out examples that do not run tests in `ci`
|
||||
echo "$clean_name ${BOLD}${test_runner}${RESET}"
|
||||
echo "$path ${BOLD}${test_runner}${RESET}"
|
||||
fi
|
||||
|
||||
cd $example_root_dir
|
||||
cd ${start_path}
|
||||
done
|
||||
echo
|
||||
echo "${ITALIC}Test Runners: C = Cargo Test, L = Cargo Leptos Test, P = Playwright Test, R = RS Test, W = WASM Test${RESET}"
|
||||
echo "${ITALIC}Runners: C = Cargo Test, L = Cargo Leptos Test, P = Playwright Test, W = WASM Test${RESET}"
|
||||
echo
|
||||
'''
|
||||
|
||||
@@ -6,7 +6,7 @@ pub fn App() -> impl IntoView {
|
||||
let show = create_rw_signal(false);
|
||||
|
||||
// the CSS classes in this example are just written directly inside the `index.html`
|
||||
view! { cx,
|
||||
view! {
|
||||
<div
|
||||
class="hover-me"
|
||||
on:mouseenter=move |_| show.set(true)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
extend = { path = "./cargo-leptos.toml" }
|
||||
|
||||
[tasks.integration-test]
|
||||
dependencies = ["cargo-leptos-e2e"]
|
||||
dependencies = ["install-cargo-leptos", "cargo-leptos-e2e"]
|
||||
|
||||
[tasks.cargo-leptos-e2e]
|
||||
command = "cargo"
|
||||
|
||||
55
examples/cargo-make/cargo-leptos.toml
Normal file
55
examples/cargo-make/cargo-leptos.toml
Normal file
@@ -0,0 +1,55 @@
|
||||
[tasks.install-cargo-leptos]
|
||||
install_crate = { crate_name = "cargo-leptos", binary = "cargo-leptos", test_arg = "--help" }
|
||||
|
||||
[tasks.build]
|
||||
clear = true
|
||||
command = "cargo"
|
||||
args = ["leptos", "build"]
|
||||
|
||||
[tasks.check]
|
||||
clear = true
|
||||
dependencies = ["check-debug", "check-release"]
|
||||
|
||||
[tasks.check-debug]
|
||||
toolchain = "nightly"
|
||||
command = "cargo"
|
||||
args = ["check-all-features"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.check-release]
|
||||
toolchain = "nightly"
|
||||
command = "cargo"
|
||||
args = ["check-all-features", "--release"]
|
||||
install_crate = "cargo-all-features"
|
||||
|
||||
[tasks.start-client]
|
||||
command = "cargo"
|
||||
args = ["leptos", "watch"]
|
||||
|
||||
[tasks.stop-client]
|
||||
condition = { env_set = ["APP_PROCESS_NAME"] }
|
||||
script = '''
|
||||
if [ ! -z $(pidof ${APP_PROCESS_NAME}) ]; then
|
||||
pkill -f todo_app_sqlite
|
||||
fi
|
||||
|
||||
if [ ! -z $(pidof ${APP_PROCESS_NAME}) ]; then
|
||||
pkill -f cargo-leptos
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.client-status]
|
||||
condition = { env_set = ["APP_PROCESS_NAME"] }
|
||||
script = '''
|
||||
if [ -z $(pidof ${APP_PROCESS_NAME}) ]; then
|
||||
echo " ${APP_PROCESS_NAME} is not running"
|
||||
else
|
||||
echo " ${APP_PROCESS_NAME} is up"
|
||||
fi
|
||||
|
||||
if [ -z $(pidof cargo-leptos) ]; then
|
||||
echo " cargo-leptos is not running"
|
||||
else
|
||||
echo " cargo-leptos is up"
|
||||
fi
|
||||
'''
|
||||
30
examples/cargo-make/webdriver.toml
Normal file
30
examples/cargo-make/webdriver.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[tasks.start-webdriver]
|
||||
script = '''
|
||||
BOLD="\e[1m"
|
||||
GREEN="\e[0;32m"
|
||||
RED="\e[0;31m"
|
||||
RESET="\e[0m"
|
||||
|
||||
if command -v chromedriver; then
|
||||
if [ -z $(pidof chromedriver) ]; then
|
||||
chromedriver --port=4444 &
|
||||
fi
|
||||
else
|
||||
echo "${RED}${BOLD}ERROR${RESET} - chromedriver is required by this task"
|
||||
exit 1
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.stop-webdriver]
|
||||
script = '''
|
||||
pkill -f "chromedriver"
|
||||
'''
|
||||
|
||||
[tasks.webdriver-status]
|
||||
script = '''
|
||||
if [ -z $(pidof chromedriver) ]; then
|
||||
echo chromedriver is not running
|
||||
else
|
||||
echo chromedriver is up
|
||||
fi
|
||||
'''
|
||||
@@ -112,9 +112,9 @@ pub fn Counter() -> impl IntoView {
|
||||
);
|
||||
|
||||
let value =
|
||||
move || counter.read().map(|count| count.unwrap_or(0)).unwrap_or(0);
|
||||
move || counter.get().map(|count| count.unwrap_or(0)).unwrap_or(0);
|
||||
let error_msg = move || {
|
||||
counter.read().and_then(|res| match res {
|
||||
counter.get().and_then(|res| match res {
|
||||
Ok(_) => None,
|
||||
Err(e) => Some(e),
|
||||
})
|
||||
@@ -159,7 +159,7 @@ pub fn FormCounter() -> impl IntoView {
|
||||
);
|
||||
let value = move || {
|
||||
log::debug!("FormCounter looking for value");
|
||||
counter.read().and_then(|n| n.ok()).unwrap_or(0)
|
||||
counter.get().and_then(|n| n.ok()).unwrap_or(0)
|
||||
};
|
||||
|
||||
view! {
|
||||
|
||||
@@ -11,29 +11,27 @@ pub fn counter(initial_value: i32, step: u32) -> impl IntoView {
|
||||
// children can be added with .child()
|
||||
// this takes any type that implements IntoView as its argument
|
||||
// for example, a string or an HtmlElement<_>
|
||||
.child(
|
||||
// it can also take an array of types that impl IntoView
|
||||
// or a tuple of up to 26 objects that impl IntoView
|
||||
.child((
|
||||
button()
|
||||
// typed events found in leptos::ev
|
||||
// 1) prevent typos in event names
|
||||
// 2) allow for correct type inference in callbacks
|
||||
.on(ev::click, move |_| set_count.update(|count| count.clear()))
|
||||
.child("Clear"),
|
||||
)
|
||||
.child(
|
||||
button()
|
||||
.on(ev::click, move |_| {
|
||||
set_count.update(|count| count.decrease())
|
||||
})
|
||||
.child("-1"),
|
||||
)
|
||||
.child(
|
||||
span()
|
||||
.child("Value: ")
|
||||
// reactive values are passed to .child() as a tuple
|
||||
// (Scope, [child function]) so an effect can be created
|
||||
.child(move || count.get().value())
|
||||
.child("!"),
|
||||
)
|
||||
))
|
||||
.child(
|
||||
button()
|
||||
.on(ev::click, move |_| {
|
||||
|
||||
@@ -67,14 +67,12 @@ pub fn fetch_example() -> impl IntoView {
|
||||
// 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
|
||||
// so we'll just use `.and_then()` to map over the happy path
|
||||
let cats_view = move || {
|
||||
cats.read().map(|data| {
|
||||
data.map(|data| {
|
||||
data.iter()
|
||||
.map(|s| view! { <p><img src={s}/></p> })
|
||||
.collect_view()
|
||||
})
|
||||
cats.and_then(|data| {
|
||||
data.iter()
|
||||
.map(|s| view! { <p><img src={s}/></p> })
|
||||
.collect_view()
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ actix-web = { version = "4", optional = true, features = ["macros"] }
|
||||
console_log = "1"
|
||||
console_error_panic_hook = "0.1"
|
||||
cfg-if = "1"
|
||||
leptos = { path = "../../leptos", features = ["nightly"] }
|
||||
leptos = { path = "../../leptos", features = ["nightly", "islands"] }
|
||||
leptos_meta = { path = "../../meta", features = ["nightly"] }
|
||||
leptos_actix = { path = "../../integrations/actix", optional = true }
|
||||
leptos_router = { path = "../../router", features = ["nightly"] }
|
||||
@@ -41,6 +41,12 @@ ssr = [
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = 'z'
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["actix-files", "actix-web", "leptos_actix"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
|
||||
@@ -88,3 +94,5 @@ lib-features = ["hydrate"]
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
|
||||
lib-profile-release = "wasm-release"
|
||||
|
||||
@@ -17,6 +17,14 @@ where
|
||||
let abort_controller = web_sys::AbortController::new().ok();
|
||||
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
|
||||
|
||||
// abort in-flight requests if the Scope is disposed
|
||||
// i.e., if we've navigated away from this page
|
||||
leptos::on_cleanup(move || {
|
||||
if let Some(abort_controller) = abort_controller {
|
||||
abort_controller.abort()
|
||||
}
|
||||
});
|
||||
|
||||
let json = gloo_net::http::Request::get(path)
|
||||
.abort_signal(abort_signal.as_ref())
|
||||
.send()
|
||||
@@ -27,13 +35,6 @@ where
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
// abort in-flight requests if the Scope is disposed
|
||||
// i.e., if we've navigated away from this page
|
||||
leptos::on_cleanup(move || {
|
||||
if let Some(abort_controller) = abort_controller {
|
||||
abort_controller.abort()
|
||||
}
|
||||
});
|
||||
T::de(&json).ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ pub fn App() -> impl IntoView {
|
||||
// adding `set_is_routing` causes the router to wait for async data to load on new pages
|
||||
<Router set_is_routing>
|
||||
// shows a progress bar while async data are loading
|
||||
<RoutingProgress is_routing max_time=std::time::Duration::from_millis(250)/>
|
||||
<div class="routing-progress">
|
||||
<RoutingProgress is_routing max_time=std::time::Duration::from_millis(250)/>
|
||||
</div>
|
||||
<Nav />
|
||||
<main>
|
||||
<Routes>
|
||||
|
||||
@@ -26,12 +26,7 @@ cfg_if! {
|
||||
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
// Generate the list of routes in your Leptos App
|
||||
|
||||
eprintln!("\n\ngenerating routes\n\n");
|
||||
|
||||
let routes = generate_route_list(|| view! { <App/> });
|
||||
|
||||
eprintln!("\n\ndone generating routes\n\n");
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
HttpServer::new(move || {
|
||||
let leptos_options = &conf.leptos_options;
|
||||
@@ -41,7 +36,7 @@ cfg_if! {
|
||||
.service(css)
|
||||
.service(favicon)
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), || view! { <App/> })
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
|
||||
.service(Files::new("/", site_root))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn Nav() -> impl IntoView {
|
||||
<A href="/job">
|
||||
<strong>"Jobs"</strong>
|
||||
</A>
|
||||
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer">
|
||||
<a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
|
||||
"Built with Leptos"
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn Stories() -> impl IntoView {
|
||||
|
||||
let hide_more_link = move || {
|
||||
pending()
|
||||
|| stories.read().unwrap_or(None).unwrap_or_default().len() < 28
|
||||
|| stories.get().unwrap_or(None).unwrap_or_default().len() < 28
|
||||
};
|
||||
|
||||
view! {
|
||||
@@ -86,7 +86,7 @@ pub fn Stories() -> impl IntoView {
|
||||
fallback=move || view! { <p>"Loading..."</p> }
|
||||
set_pending=set_pending.into()
|
||||
>
|
||||
{move || match stories.read() {
|
||||
{move || match stories.get() {
|
||||
None => None,
|
||||
Some(None) => Some(view! { <p>"Error loading stories."</p> }.into_any()),
|
||||
Some(Some(stories)) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn Story() -> impl IntoView {
|
||||
);
|
||||
let meta_description = move || {
|
||||
story
|
||||
.read()
|
||||
.get()
|
||||
.and_then(|story| story.map(|story| story.title))
|
||||
.unwrap_or_else(|| "Loading story...".to_string())
|
||||
};
|
||||
@@ -28,7 +28,7 @@ pub fn Story() -> impl IntoView {
|
||||
<>
|
||||
<Meta name="description" content=meta_description/>
|
||||
<Suspense fallback=|| view! { "Loading..." }>
|
||||
{move || story.read().map(|story| match story {
|
||||
{move || story.get().map(|story| match story {
|
||||
None => view! { <div class="item-view">"Error loading this story."</div> },
|
||||
Some(story) => view! {
|
||||
<div class="item-view">
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn User() -> impl IntoView {
|
||||
view! {
|
||||
<div class="user-view">
|
||||
<Suspense fallback=|| view! { "Loading..." }>
|
||||
{move || user.read().map(|user| match user {
|
||||
{move || user.get().map(|user| match user {
|
||||
None => view! { <h1>"User not found."</h1> }.into_view(),
|
||||
Some(user) => view! {
|
||||
<div>
|
||||
|
||||
@@ -323,4 +323,9 @@ a {
|
||||
|
||||
.user-view .links a {
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
.routing-progress, .routing-progress progress {
|
||||
height: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -17,6 +17,14 @@ where
|
||||
let abort_controller = web_sys::AbortController::new().ok();
|
||||
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
|
||||
|
||||
// abort in-flight requests if the Scope is disposed
|
||||
// i.e., if we've navigated away from this page
|
||||
leptos::on_cleanup(move || {
|
||||
if let Some(abort_controller) = abort_controller {
|
||||
abort_controller.abort()
|
||||
}
|
||||
});
|
||||
|
||||
let json = gloo_net::http::Request::get(path)
|
||||
.abort_signal(abort_signal.as_ref())
|
||||
.send()
|
||||
@@ -27,12 +35,6 @@ where
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
// abort in-flight requests if e.g., we've navigated away from this page
|
||||
leptos::on_cleanup(move || {
|
||||
if let Some(abort_controller) = abort_controller {
|
||||
abort_controller.abort()
|
||||
}
|
||||
});
|
||||
T::de(&json).ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn Nav() -> impl IntoView {
|
||||
<A href="/job">
|
||||
<strong>"Jobs"</strong>
|
||||
</A>
|
||||
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer">
|
||||
<a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
|
||||
"Built with Leptos"
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn Stories() -> impl IntoView {
|
||||
|
||||
let hide_more_link = move || {
|
||||
pending()
|
||||
|| stories.read().unwrap_or(None).unwrap_or_default().len() < 28
|
||||
|| stories.get().unwrap_or(None).unwrap_or_default().len() < 28
|
||||
};
|
||||
|
||||
view! {
|
||||
@@ -86,7 +86,7 @@ pub fn Stories() -> impl IntoView {
|
||||
fallback=move || view! { <p>"Loading..."</p> }
|
||||
set_pending=set_pending.into()
|
||||
>
|
||||
{move || match stories.read() {
|
||||
{move || match stories.get() {
|
||||
None => None,
|
||||
Some(None) => Some(view! { <p>"Error loading stories."</p> }.into_any()),
|
||||
Some(Some(stories)) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn Story() -> impl IntoView {
|
||||
);
|
||||
let meta_description = move || {
|
||||
story
|
||||
.read()
|
||||
.get()
|
||||
.and_then(|story| story.map(|story| story.title))
|
||||
.unwrap_or_else(|| "Loading story...".to_string())
|
||||
};
|
||||
@@ -28,7 +28,7 @@ pub fn Story() -> impl IntoView {
|
||||
<>
|
||||
<Meta name="description" content=meta_description/>
|
||||
<Suspense fallback=|| view! { "Loading..." }>
|
||||
{move || story.read().map(|story| match story {
|
||||
{move || story.get().map(|story| match story {
|
||||
None => view! { <div class="item-view">"Error loading this story."</div> },
|
||||
Some(story) => view! {
|
||||
<div class="item-view">
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn User() -> impl IntoView {
|
||||
view! {
|
||||
<div class="user-view">
|
||||
<Suspense fallback=|| view! { "Loading..." }>
|
||||
{move || user.read().map(|user| match user {
|
||||
{move || user.get().map(|user| match user {
|
||||
None => view! { <h1>"User not found."</h1> }.into_any(),
|
||||
Some(user) => view! {
|
||||
<div>
|
||||
|
||||
3
examples/hackernews_islands/.cargo/config.wasm.toml
Normal file
3
examples/hackernews_islands/.cargo/config.wasm.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[unstable]
|
||||
build-std = ["std", "panic_abort", "core", "alloc"]
|
||||
build-std-features = ["panic_immediate_abort"]
|
||||
1
examples/hackernews_islands/.dockerignore
Normal file
1
examples/hackernews_islands/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
target
|
||||
13
examples/hackernews_islands/.gitignore
vendored
Normal file
13
examples/hackernews_islands/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
.direnv
|
||||
|
||||
fly.toml
|
||||
112
examples/hackernews_islands/Cargo.toml
Normal file
112
examples/hackernews_islands/Cargo.toml
Normal file
@@ -0,0 +1,112 @@
|
||||
[package]
|
||||
name = "hackernews"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
[dependencies]
|
||||
actix-files = { version = "0.6", optional = true }
|
||||
actix-web = { version = "4", optional = true, features = ["macros"] }
|
||||
console_log = "1"
|
||||
console_error_panic_hook = "0.1"
|
||||
cfg-if = "1"
|
||||
leptos = { git = "https://github.com/leptos-rs/leptos", branch = "islands", features = [
|
||||
"islands",
|
||||
"nightly",
|
||||
] }
|
||||
leptos_meta = { git = "https://github.com/leptos-rs/leptos", branch = "islands", features = [
|
||||
"nightly",
|
||||
] }
|
||||
leptos_router = { git = "https://github.com/leptos-rs/leptos", branch = "islands", features = [
|
||||
"nightly",
|
||||
] }
|
||||
leptos_actix = { git = "https://github.com/leptos-rs/leptos", branch = "islands", optional = true, features = [
|
||||
#"nonce",
|
||||
"islands",
|
||||
] }
|
||||
log = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.11", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"json",
|
||||
] }
|
||||
tracing = "0.1"
|
||||
# openssl = { version = "0.10", features = ["v110"] }
|
||||
wasm-bindgen = "0.2"
|
||||
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
|
||||
wee_alloc = "0.4.5"
|
||||
|
||||
[features]
|
||||
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
||||
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",
|
||||
]
|
||||
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = 'z'
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["actix-files", "actix-web", "leptos_actix"]
|
||||
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"
|
||||
# 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 = "0.0.0.0:8080"
|
||||
#site-addr = "127.0.0.1:3004"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3005
|
||||
# [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
|
||||
|
||||
lib-profile-release = "wasm-release"
|
||||
25
examples/hackernews_islands/Dockerfile
Normal file
25
examples/hackernews_islands/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM rustlang/rust:nightly-bullseye as builder
|
||||
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||
#RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||
#RUN cp cargo-binstall /usr/local/cargo/bin
|
||||
#RUN cargo binstall cargo-leptos -y
|
||||
#RUN rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu
|
||||
#RUN rustup target add wasm32-unknown-unknown
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release --no-default-features --features=ssr
|
||||
RUN ls -l /app/target
|
||||
|
||||
FROM rustlang/rust:nightly-bullseye as runner
|
||||
COPY --from=builder /app/target/release/hackernews /app/
|
||||
COPY --from=builder /app/pkg /app
|
||||
COPY --from=builder /app/Cargo.toml /app/
|
||||
WORKDIR /app
|
||||
ENV RUST_LOG="info"
|
||||
ENV LEPTOS_OUTPUT_NAME="hackernews"
|
||||
ENV APP_ENVIRONMENT="production"
|
||||
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
|
||||
ENV LEPTOS_SITE_ROOT="site"
|
||||
EXPOSE 8080
|
||||
CMD ["/app/hackernews"]
|
||||
21
examples/hackernews_islands/LICENSE
Normal file
21
examples/hackernews_islands/LICENSE
Normal 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.
|
||||
1
examples/hackernews_islands/Makefile.toml
Normal file
1
examples/hackernews_islands/Makefile.toml
Normal file
@@ -0,0 +1 @@
|
||||
extend = [{ path = "../cargo-make/main.toml" }]
|
||||
43
examples/hackernews_islands/README.md
Normal file
43
examples/hackernews_islands/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Leptos Hacker News Example
|
||||
|
||||
This example creates a basic clone of the Hacker News site. It showcases Leptos' ability to create both a client-side rendered app, and a server side rendered app with hydration, in a single repository
|
||||
|
||||
## Client Side Rendering
|
||||
To run it as a Client Side App, you can issue `trunk serve --open` in the root. This will build the entire
|
||||
app into one CSR bundle. Make sure you have trunk installed with `cargo install trunk`.
|
||||
|
||||
## 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
|
||||
```
|
||||
11
examples/hackernews_islands/build-front.sh
Executable file
11
examples/hackernews_islands/build-front.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
mv .cargo/config.wasm.toml .cargo/config.toml
|
||||
wasm-pack build --target=web --features=hydrate --release
|
||||
cd pkg
|
||||
rm *.br
|
||||
cp hackernews.js hackernews.unmin.js
|
||||
cat hackernews.unmin.js | esbuild > hackernews.js
|
||||
brotli hackernews.js
|
||||
brotli hackernews_bg.wasm
|
||||
brotli style.css
|
||||
cd ..
|
||||
mv .cargo/config.toml .cargo/config.wasm.toml
|
||||
15
examples/hackernews_islands/fly.toml
Normal file
15
examples/hackernews_islands/fly.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
# fly.toml app configuration file generated for leptos-hackernews-islands on 2023-07-27T08:08:20-04:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = "leptos-hackernews-islands"
|
||||
primary_region = "bos"
|
||||
|
||||
[http_service]
|
||||
internal_port = 8080
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
processes = ["app"]
|
||||
8
examples/hackernews_islands/index.html
Normal file
8
examples/hackernews_islands/index.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" data-cargo-features="csr"/>
|
||||
<link data-trunk rel="css" href="./style.css"/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
BIN
examples/hackernews_islands/public/favicon.ico
Normal file
BIN
examples/hackernews_islands/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
examples/hackernews_islands/public/style.css
Normal file
1
examples/hackernews_islands/public/style.css
Normal file
@@ -0,0 +1 @@
|
||||
body{color:#1e1333;background-color:#f2f3f5;margin:0;padding-top:55px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-size:15px;overflow-y:scroll}a{color:#1e1333;text-decoration:none}.header{z-index:999;background-color:#1e1333;height:55px;position:fixed;top:0;left:0;right:0}.header .inner{box-sizing:border-box;max-width:800px;margin:0 auto;padding:15px 5px}.header a{color:#fffc;vertical-align:middle;letter-spacing:.075em;margin-right:1.8em;font-weight:300;line-height:24px;transition:color .15s;display:inline-block}.header a:hover{color:#fff}.header a.active{color:#fff;font-weight:400}.header a:nth-child(6){margin-right:0}.header .github{color:#fff;float:right;margin:0;font-size:.9em}.logo{vertical-align:middle;width:24px;margin-right:10px;display:inline-block}.view{max-width:800px;margin:0 auto;position:relative}.fade-enter-active,.fade-exit-active{transition:all .2s}.fade-enter,.fade-exit-active{opacity:0}@media (max-width:860px){.header .inner{padding:15px 30px}}@media (max-width:600px){.header .inner{padding:15px}.header a{margin-right:1em}.header .github{display:none}}.news-view{padding-top:45px}.news-list,.news-list-nav{background-color:#fff;border-radius:2px}.news-list-nav{text-align:center;z-index:998;padding:15px 30px;position:fixed;top:55px;left:0;right:0;box-shadow:0 1px 2px #0000001a}.news-list-nav .page-link{margin:0 1em}.news-list-nav .disabled{color:#aaa}.news-list{width:100%;margin:30px 0;transition:all .5s cubic-bezier(.55,0,.1,1);position:absolute}.news-list ul{margin:0;padding:0;list-style-type:none}@media (max-width:600px){.news-list{margin:10px 0}}.news-item{background-color:#fff;border-bottom:1px solid #eee;padding:20px 30px 20px 80px;line-height:20px;position:relative}.news-item .score{color:#1e1333;text-align:center;width:80px;margin-top:-10px;font-size:1.1em;font-weight:700;position:absolute;top:50%;left:0}.news-item .host,.news-item .meta{color:#626262;font-size:.85em}.news-item .host a,.news-item .meta a{color:#626262;text-decoration:underline}.news-item .host a:hover,.news-item .meta a:hover{color:#1e1333}.item-view-header{background-color:#fff;padding:1.8em 2em 1em;box-shadow:0 1px 2px #0000001a}.item-view-header h1{margin:0 .5em 0 0;font-size:1.5em;display:inline}.item-view-header .host,.item-view-header .meta,.item-view-header .meta a{color:#626262}.item-view-header .meta a{text-decoration:underline}.item-view-comments{background-color:#fff;margin-top:10px;padding:0 2em .5em}.item-view-comments-header{margin:0;padding:1em 0;font-size:1.1em;position:relative}.item-view-comments-header .spinner{margin:-15px 0;display:inline-block}.comment-children{margin:0;padding:0;list-style-type:none}@media (max-width:600px){.item-view-header h1{font-size:1.25em}}.comment-children .comment-children{margin-left:1.5em}.comment{border-top:1px solid #eee;position:relative}.comment .by,.comment .text,.comment .toggle{margin:1em 0;font-size:.9em}.comment .by{color:#626262}.comment .by a{color:#626262;text-decoration:underline}.comment .text{overflow-wrap:break-word}.comment .text a:hover{color:#1e1333}.comment .text pre{white-space:pre-wrap}.comment .toggle{background-color:#fffbf2;border-radius:4px;padding:.3em .5em}.comment .toggle a{color:#626262;cursor:pointer}.comment .toggle.open{background-color:#0000;margin-bottom:-.5em;padding:0}.user-view{box-sizing:border-box;background-color:#fff;padding:2em 3em}.user-view h1{margin:0;font-size:1.5em}.user-view .meta{padding:0;list-style-type:none}.user-view .label{min-width:4em;display:inline-block}.user-view .about{margin:1em 0}.user-view .links a{text-decoration:underline}leptos-island,leptos-children{display:contents}
|
||||
64
examples/hackernews_islands/src/api.rs
Normal file
64
examples/hackernews_islands/src/api.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use leptos::Serializable;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn story(path: &str) -> String {
|
||||
format!("https://node-hnapi.herokuapp.com/{path}")
|
||||
}
|
||||
|
||||
pub fn user(path: &str) -> String {
|
||||
format!("https://hacker-news.firebaseio.com/v0/user/{path}.json")
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn fetch_api<T>(path: &str) -> Option<T>
|
||||
where
|
||||
T: Serializable,
|
||||
{
|
||||
use actix_web::web;
|
||||
let path = path.to_string();
|
||||
leptos_actix::extract(|client: web::Data<reqwest::Client>| async move {
|
||||
let client = client.into_inner();
|
||||
let json = client.get(&path).send().await.ok()?.text().await.ok()?;
|
||||
T::de(&json).map_err(|e| log::error!("{e}")).ok()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
|
||||
pub struct Story {
|
||||
pub id: usize,
|
||||
pub title: String,
|
||||
pub points: Option<i32>,
|
||||
pub user: Option<String>,
|
||||
pub time: usize,
|
||||
pub time_ago: String,
|
||||
#[serde(alias = "type")]
|
||||
pub story_type: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub domain: String,
|
||||
#[serde(default)]
|
||||
pub comments: Option<Vec<Comment>>,
|
||||
pub comments_count: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
|
||||
pub struct Comment {
|
||||
pub id: usize,
|
||||
pub level: usize,
|
||||
pub user: Option<String>,
|
||||
pub time: usize,
|
||||
pub time_ago: String,
|
||||
pub content: Option<String>,
|
||||
pub comments: Vec<Comment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
|
||||
pub struct User {
|
||||
pub created: usize,
|
||||
pub id: String,
|
||||
pub karma: i32,
|
||||
pub about: Option<String>,
|
||||
}
|
||||
50
examples/hackernews_islands/src/lib.rs
Normal file
50
examples/hackernews_islands/src/lib.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
#![feature(lazy_cell)]
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
mod api;
|
||||
mod routes;
|
||||
use routes::{nav::*, stories::*, story::*, users::*};
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/style.css"/>
|
||||
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
|
||||
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
|
||||
<Router>
|
||||
<Nav />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="users/:id" view=User ssr=SsrMode::InOrder/>
|
||||
<Route path="stories/:id" view=Story ssr=SsrMode::InOrder/>
|
||||
<Route path=":stories?" view=Stories ssr=SsrMode::InOrder/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
extern crate wee_alloc;
|
||||
|
||||
// Use `wee_alloc` as the global allocator.
|
||||
#[global_allocator]
|
||||
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
#[cfg(debug_assertions)]
|
||||
console_error_panic_hook::set_once();
|
||||
leptos::leptos_dom::HydrationCtx::stop_hydrating();
|
||||
}
|
||||
}
|
||||
}
|
||||
84
examples/hackernews_islands/src/main.rs
Normal file
84
examples/hackernews_islands/src/main.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
|
||||
static CSS: &[u8] = include_bytes!("../pkg/style.css.br");
|
||||
static JS: &[u8] = include_bytes!("../pkg/hackernews.js.br");
|
||||
static WASM: &[u8] = include_bytes!("../pkg/hackernews_bg.wasm.br");
|
||||
|
||||
// boilerplate to run in different modes
|
||||
cfg_if! {
|
||||
// server-only stuff
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use actix_files::{Files};
|
||||
use actix_web::*;
|
||||
use hackernews::{App};
|
||||
use leptos_actix::{LeptosRoutes, generate_route_list};
|
||||
|
||||
#[get("/style.css")]
|
||||
async fn css() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.append_header(("content-encoding", "br"))
|
||||
.content_type("text/css")
|
||||
.body(CSS)
|
||||
}
|
||||
|
||||
#[get("/pkg/hackernews.js")]
|
||||
async fn js() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.append_header(("content-encoding", "br"))
|
||||
.content_type("text/javascript")
|
||||
.body(JS)
|
||||
}
|
||||
|
||||
#[get("/pkg/hackernews_bg.wasm")]
|
||||
async fn wasm() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.append_header(("content-encoding", "br"))
|
||||
.content_type("application/wasm")
|
||||
.body(WASM)
|
||||
}
|
||||
|
||||
|
||||
#[get("/favicon.ico")]
|
||||
async fn favicon() -> impl Responder {
|
||||
actix_files::NamedFile::open_async("./public/favicon.ico").await
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// Setting this to None means we'll be using cargo-leptos and its env vars.
|
||||
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
|
||||
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
// Generate the list of routes in your Leptos App
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
HttpServer::new(move || {
|
||||
let leptos_options = &conf.leptos_options;
|
||||
let site_root = &leptos_options.site_root;
|
||||
|
||||
App::new()
|
||||
.service(css)
|
||||
.service(favicon)
|
||||
.service(js)
|
||||
.service(wasm)
|
||||
.app_data(actix_web::web::Data::new(reqwest::Client::new()))
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
|
||||
.service(Files::new("/", site_root))
|
||||
.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
fn main() {
|
||||
use hackernews::{App};
|
||||
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(App)
|
||||
}
|
||||
}
|
||||
}
|
||||
4
examples/hackernews_islands/src/routes.rs
Normal file
4
examples/hackernews_islands/src/routes.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod nav;
|
||||
pub mod stories;
|
||||
pub mod story;
|
||||
pub mod users;
|
||||
30
examples/hackernews_islands/src/routes/nav.rs
Normal file
30
examples/hackernews_islands/src/routes/nav.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use leptos::{component, view, IntoView};
|
||||
use leptos_router::*;
|
||||
|
||||
#[component]
|
||||
pub fn Nav() -> impl IntoView {
|
||||
view! {
|
||||
<header class="header">
|
||||
<nav class="inner">
|
||||
<A href="/home">
|
||||
<strong>"HN"</strong>
|
||||
</A>
|
||||
<A href="/new">
|
||||
<strong>"New"</strong>
|
||||
</A>
|
||||
<A href="/show">
|
||||
<strong>"Show"</strong>
|
||||
</A>
|
||||
<A href="/ask">
|
||||
<strong>"Ask"</strong>
|
||||
</A>
|
||||
<A href="/job">
|
||||
<strong>"Jobs"</strong>
|
||||
</A>
|
||||
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer">
|
||||
"Built with Leptos"
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
164
examples/hackernews_islands/src/routes/stories.rs
Normal file
164
examples/hackernews_islands/src/routes/stories.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use crate::api;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
fn category(from: &str) -> String {
|
||||
match from {
|
||||
"new" => "newest",
|
||||
"show" => "show",
|
||||
"ask" => "ask",
|
||||
"job" => "jobs",
|
||||
_ => "news",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[server(FetchStories, "/api")]
|
||||
pub async fn fetch_stories(
|
||||
story_type: String,
|
||||
page: usize,
|
||||
) -> Result<Vec<api::Story>, ServerFnError> {
|
||||
let path = format!("{}?page={}", category(&story_type), page);
|
||||
Ok(api::fetch_api::<Vec<api::Story>>(&api::story(&path))
|
||||
.await
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Stories() -> impl IntoView {
|
||||
let query = use_query_map();
|
||||
let params = use_params_map();
|
||||
let page = move || {
|
||||
query
|
||||
.with(|q| q.get("page").and_then(|page| page.parse::<usize>().ok()))
|
||||
.unwrap_or(1)
|
||||
};
|
||||
let story_type = move || {
|
||||
params
|
||||
.with(|p| p.get("stories").cloned())
|
||||
.unwrap_or_else(|| "top".to_string())
|
||||
};
|
||||
let stories = create_resource(
|
||||
move || (page(), story_type()),
|
||||
move |(page, story_type)| fetch_stories(category(&story_type), page),
|
||||
);
|
||||
let (pending, set_pending) = create_signal(false);
|
||||
|
||||
let hide_more_link = move || {
|
||||
pending()
|
||||
|| stories
|
||||
.with(|stories| {
|
||||
stories.as_ref().map(|s| s.len() < 28).unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
view! {
|
||||
|
||||
<div class="news-view">
|
||||
<div class="news-list-nav">
|
||||
<span>
|
||||
{move || if page() > 1 {
|
||||
view! {
|
||||
<a class="page-link"
|
||||
href=move || format!("/{}?page={}", story_type(), page() - 1)
|
||||
attr:aria_label="Previous Page"
|
||||
>
|
||||
"< prev"
|
||||
</a>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<span class="page-link disabled" aria-hidden="true">
|
||||
"< prev"
|
||||
</span>
|
||||
}.into_any()
|
||||
}}
|
||||
</span>
|
||||
<span>"page " {page}</span>
|
||||
<span class="page-link"
|
||||
class:disabled=hide_more_link
|
||||
aria-hidden=hide_more_link
|
||||
>
|
||||
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
|
||||
aria-label="Next Page"
|
||||
>
|
||||
"more >"
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<main class="news-list">
|
||||
<div>
|
||||
<Transition
|
||||
fallback=|| ()
|
||||
set_pending=set_pending.into()
|
||||
>
|
||||
{move || stories.read().map(|story| story.map(|stories| view! {
|
||||
<ul>
|
||||
<For
|
||||
each=move || stories.clone()
|
||||
key=|story| story.id
|
||||
view=move |story: api::Story| {
|
||||
view! {
|
||||
<Story story/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ul>
|
||||
}.into_any()))}
|
||||
</Transition>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Story(story: api::Story) -> impl IntoView {
|
||||
view! {
|
||||
<li class="news-item">
|
||||
<span class="score">{story.points}</span>
|
||||
<span class="title">
|
||||
{if !story.url.starts_with("item?id=") {
|
||||
view! {
|
||||
<span>
|
||||
<a href=story.url target="_blank" rel="noreferrer">
|
||||
{story.title.clone()}
|
||||
</a>
|
||||
<span class="host">"("{story.domain}")"</span>
|
||||
</span>
|
||||
}.into_view()
|
||||
} else {
|
||||
let title = story.title.clone();
|
||||
view! { <A href=format!("/stories/{}", story.id)>{title.clone()}</A> }.into_view()
|
||||
}}
|
||||
</span>
|
||||
<br />
|
||||
<span class="meta">
|
||||
{if story.story_type != "job" {
|
||||
view! {
|
||||
<span>
|
||||
{"by "}
|
||||
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
|
||||
{format!(" {} | ", story.time_ago)}
|
||||
<A href=format!("/stories/{}", story.id)>
|
||||
{if story.comments_count.unwrap_or_default() > 0 {
|
||||
format!("{} comments", story.comments_count.unwrap_or_default())
|
||||
} else {
|
||||
"discuss".into()
|
||||
}}
|
||||
</A>
|
||||
</span>
|
||||
}.into_view()
|
||||
} else {
|
||||
let title = story.title.clone();
|
||||
view! { <A href=format!("/item/{}", story.id)>{title.clone()}</A> }.into_view()
|
||||
}}
|
||||
</span>
|
||||
{(story.story_type != "link").then(|| view! {
|
||||
" "
|
||||
<span class="label">{story.story_type}</span>
|
||||
})}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
141
examples/hackernews_islands/src/routes/story.rs
Normal file
141
examples/hackernews_islands/src/routes/story.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use crate::api;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[server(FetchStory, "/api")]
|
||||
pub async fn fetch_story(
|
||||
id: String,
|
||||
) -> Result<RefCell<Option<api::Story>>, ServerFnError> {
|
||||
Ok(RefCell::new(
|
||||
api::fetch_api::<api::Story>(&api::story(&format!("item/{id}"))).await,
|
||||
))
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Story() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let story = create_resource(
|
||||
move || params().get("id").cloned().unwrap_or_default(),
|
||||
move |id| async move {
|
||||
if id.is_empty() {
|
||||
Ok(RefCell::new(None))
|
||||
} else {
|
||||
fetch_story(id).await
|
||||
}
|
||||
},
|
||||
);
|
||||
let meta_description = move || {
|
||||
story
|
||||
.with(|story| {
|
||||
story
|
||||
.as_ref()
|
||||
.map(|story| {
|
||||
story.borrow().as_ref().map(|story| story.title.clone())
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.flatten()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "Loading story...".to_string())
|
||||
};
|
||||
|
||||
let story_view = move || {
|
||||
story.with(|story| {
|
||||
story.as_ref().ok().and_then(|story| {
|
||||
let story: Option<api::Story> = story.borrow_mut().take();
|
||||
story.map(|story| {
|
||||
view! {
|
||||
<div class="item-view">
|
||||
<div class="item-view-header">
|
||||
<a href=story.url target="_blank">
|
||||
<h1>{story.title}</h1>
|
||||
</a>
|
||||
<span class="host">
|
||||
"("{story.domain}")"
|
||||
</span>
|
||||
{story.user.map(|user| view! { <p class="meta">
|
||||
{story.points}
|
||||
" points | by "
|
||||
<A href=format!("/users/{user}")>{user.clone()}</A>
|
||||
{format!(" {}", story.time_ago)}
|
||||
</p>})}
|
||||
</div>
|
||||
<div class="item-view-comments">
|
||||
<p class="item-view-comments-header">
|
||||
{if story.comments_count.unwrap_or_default() > 0 {
|
||||
format!("{} comments", story.comments_count.unwrap_or_default())
|
||||
} else {
|
||||
"No comments yet.".into()
|
||||
}}
|
||||
</p>
|
||||
<ul class="comment-children">
|
||||
{story.comments.unwrap_or_default().into_iter()
|
||||
.map(|comment: api::Comment| view! { <Comment comment /> })
|
||||
.collect_view()}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}})})})
|
||||
};
|
||||
|
||||
view! {
|
||||
<Meta name="description" content=meta_description/>
|
||||
<Suspense fallback=|| ()>
|
||||
{story_view}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[island]
|
||||
pub fn Toggle(children: Children) -> impl IntoView {
|
||||
let (open, set_open) = create_signal(true);
|
||||
let children = std::cell::LazyCell::new(|| children().into_view());
|
||||
view! {
|
||||
<div class="toggle" class:open=open>
|
||||
<a
|
||||
on:click=move |_| set_open.update(|n| *n = !*n)
|
||||
onclick // necessary for event bubbling on iOS Safari FML
|
||||
>
|
||||
{move || if open() {
|
||||
"[-]"
|
||||
} else {
|
||||
"[+] comments collapsed"
|
||||
}}
|
||||
</a>
|
||||
</div>
|
||||
<ul
|
||||
class="comment-children"
|
||||
style:display=move || if open() {
|
||||
"block"
|
||||
} else {
|
||||
"none"
|
||||
}
|
||||
>
|
||||
{move || open().then(|| children.clone())}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Comment(comment: api::Comment) -> impl IntoView {
|
||||
view! {
|
||||
<li class="comment">
|
||||
<div class="by">
|
||||
<A href=format!("/users/{}", comment.user.clone().unwrap_or_default())>{comment.user.clone()}</A>
|
||||
{format!(" {}", comment.time_ago)}
|
||||
</div>
|
||||
<div class="text" inner_html=comment.content></div>
|
||||
{(!comment.comments.is_empty()).then(|| {
|
||||
view! {
|
||||
<Toggle>
|
||||
{comment.comments.into_iter()
|
||||
.map(|comment: api::Comment| view! { <Comment comment /> })
|
||||
.collect_view()}
|
||||
</Toggle>
|
||||
}
|
||||
})}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
53
examples/hackernews_islands/src/routes/users.rs
Normal file
53
examples/hackernews_islands/src/routes/users.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::api::{self, User};
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
#[server(FetchUser, "/api")]
|
||||
pub async fn fetch_user(
|
||||
id: String,
|
||||
) -> Result<Option<api::User>, ServerFnError> {
|
||||
Ok(api::fetch_api::<User>(&api::user(&id)).await)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn User() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let user = create_resource(
|
||||
move || params().get("id").cloned().unwrap_or_default(),
|
||||
move |id| async move {
|
||||
if id.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
fetch_user(id).await
|
||||
}
|
||||
},
|
||||
);
|
||||
view! {
|
||||
<div class="user-view">
|
||||
<Suspense fallback=|| ()>
|
||||
{move || user.read().map(|user| user.map(|user| match user {
|
||||
None => view! { <h1>"User not found."</h1> }.into_view(),
|
||||
Some(user) => view! {
|
||||
<div>
|
||||
<h1>"User: " {&user.id}</h1>
|
||||
<ul class="meta">
|
||||
<li>
|
||||
<span class="label">"Created: "</span> {user.created}
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">"Karma: "</span> {user.karma}
|
||||
</li>
|
||||
<li inner_html={user.about} class="about"></li>
|
||||
</ul>
|
||||
<p class="links">
|
||||
<a href=format!("https://news.ycombinator.com/submitted?id={}", user.id)>"submissions"</a>
|
||||
" | "
|
||||
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
|
||||
</p>
|
||||
</div>
|
||||
}.into_view()
|
||||
}))}
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
326
examples/hackernews_islands/style.css
Normal file
326
examples/hackernews_islands/style.css
Normal file
@@ -0,0 +1,326 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
font-size: 15px;
|
||||
background-color: #f2f3f5;
|
||||
margin: 0;
|
||||
padding-top: 55px;
|
||||
color: #1E1333;
|
||||
overflow-y: scroll
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1E1333;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #1E1333;
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
height: 55px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0
|
||||
}
|
||||
|
||||
.header .inner {
|
||||
max-width: 800px;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
padding: 15px 5px
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: rgba(255, 255, 255, .8);
|
||||
line-height: 24px;
|
||||
transition: color .15s ease;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
font-weight: 300;
|
||||
letter-spacing: .075em;
|
||||
margin-right: 1.8em
|
||||
}
|
||||
|
||||
.header a:hover {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.header a.active {
|
||||
color: #fff;
|
||||
font-weight: 400
|
||||
}
|
||||
|
||||
.header a:nth-child(6) {
|
||||
margin-right: 0
|
||||
}
|
||||
|
||||
.header .github {
|
||||
color: #fff;
|
||||
font-size: .9em;
|
||||
margin: 0;
|
||||
float: right
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 24px;
|
||||
margin-right: 10px;
|
||||
display: inline-block;
|
||||
vertical-align: middle
|
||||
}
|
||||
|
||||
.view {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
position: relative
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-exit-active {
|
||||
transition: all .2s ease
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-exit-active {
|
||||
opacity: 0
|
||||
}
|
||||
|
||||
@media (max-width:860px) {
|
||||
.header .inner {
|
||||
padding: 15px 30px
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:600px) {
|
||||
.header .inner {
|
||||
padding: 15px
|
||||
}
|
||||
|
||||
.header a {
|
||||
margin-right: 1em
|
||||
}
|
||||
|
||||
.header .github {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
|
||||
.news-view {
|
||||
padding-top: 45px
|
||||
}
|
||||
|
||||
.news-list,
|
||||
.news-list-nav {
|
||||
background-color: #fff;
|
||||
border-radius: 2px
|
||||
}
|
||||
|
||||
.news-list-nav {
|
||||
padding: 15px 30px;
|
||||
position: fixed;
|
||||
text-align: center;
|
||||
top: 55px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 998;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .1)
|
||||
}
|
||||
|
||||
.news-list-nav .page-link {
|
||||
margin: 0 1em
|
||||
}
|
||||
|
||||
.news-list-nav .disabled {
|
||||
color: #aaa
|
||||
}
|
||||
|
||||
.news-list {
|
||||
position: absolute;
|
||||
margin: 30px 0;
|
||||
width: 100%;
|
||||
transition: all .5s cubic-bezier(.55, 0, .1, 1)
|
||||
}
|
||||
|
||||
.news-list ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
@media (max-width:600px) {
|
||||
.news-list {
|
||||
margin: 10px 0
|
||||
}
|
||||
}
|
||||
|
||||
.news-item {
|
||||
background-color: #fff;
|
||||
padding: 20px 30px 20px 80px;
|
||||
border-bottom: 1px solid #eee;
|
||||
position: relative;
|
||||
line-height: 20px
|
||||
}
|
||||
|
||||
.news-item .score {
|
||||
color: #1E1333;
|
||||
font-size: 1.1em;
|
||||
font-weight: 700;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
margin-top: -10px
|
||||
}
|
||||
|
||||
.news-item .host,
|
||||
.news-item .meta {
|
||||
font-size: .85em;
|
||||
color: #626262
|
||||
}
|
||||
|
||||
.news-item .host a,
|
||||
.news-item .meta a {
|
||||
color: #626262;
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
.news-item .host a:hover,
|
||||
.news-item .meta a:hover {
|
||||
color: #1E1333
|
||||
}
|
||||
|
||||
.item-view-header {
|
||||
background-color: #fff;
|
||||
padding: 1.8em 2em 1em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .1)
|
||||
}
|
||||
|
||||
.item-view-header h1 {
|
||||
display: inline;
|
||||
font-size: 1.5em;
|
||||
margin: 0;
|
||||
margin-right: .5em
|
||||
}
|
||||
|
||||
.item-view-header .host,
|
||||
.item-view-header .meta,
|
||||
.item-view-header .meta a {
|
||||
color: #626262
|
||||
}
|
||||
|
||||
.item-view-header .meta a {
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
.item-view-comments {
|
||||
background-color: #fff;
|
||||
margin-top: 10px;
|
||||
padding: 0 2em .5em
|
||||
}
|
||||
|
||||
.item-view-comments-header {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
padding: 1em 0;
|
||||
position: relative
|
||||
}
|
||||
|
||||
.item-view-comments-header .spinner {
|
||||
display: inline-block;
|
||||
margin: -15px 0
|
||||
}
|
||||
|
||||
.comment-children {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
@media (max-width:600px) {
|
||||
.item-view-header h1 {
|
||||
font-size: 1.25em
|
||||
}
|
||||
}
|
||||
|
||||
.comment-children .comment-children {
|
||||
margin-left: 1.5em
|
||||
}
|
||||
|
||||
.comment {
|
||||
border-top: 1px solid #eee;
|
||||
position: relative
|
||||
}
|
||||
|
||||
.comment .by,
|
||||
.comment .text,
|
||||
.comment .toggle {
|
||||
font-size: .9em;
|
||||
margin: 1em 0
|
||||
}
|
||||
|
||||
.comment .by {
|
||||
color: #626262
|
||||
}
|
||||
|
||||
.comment .by a {
|
||||
color: #626262;
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
.comment .text {
|
||||
overflow-wrap: break-word
|
||||
}
|
||||
|
||||
.comment .text a:hover {
|
||||
color: #1E1333
|
||||
}
|
||||
|
||||
.comment .text pre {
|
||||
white-space: pre-wrap
|
||||
}
|
||||
|
||||
.comment .toggle {
|
||||
background-color: #fffbf2;
|
||||
padding: .3em .5em;
|
||||
border-radius: 4px
|
||||
}
|
||||
|
||||
.comment .toggle a {
|
||||
color: #626262;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.comment .toggle.open {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
margin-bottom: -.5em
|
||||
}
|
||||
|
||||
.user-view {
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
padding: 2em 3em
|
||||
}
|
||||
|
||||
.user-view h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5em
|
||||
}
|
||||
|
||||
.user-view .meta {
|
||||
list-style-type: none;
|
||||
padding: 0
|
||||
}
|
||||
|
||||
.user-view .label {
|
||||
display: inline-block;
|
||||
min-width: 4em
|
||||
}
|
||||
|
||||
.user-view .about {
|
||||
margin: 1em 0
|
||||
}
|
||||
|
||||
.user-view .links a {
|
||||
text-decoration: underline
|
||||
}
|
||||
3
examples/hackernews_islands_axum/.cargo/config.wasm.toml
Normal file
3
examples/hackernews_islands_axum/.cargo/config.wasm.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[unstable]
|
||||
build-std = ["std", "panic_abort", "core", "alloc"]
|
||||
build-std-features = ["panic_immediate_abort"]
|
||||
112
examples/hackernews_islands_axum/Cargo.toml
Normal file
112
examples/hackernews_islands_axum/Cargo.toml
Normal file
@@ -0,0 +1,112 @@
|
||||
[package]
|
||||
name = "hackernews"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
|
||||
[dependencies]
|
||||
console_log = "1.0.0"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
cfg-if = "1.0.0"
|
||||
leptos = { git = "https://github.com/leptos-rs/leptos", rev = "1eba12f", features = [
|
||||
"nightly",
|
||||
"islands",
|
||||
] }
|
||||
leptos_meta = { git = "https://github.com/leptos-rs/leptos", rev = "1eba12f", features = [
|
||||
"nightly",
|
||||
] }
|
||||
leptos_axum = { git = "https://github.com/leptos-rs/leptos", rev = "1eba12f", optional = true, features = [
|
||||
"islands",
|
||||
] }
|
||||
leptos_router = { git = "https://github.com/leptos-rs/leptos", rev = "1eba12f", features = [
|
||||
"nightly",
|
||||
] }
|
||||
log = "0.4.17"
|
||||
simple_logger = "4.0.0"
|
||||
serde = { version = "1.0.148", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
gloo-net = { version = "0.2.5", features = ["http"] }
|
||||
reqwest = { version = "0.11.13", features = ["json"] }
|
||||
axum = { version = "0.6.1", optional = true, features = ["http2"] }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.4", features = [
|
||||
"fs",
|
||||
"compression-br",
|
||||
], optional = true }
|
||||
tokio = { version = "1.22.0", features = ["full"], optional = true }
|
||||
http = { version = "0.2.8", optional = true }
|
||||
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wee_alloc = "0.4.5"
|
||||
lazy_static = "1.4.0"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:tokio",
|
||||
"dep:http",
|
||||
"leptos/ssr",
|
||||
"leptos_axum",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
denylist = ["axum", "tower", "tower-http", "tokio", "http", "leptos_axum"]
|
||||
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "hackernews"
|
||||
# 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"
|
||||
site-addr = "0.0.0.0:8080"
|
||||
# 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
|
||||
25
examples/hackernews_islands_axum/Dockerfile
Normal file
25
examples/hackernews_islands_axum/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM rustlang/rust:nightly-bullseye as builder
|
||||
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||
#RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||
#RUN cp cargo-binstall /usr/local/cargo/bin
|
||||
#RUN cargo binstall cargo-leptos -y
|
||||
#RUN rustup component add rust-src --toolchain nightly-x86_64-unknown-linux-gnu
|
||||
#RUN rustup target add wasm32-unknown-unknown
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release --no-default-features --features=ssr
|
||||
RUN ls -l /app/target
|
||||
|
||||
FROM rustlang/rust:nightly-bullseye as runner
|
||||
COPY --from=builder /app/target/release/hackernews /app/
|
||||
COPY --from=builder /app/pkg /app
|
||||
COPY --from=builder /app/Cargo.toml /app/
|
||||
WORKDIR /app
|
||||
ENV RUST_LOG="info"
|
||||
ENV LEPTOS_OUTPUT_NAME="hackernews"
|
||||
ENV APP_ENVIRONMENT="production"
|
||||
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
|
||||
ENV LEPTOS_SITE_ROOT="site"
|
||||
EXPOSE 8080
|
||||
CMD ["/app/hackernews"]
|
||||
21
examples/hackernews_islands_axum/LICENSE
Normal file
21
examples/hackernews_islands_axum/LICENSE
Normal 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.
|
||||
1
examples/hackernews_islands_axum/Makefile.toml
Normal file
1
examples/hackernews_islands_axum/Makefile.toml
Normal file
@@ -0,0 +1 @@
|
||||
extend = [{ path = "../cargo-make/main.toml" }]
|
||||
43
examples/hackernews_islands_axum/README.md
Normal file
43
examples/hackernews_islands_axum/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Leptos Hacker News Example with Axum
|
||||
|
||||
This example creates a basic clone of the Hacker News site. It showcases Leptos' ability to create both a client-side rendered app, and a server side rendered app with hydration, in a single repository. This repo differs from the main Hacker News example by using Axum as it's server.
|
||||
|
||||
## Client Side Rendering
|
||||
To run it as a Client Side App, you can issue `trunk serve --open` in the root. This will build the entire
|
||||
app into one CSR bundle. Make sure you have trunk installed with `cargo install trunk`.
|
||||
|
||||
## 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
|
||||
```
|
||||
8
examples/hackernews_islands_axum/build-front.sh
Executable file
8
examples/hackernews_islands_axum/build-front.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
wasm-pack build --target=web --features=hydrate --release
|
||||
cd pkg
|
||||
rm *.br
|
||||
cp hackernews.js hackernews.unmin.js
|
||||
cat hackernews.unmin.js | esbuild > hackernews.js
|
||||
brotli hackernews.js
|
||||
brotli hackernews_bg.wasm
|
||||
brotli style.css
|
||||
8
examples/hackernews_islands_axum/index.html
Normal file
8
examples/hackernews_islands_axum/index.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z"/>
|
||||
<link data-trunk rel="css" href="/style.css"/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
BIN
examples/hackernews_islands_axum/public/favicon.ico
Normal file
BIN
examples/hackernews_islands_axum/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
60
examples/hackernews_islands_axum/src/api.rs
Normal file
60
examples/hackernews_islands_axum/src/api.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use leptos::Serializable;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn story(path: &str) -> String {
|
||||
format!("https://node-hnapi.herokuapp.com/{path}")
|
||||
}
|
||||
|
||||
pub fn user(path: &str) -> String {
|
||||
format!("https://hacker-news.firebaseio.com/v0/user/{path}.json")
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CLIENT: reqwest::Client = reqwest::Client::new();
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn fetch_api<T>(path: &str) -> Option<T>
|
||||
where
|
||||
T: Serializable,
|
||||
{
|
||||
let json = CLIENT.get(path).send().await.ok()?.text().await.ok()?;
|
||||
T::de(&json).map_err(|e| log::error!("{e}")).ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
|
||||
pub struct Story {
|
||||
pub id: usize,
|
||||
pub title: String,
|
||||
pub points: Option<i32>,
|
||||
pub user: Option<String>,
|
||||
pub time: usize,
|
||||
pub time_ago: String,
|
||||
#[serde(alias = "type")]
|
||||
pub story_type: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub domain: String,
|
||||
#[serde(default)]
|
||||
pub comments: Option<Vec<Comment>>,
|
||||
pub comments_count: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
|
||||
pub struct Comment {
|
||||
pub id: usize,
|
||||
pub level: usize,
|
||||
pub user: Option<String>,
|
||||
pub time: usize,
|
||||
pub time_ago: String,
|
||||
pub content: Option<String>,
|
||||
pub comments: Vec<Comment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
|
||||
pub struct User {
|
||||
pub created: usize,
|
||||
pub id: String,
|
||||
pub karma: i32,
|
||||
pub about: Option<String>,
|
||||
}
|
||||
28
examples/hackernews_islands_axum/src/error_template.rs
Normal file
28
examples/hackernews_islands_axum/src/error_template.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use leptos::{view, Errors, For, IntoView, RwSignal, View};
|
||||
|
||||
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
|
||||
// here than just displaying them
|
||||
pub fn error_template(errors: Option<RwSignal<Errors>>) -> View {
|
||||
let Some(errors) = errors else {
|
||||
panic!("No Errors found and we expected errors!");
|
||||
};
|
||||
|
||||
view! {
|
||||
<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 | (_, error)| {
|
||||
let error_string = error.to_string();
|
||||
view! {
|
||||
|
||||
<p>"Error: " {error_string}</p>
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
.into_view()
|
||||
}
|
||||
44
examples/hackernews_islands_axum/src/fallback.rs
Normal file
44
examples/hackernews_islands_axum/src/fallback.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use axum::{
|
||||
body::{boxed, Body, BoxBody},
|
||||
extract::State,
|
||||
response::IntoResponse,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
use leptos::{LeptosOptions};
|
||||
use crate::error_template::error_template;
|
||||
|
||||
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else{
|
||||
let handler = leptos_axum::render_app_to_stream(options.to_owned(), || error_template( None));
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
|
||||
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root).oneshot(req).await {
|
||||
Ok(res) => Ok(res.map(boxed)),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {}", err),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
63
examples/hackernews_islands_axum/src/handlers.rs
Normal file
63
examples/hackernews_islands_axum/src/handlers.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use axum::{
|
||||
body::{boxed, Body, BoxBody},
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
pub async fn file_handler(uri: Uri) -> Result<Response<BoxBody>, (StatusCode, String)> {
|
||||
let res = get_static_file(uri.clone(), "/pkg").await?;
|
||||
|
||||
if res.status() == StatusCode::NOT_FOUND {
|
||||
// try with `.html`
|
||||
// TODO: handle if the Uri has query parameters
|
||||
match format!("{}.html", uri).parse() {
|
||||
Ok(uri_html) => get_static_file(uri_html, "/pkg").await,
|
||||
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid URI".to_string())),
|
||||
}
|
||||
} else {
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_static_file_handler(uri: Uri) -> Result<Response<BoxBody>, (StatusCode, String)> {
|
||||
let res = get_static_file(uri.clone(), "/static").await?;
|
||||
|
||||
if res.status() == StatusCode::NOT_FOUND {
|
||||
Err((StatusCode::INTERNAL_SERVER_ERROR, "Invalid URI".to_string()))
|
||||
} else {
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(uri: Uri, base: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
|
||||
let req = Request::builder().uri(&uri).body(Body::empty()).unwrap();
|
||||
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// When run normally, the root should be the crate root
|
||||
if base == "/static" {
|
||||
match ServeDir::new("./static").oneshot(req).await {
|
||||
Ok(res) => Ok(res.map(boxed)),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {}", err),
|
||||
))
|
||||
}
|
||||
} else if base == "/pkg" {
|
||||
match ServeDir::new("./pkg").oneshot(req).await {
|
||||
Ok(res) => Ok(res.map(boxed)),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {}", err),
|
||||
)),
|
||||
}
|
||||
} else{
|
||||
Err((StatusCode::NOT_FOUND, "Not Found".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
examples/hackernews_islands_axum/src/lib.rs
Normal file
50
examples/hackernews_islands_axum/src/lib.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
#![feature(lazy_cell)]
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
mod api;
|
||||
mod routes;
|
||||
use routes::{nav::*, stories::*, story::*, users::*};
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<Stylesheet id="leptos" href="/style.css"/>
|
||||
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
|
||||
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
|
||||
<Router>
|
||||
<Nav />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="users/:id" view=User ssr=SsrMode::InOrder/>
|
||||
<Route path="stories/:id" view=Story ssr=SsrMode::InOrder/>
|
||||
<Route path=":stories?" view=Stories ssr=SsrMode::InOrder/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
extern crate wee_alloc;
|
||||
|
||||
// Use `wee_alloc` as the global allocator.
|
||||
#[global_allocator]
|
||||
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
#[cfg(debug_assertions)]
|
||||
console_error_panic_hook::set_once();
|
||||
leptos::leptos_dom::HydrationCtx::stop_hydrating();
|
||||
}
|
||||
}
|
||||
}
|
||||
46
examples/hackernews_islands_axum/src/main.rs
Normal file
46
examples/hackernews_islands_axum/src/main.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
mod ssr_imports {
|
||||
pub use axum::Router;
|
||||
pub use leptos::*;
|
||||
pub use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
pub use tower_http::{compression::CompressionLayer, services::ServeFile};
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
use hackernews::*;
|
||||
use ssr_imports::*;
|
||||
|
||||
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(|| view! { <App/> }).await;
|
||||
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.route_service("/favicon.ico", ServeFile::new("./public/favicon.ico"))
|
||||
.route_service(
|
||||
"/style.css",
|
||||
ServeFile::new("./pkg/style.css").precompressed_br(),
|
||||
)
|
||||
.route_service(
|
||||
"/pkg/hackernews.js",
|
||||
ServeFile::new("./pkg/hackernews.js").precompressed_br(),
|
||||
)
|
||||
.route_service(
|
||||
"/pkg/hackernews_bg.wasm",
|
||||
ServeFile::new("./pkg/hackernews_bg.wasm").precompressed_br(),
|
||||
)
|
||||
.leptos_routes(&leptos_options, routes, App)
|
||||
//.layer(CompressionLayer::new())
|
||||
.with_state(leptos_options);
|
||||
|
||||
// run our app with hyper
|
||||
// `axum::Server` is a re-export of `hyper::Server`
|
||||
log!("listening on {}", addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
4
examples/hackernews_islands_axum/src/routes.rs
Normal file
4
examples/hackernews_islands_axum/src/routes.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod nav;
|
||||
pub mod stories;
|
||||
pub mod story;
|
||||
pub mod users;
|
||||
30
examples/hackernews_islands_axum/src/routes/nav.rs
Normal file
30
examples/hackernews_islands_axum/src/routes/nav.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use leptos::{component, view, IntoView};
|
||||
use leptos_router::*;
|
||||
|
||||
#[component]
|
||||
pub fn Nav() -> impl IntoView {
|
||||
view! {
|
||||
<header class="header">
|
||||
<nav class="inner">
|
||||
<A href="/home">
|
||||
<strong>"HN"</strong>
|
||||
</A>
|
||||
<A href="/new">
|
||||
<strong>"New"</strong>
|
||||
</A>
|
||||
<A href="/show">
|
||||
<strong>"Show"</strong>
|
||||
</A>
|
||||
<A href="/ask">
|
||||
<strong>"Ask"</strong>
|
||||
</A>
|
||||
<A href="/job">
|
||||
<strong>"Jobs"</strong>
|
||||
</A>
|
||||
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer">
|
||||
"Built with Leptos"
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
164
examples/hackernews_islands_axum/src/routes/stories.rs
Normal file
164
examples/hackernews_islands_axum/src/routes/stories.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use crate::api;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
fn category(from: &str) -> String {
|
||||
match from {
|
||||
"new" => "newest",
|
||||
"show" => "show",
|
||||
"ask" => "ask",
|
||||
"job" => "jobs",
|
||||
_ => "news",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[server(FetchStories, "/api")]
|
||||
pub async fn fetch_stories(
|
||||
story_type: String,
|
||||
page: usize,
|
||||
) -> Result<Vec<api::Story>, ServerFnError> {
|
||||
let path = format!("{}?page={}", category(&story_type), page);
|
||||
Ok(api::fetch_api::<Vec<api::Story>>(&api::story(&path))
|
||||
.await
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Stories() -> impl IntoView {
|
||||
let query = use_query_map();
|
||||
let params = use_params_map();
|
||||
let page = move || {
|
||||
query
|
||||
.with(|q| q.get("page").and_then(|page| page.parse::<usize>().ok()))
|
||||
.unwrap_or(1)
|
||||
};
|
||||
let story_type = move || {
|
||||
params
|
||||
.with(|p| p.get("stories").cloned())
|
||||
.unwrap_or_else(|| "top".to_string())
|
||||
};
|
||||
let stories = create_resource(
|
||||
move || (page(), story_type()),
|
||||
move |(page, story_type)| fetch_stories(category(&story_type), page),
|
||||
);
|
||||
let (pending, set_pending) = create_signal(false);
|
||||
|
||||
let hide_more_link = move || {
|
||||
pending()
|
||||
|| stories
|
||||
.with(|stories| {
|
||||
stories.as_ref().map(|s| s.len() < 28).unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
view! {
|
||||
|
||||
<div class="news-view">
|
||||
<div class="news-list-nav">
|
||||
<span>
|
||||
{move || if page() > 1 {
|
||||
view! {
|
||||
<a class="page-link"
|
||||
href=move || format!("/{}?page={}", story_type(), page() - 1)
|
||||
attr:aria_label="Previous Page"
|
||||
>
|
||||
"< prev"
|
||||
</a>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<span class="page-link disabled" aria-hidden="true">
|
||||
"< prev"
|
||||
</span>
|
||||
}.into_any()
|
||||
}}
|
||||
</span>
|
||||
<span>"page " {page}</span>
|
||||
<span class="page-link"
|
||||
class:disabled=hide_more_link
|
||||
aria-hidden=hide_more_link
|
||||
>
|
||||
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
|
||||
aria-label="Next Page"
|
||||
>
|
||||
"more >"
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<main class="news-list">
|
||||
<div>
|
||||
<Transition
|
||||
fallback=|| ()
|
||||
set_pending=set_pending.into()
|
||||
>
|
||||
{move || stories.read().map(|story| story.map(|stories| view! {
|
||||
<ul>
|
||||
<For
|
||||
each=move || stories.clone()
|
||||
key=|story| story.id
|
||||
view=move |story: api::Story| {
|
||||
view! {
|
||||
<Story story/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ul>
|
||||
}.into_any()))}
|
||||
</Transition>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Story(story: api::Story) -> impl IntoView {
|
||||
view! {
|
||||
<li class="news-item">
|
||||
<span class="score">{story.points}</span>
|
||||
<span class="title">
|
||||
{if !story.url.starts_with("item?id=") {
|
||||
view! {
|
||||
<span>
|
||||
<a href=story.url target="_blank" rel="noreferrer">
|
||||
{story.title.clone()}
|
||||
</a>
|
||||
<span class="host">"("{story.domain}")"</span>
|
||||
</span>
|
||||
}.into_view()
|
||||
} else {
|
||||
let title = story.title.clone();
|
||||
view! { <A href=format!("/stories/{}", story.id)>{title.clone()}</A> }.into_view()
|
||||
}}
|
||||
</span>
|
||||
<br />
|
||||
<span class="meta">
|
||||
{if story.story_type != "job" {
|
||||
view! {
|
||||
<span>
|
||||
{"by "}
|
||||
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
|
||||
{format!(" {} | ", story.time_ago)}
|
||||
<A href=format!("/stories/{}", story.id)>
|
||||
{if story.comments_count.unwrap_or_default() > 0 {
|
||||
format!("{} comments", story.comments_count.unwrap_or_default())
|
||||
} else {
|
||||
"discuss".into()
|
||||
}}
|
||||
</A>
|
||||
</span>
|
||||
}.into_view()
|
||||
} else {
|
||||
let title = story.title.clone();
|
||||
view! { <A href=format!("/item/{}", story.id)>{title.clone()}</A> }.into_view()
|
||||
}}
|
||||
</span>
|
||||
{(story.story_type != "link").then(|| view! {
|
||||
" "
|
||||
<span class="label">{story.story_type}</span>
|
||||
})}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
138
examples/hackernews_islands_axum/src/routes/story.rs
Normal file
138
examples/hackernews_islands_axum/src/routes/story.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use crate::api;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[server(FetchStory, "/api")]
|
||||
pub async fn fetch_story(
|
||||
id: String,
|
||||
) -> Result<RefCell<Option<api::Story>>, ServerFnError> {
|
||||
Ok(RefCell::new(
|
||||
api::fetch_api::<api::Story>(&api::story(&format!("item/{id}"))).await,
|
||||
))
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Story() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let story = create_resource(
|
||||
move || params().get("id").cloned().unwrap_or_default(),
|
||||
move |id| async move {
|
||||
if id.is_empty() {
|
||||
Ok(RefCell::new(None))
|
||||
} else {
|
||||
fetch_story(id).await
|
||||
}
|
||||
},
|
||||
);
|
||||
let meta_description = move || {
|
||||
story
|
||||
.with(|story| {
|
||||
story
|
||||
.as_ref()
|
||||
.map(|story| {
|
||||
story.borrow().as_ref().map(|story| story.title.clone())
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.flatten()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "Loading story...".to_string())
|
||||
};
|
||||
|
||||
let story_view = move || {
|
||||
story.with(|story| {
|
||||
story.as_ref().ok().and_then(|story| {
|
||||
let story: Option<api::Story> = story.borrow_mut().take();
|
||||
story.map(|story| {
|
||||
view! {
|
||||
<div class="item-view">
|
||||
<div class="item-view-header">
|
||||
<a href=story.url target="_blank">
|
||||
<h1>{story.title}</h1>
|
||||
</a>
|
||||
<span class="host">
|
||||
"("{story.domain}")"
|
||||
</span>
|
||||
{story.user.map(|user| view! { <p class="meta">
|
||||
{story.points}
|
||||
" points | by "
|
||||
<A href=format!("/users/{user}")>{user.clone()}</A>
|
||||
{format!(" {}", story.time_ago)}
|
||||
</p>})}
|
||||
</div>
|
||||
<div class="item-view-comments">
|
||||
<p class="item-view-comments-header">
|
||||
{if story.comments_count.unwrap_or_default() > 0 {
|
||||
format!("{} comments", story.comments_count.unwrap_or_default())
|
||||
} else {
|
||||
"No comments yet.".into()
|
||||
}}
|
||||
</p>
|
||||
<ul class="comment-children">
|
||||
{story.comments.unwrap_or_default().into_iter()
|
||||
.map(|comment: api::Comment| view! { <Comment comment /> })
|
||||
.collect_view()}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}})})})
|
||||
};
|
||||
|
||||
view! {
|
||||
<Meta name="description" content=meta_description/>
|
||||
<Suspense fallback=|| ()>
|
||||
{story_view}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[island]
|
||||
pub fn Toggle(children: Children) -> impl IntoView {
|
||||
let (open, set_open) = create_signal(true);
|
||||
let children = std::cell::LazyCell::new(|| children().into_view());
|
||||
view! {
|
||||
<div class="toggle" class:open=open>
|
||||
<a on:click=move |_| set_open.update(|n| *n = !*n)>
|
||||
{move || if open() {
|
||||
"[-]"
|
||||
} else {
|
||||
"[+] comments collapsed"
|
||||
}}
|
||||
</a>
|
||||
</div>
|
||||
<ul
|
||||
class="comment-children"
|
||||
style:display=move || if open() {
|
||||
"block"
|
||||
} else {
|
||||
"none"
|
||||
}
|
||||
>
|
||||
{move || open().then(|| children.clone())}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Comment(comment: api::Comment) -> impl IntoView {
|
||||
view! {
|
||||
<li class="comment">
|
||||
<div class="by">
|
||||
<A href=format!("/users/{}", comment.user.clone().unwrap_or_default())>{comment.user.clone()}</A>
|
||||
{format!(" {}", comment.time_ago)}
|
||||
</div>
|
||||
<div class="text" inner_html=comment.content></div>
|
||||
{(!comment.comments.is_empty()).then(|| {
|
||||
view! {
|
||||
<Toggle>
|
||||
{comment.comments.into_iter()
|
||||
.map(|comment: api::Comment| view! { <Comment comment /> })
|
||||
.collect_view()}
|
||||
</Toggle>
|
||||
}
|
||||
})}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
53
examples/hackernews_islands_axum/src/routes/users.rs
Normal file
53
examples/hackernews_islands_axum/src/routes/users.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::api::{self, User};
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
#[server(FetchUser, "/api")]
|
||||
pub async fn fetch_user(
|
||||
id: String,
|
||||
) -> Result<Option<api::User>, ServerFnError> {
|
||||
Ok(api::fetch_api::<User>(&api::user(&id)).await)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn User() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let user = create_resource(
|
||||
move || params().get("id").cloned().unwrap_or_default(),
|
||||
move |id| async move {
|
||||
if id.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
fetch_user(id).await
|
||||
}
|
||||
},
|
||||
);
|
||||
view! {
|
||||
<div class="user-view">
|
||||
<Suspense fallback=|| ()>
|
||||
{move || user.read().map(|user| user.map(|user| match user {
|
||||
None => view! { <h1>"User not found."</h1> }.into_view(),
|
||||
Some(user) => view! {
|
||||
<div>
|
||||
<h1>"User: " {&user.id}</h1>
|
||||
<ul class="meta">
|
||||
<li>
|
||||
<span class="label">"Created: "</span> {user.created}
|
||||
</li>
|
||||
<li>
|
||||
<span class="label">"Karma: "</span> {user.karma}
|
||||
</li>
|
||||
<li inner_html={user.about} class="about"></li>
|
||||
</ul>
|
||||
<p class="links">
|
||||
<a href=format!("https://news.ycombinator.com/submitted?id={}", user.id)>"submissions"</a>
|
||||
" | "
|
||||
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
|
||||
</p>
|
||||
</div>
|
||||
}.into_view()
|
||||
}))}
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
326
examples/hackernews_islands_axum/style.css
Normal file
326
examples/hackernews_islands_axum/style.css
Normal file
@@ -0,0 +1,326 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
font-size: 15px;
|
||||
background-color: #f2f3f5;
|
||||
margin: 0;
|
||||
padding-top: 55px;
|
||||
color: #34495e;
|
||||
overflow-y: scroll
|
||||
}
|
||||
|
||||
a {
|
||||
color: #34495e;
|
||||
text-decoration: none
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #335d92;
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
height: 55px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0
|
||||
}
|
||||
|
||||
.header .inner {
|
||||
max-width: 800px;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
padding: 15px 5px
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: rgba(255, 255, 255, .8);
|
||||
line-height: 24px;
|
||||
transition: color .15s ease;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
font-weight: 300;
|
||||
letter-spacing: .075em;
|
||||
margin-right: 1.8em
|
||||
}
|
||||
|
||||
.header a:hover {
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.header a.active {
|
||||
color: #fff;
|
||||
font-weight: 400
|
||||
}
|
||||
|
||||
.header a:nth-child(6) {
|
||||
margin-right: 0
|
||||
}
|
||||
|
||||
.header .github {
|
||||
color: #fff;
|
||||
font-size: .9em;
|
||||
margin: 0;
|
||||
float: right
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 24px;
|
||||
margin-right: 10px;
|
||||
display: inline-block;
|
||||
vertical-align: middle
|
||||
}
|
||||
|
||||
.view {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
position: relative
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-exit-active {
|
||||
transition: all .2s ease
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-exit-active {
|
||||
opacity: 0
|
||||
}
|
||||
|
||||
@media (max-width:860px) {
|
||||
.header .inner {
|
||||
padding: 15px 30px
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:600px) {
|
||||
.header .inner {
|
||||
padding: 15px
|
||||
}
|
||||
|
||||
.header a {
|
||||
margin-right: 1em
|
||||
}
|
||||
|
||||
.header .github {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
|
||||
.news-view {
|
||||
padding-top: 45px
|
||||
}
|
||||
|
||||
.news-list,
|
||||
.news-list-nav {
|
||||
background-color: #fff;
|
||||
border-radius: 2px
|
||||
}
|
||||
|
||||
.news-list-nav {
|
||||
padding: 15px 30px;
|
||||
position: fixed;
|
||||
text-align: center;
|
||||
top: 55px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 998;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .1)
|
||||
}
|
||||
|
||||
.news-list-nav .page-link {
|
||||
margin: 0 1em
|
||||
}
|
||||
|
||||
.news-list-nav .disabled {
|
||||
color: #aaa
|
||||
}
|
||||
|
||||
.news-list {
|
||||
position: absolute;
|
||||
margin: 30px 0;
|
||||
width: 100%;
|
||||
transition: all .5s cubic-bezier(.55, 0, .1, 1)
|
||||
}
|
||||
|
||||
.news-list ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
@media (max-width:600px) {
|
||||
.news-list {
|
||||
margin: 10px 0
|
||||
}
|
||||
}
|
||||
|
||||
.news-item {
|
||||
background-color: #fff;
|
||||
padding: 20px 30px 20px 80px;
|
||||
border-bottom: 1px solid #eee;
|
||||
position: relative;
|
||||
line-height: 20px
|
||||
}
|
||||
|
||||
.news-item .score {
|
||||
color: #335d92;
|
||||
font-size: 1.1em;
|
||||
font-weight: 700;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
margin-top: -10px
|
||||
}
|
||||
|
||||
.news-item .host,
|
||||
.news-item .meta {
|
||||
font-size: .85em;
|
||||
color: #626262
|
||||
}
|
||||
|
||||
.news-item .host a,
|
||||
.news-item .meta a {
|
||||
color: #626262;
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
.news-item .host a:hover,
|
||||
.news-item .meta a:hover {
|
||||
color: #335d92
|
||||
}
|
||||
|
||||
.item-view-header {
|
||||
background-color: #fff;
|
||||
padding: 1.8em 2em 1em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .1)
|
||||
}
|
||||
|
||||
.item-view-header h1 {
|
||||
display: inline;
|
||||
font-size: 1.5em;
|
||||
margin: 0;
|
||||
margin-right: .5em
|
||||
}
|
||||
|
||||
.item-view-header .host,
|
||||
.item-view-header .meta,
|
||||
.item-view-header .meta a {
|
||||
color: #626262
|
||||
}
|
||||
|
||||
.item-view-header .meta a {
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
.item-view-comments {
|
||||
background-color: #fff;
|
||||
margin-top: 10px;
|
||||
padding: 0 2em .5em
|
||||
}
|
||||
|
||||
.item-view-comments-header {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
padding: 1em 0;
|
||||
position: relative
|
||||
}
|
||||
|
||||
.item-view-comments-header .spinner {
|
||||
display: inline-block;
|
||||
margin: -15px 0
|
||||
}
|
||||
|
||||
.comment-children {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
@media (max-width:600px) {
|
||||
.item-view-header h1 {
|
||||
font-size: 1.25em
|
||||
}
|
||||
}
|
||||
|
||||
.comment-children .comment-children {
|
||||
margin-left: 1.5em
|
||||
}
|
||||
|
||||
.comment {
|
||||
border-top: 1px solid #eee;
|
||||
position: relative
|
||||
}
|
||||
|
||||
.comment .by,
|
||||
.comment .text,
|
||||
.comment .toggle {
|
||||
font-size: .9em;
|
||||
margin: 1em 0
|
||||
}
|
||||
|
||||
.comment .by {
|
||||
color: #626262
|
||||
}
|
||||
|
||||
.comment .by a {
|
||||
color: #626262;
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
.comment .text {
|
||||
overflow-wrap: break-word
|
||||
}
|
||||
|
||||
.comment .text a:hover {
|
||||
color: #335d92
|
||||
}
|
||||
|
||||
.comment .text pre {
|
||||
white-space: pre-wrap
|
||||
}
|
||||
|
||||
.comment .toggle {
|
||||
background-color: #fffbf2;
|
||||
padding: .3em .5em;
|
||||
border-radius: 4px
|
||||
}
|
||||
|
||||
.comment .toggle a {
|
||||
color: #626262;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.comment .toggle.open {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
margin-bottom: -.5em
|
||||
}
|
||||
|
||||
.user-view {
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
padding: 2em 3em
|
||||
}
|
||||
|
||||
.user-view h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5em
|
||||
}
|
||||
|
||||
.user-view .meta {
|
||||
list-style-type: none;
|
||||
padding: 0
|
||||
}
|
||||
|
||||
.user-view .label {
|
||||
display: inline-block;
|
||||
min-width: 4em
|
||||
}
|
||||
|
||||
.user-view .about {
|
||||
margin: 1em 0
|
||||
}
|
||||
|
||||
.user-view .links a {
|
||||
text-decoration: underline
|
||||
}
|
||||
@@ -115,7 +115,7 @@ pub fn App() -> impl IntoView {
|
||||
log::info!("Successfully logged in");
|
||||
authorized_api.update(|v| *v = Some(api));
|
||||
let navigate = use_navigate();
|
||||
navigate(Page::Home.path(), Default::default()).expect("Home route");
|
||||
navigate(Page::Home.path(), Default::default());
|
||||
fetch_user_info.dispatch(());
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn ContactList() -> impl IntoView {
|
||||
let location = use_location();
|
||||
let contacts = create_resource(move || location.search.get(), get_contacts);
|
||||
let contacts = move || {
|
||||
contacts.read().map(|contacts| {
|
||||
contacts.get().map(|contacts| {
|
||||
// this data doesn't change frequently so we can use .map().collect() instead of a keyed <For/>
|
||||
contacts
|
||||
.into_iter()
|
||||
@@ -147,7 +147,7 @@ pub fn Contact() -> impl IntoView {
|
||||
log!("params = {:#?}", params.get());
|
||||
});
|
||||
|
||||
let contact_display = move || match contact.read() {
|
||||
let contact_display = move || match contact.get() {
|
||||
// None => loading, but will be caught by Suspense fallback
|
||||
// I'm only doing this explicitly for the example
|
||||
None => None,
|
||||
@@ -198,7 +198,7 @@ pub fn About() -> impl IntoView {
|
||||
// <button on:click> to navigate is an *anti-pattern*
|
||||
// you should ordinarily use a link instead,
|
||||
// both semantically and so your link will work before WASM loads
|
||||
<button on:click=move |_| { _ = navigate("/", Default::default()); }>
|
||||
<button on:click=move |_| navigate("/", Default::default())>
|
||||
"Home"
|
||||
</button>
|
||||
<h1>"About"</h1>
|
||||
|
||||
Binary file not shown.
@@ -33,7 +33,8 @@ if #[cfg(feature = "ssr")] {
|
||||
}
|
||||
|
||||
async fn leptos_routes_handler(auth_session: AuthSession, State(app_state): State<AppState>, req: Request<AxumBody>) -> Response{
|
||||
let handler = leptos_axum::render_app_to_stream_with_context(app_state.leptos_options.clone(),
|
||||
let handler = leptos_axum::render_route_with_context(app_state.leptos_options.clone(),
|
||||
app_state.routes.clone(),
|
||||
move || {
|
||||
provide_context(auth_session.clone());
|
||||
provide_context(app_state.pool.clone());
|
||||
@@ -84,6 +85,7 @@ if #[cfg(feature = "ssr")] {
|
||||
let app_state = AppState{
|
||||
leptos_options,
|
||||
pool: pool.clone(),
|
||||
routes: routes.clone(),
|
||||
};
|
||||
|
||||
// build our application with a route
|
||||
|
||||
@@ -5,13 +5,14 @@ cfg_if! {
|
||||
use leptos::LeptosOptions;
|
||||
use sqlx::SqlitePool;
|
||||
use axum::extract::FromRef;
|
||||
|
||||
use leptos_router::RouteListing;
|
||||
/// This takes advantage of Axum's SubStates feature by deriving FromRef. This is the only way to have more than one
|
||||
/// item in Axum's State. Leptos requires you to have leptosOptions in your State struct for the leptos route handlers
|
||||
#[derive(FromRef, Debug, Clone)]
|
||||
pub struct AppState{
|
||||
pub leptos_options: LeptosOptions,
|
||||
pub pool: SqlitePool
|
||||
pub pool: SqlitePool,
|
||||
pub routes: Vec<RouteListing>,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn TodoApp() -> impl IntoView {
|
||||
fallback=move || view! {<span>"Loading..."</span>}
|
||||
>
|
||||
{move || {
|
||||
user.read().map(|user| match user {
|
||||
user.get().map(|user| match user {
|
||||
Err(e) => view! {
|
||||
<A href="/signup">"Signup"</A>", "
|
||||
<A href="/login">"Login"</A>", "
|
||||
@@ -215,7 +215,7 @@ pub fn Todos() -> impl IntoView {
|
||||
{move || {
|
||||
let existing_todos = {
|
||||
move || {
|
||||
todos.read()
|
||||
todos.get()
|
||||
.map(move |todos| match todos {
|
||||
Err(e) => {
|
||||
view! { <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_view()
|
||||
|
||||
@@ -27,6 +27,11 @@ pub fn App() -> impl IntoView {
|
||||
view=Post
|
||||
ssr=SsrMode::Async
|
||||
/>
|
||||
<Route
|
||||
path="/post_in_order/:id"
|
||||
view=Post
|
||||
ssr=SsrMode::InOrder
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
@@ -39,14 +44,16 @@ fn HomePage() -> impl IntoView {
|
||||
let posts =
|
||||
create_resource(|| (), |_| async { list_post_metadata().await });
|
||||
let posts_view = move || {
|
||||
posts.with(|posts| posts
|
||||
.clone()
|
||||
.map(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! { <li><a href=format!("/post/{}", post.id)>{&post.title}</a></li>})
|
||||
.collect_view()
|
||||
})
|
||||
)
|
||||
posts.and_then(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! {
|
||||
<li>
|
||||
<a href=format!("/post/{}", post.id)>{&post.title}</a> "|"
|
||||
<a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a>
|
||||
</li>
|
||||
})
|
||||
.collect_view()
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
@@ -81,21 +88,22 @@ fn Post() -> impl IntoView {
|
||||
}
|
||||
});
|
||||
|
||||
// this view needs to take the `Scope` from the `<Suspense/>`, not
|
||||
// from the parent component, so we take that as an argument and
|
||||
// pass it in under the `<Suspense/>` so that it is correct
|
||||
let post_view = move || {
|
||||
post.with(|post| {
|
||||
post.clone().map(|post| {
|
||||
view! {
|
||||
// render content
|
||||
<h1>{&post.title}</h1>
|
||||
<p>{&post.content}</p>
|
||||
post.and_then(|post| {
|
||||
view! {
|
||||
// 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/>
|
||||
}
|
||||
})
|
||||
// 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.clone()/>
|
||||
<Meta name="description" content=post.content.clone()/>
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
@@ -43,22 +43,23 @@ fn HomePage() -> impl IntoView {
|
||||
// load the posts
|
||||
let posts =
|
||||
create_resource(|| (), |_| async { list_post_metadata().await });
|
||||
let posts_view = move || {
|
||||
posts.and_then(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! {
|
||||
<li>
|
||||
<a href=format!("/post/{}", post.id)>{&post.title}</a> "|"
|
||||
<a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a>
|
||||
</li>
|
||||
})
|
||||
.collect_view()
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<h1>"My Great Blog"</h1>
|
||||
<Suspense fallback=move || view! { <p>"Loading posts..."</p> }>
|
||||
<ul>
|
||||
{move || {
|
||||
posts.with(|posts| posts
|
||||
.clone()
|
||||
.map(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! { <li><a href=format!("/post/{}", post.id)>{&post.title}</a> "|" <a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a></li>})
|
||||
.collect_view()
|
||||
})
|
||||
)
|
||||
}}
|
||||
</ul>
|
||||
<ul>{posts_view}</ul>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
@@ -91,23 +92,19 @@ fn Post() -> impl IntoView {
|
||||
// from the parent component, so we take that as an argument and
|
||||
// pass it in under the `<Suspense/>` so that it is correct
|
||||
let post_view = move || {
|
||||
move || {
|
||||
post.with(|post| {
|
||||
post.clone().map(|post| {
|
||||
view! {
|
||||
// render content
|
||||
<h1>{&post.title}</h1>
|
||||
<p>{&post.content}</p>
|
||||
post.and_then(|post| {
|
||||
view! {
|
||||
// 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/>
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
// 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.clone()/>
|
||||
<Meta name="description" content=post.content.clone()/>
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "leptos_start"
|
||||
name = "suspense_tests"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
@@ -12,9 +12,9 @@ actix-web = { version = "4", optional = true, features = ["macros"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "1"
|
||||
cfg-if = "1"
|
||||
leptos = { path = "../../..", features = ["serde"] }
|
||||
leptos_actix = { path = "../../../../integrations/actix", optional = true }
|
||||
leptos_router = { path = "../../../../router" }
|
||||
leptos = { path = "../../leptos", features = ["serde"] }
|
||||
leptos_actix = { path = "../../integrations/actix", optional = true }
|
||||
leptos_router = { path = "../../router" }
|
||||
log = "0.4"
|
||||
simple_logger = "4"
|
||||
wasm-bindgen = "0.2.87"
|
||||
@@ -34,7 +34,7 @@ ssr = [
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "leptos_start"
|
||||
output-name = "suspense_tests"
|
||||
# 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
|
||||
@@ -47,8 +47,8 @@ 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"
|
||||
end2end-cmd = "cargo make test-ui"
|
||||
end2end-dir = "e2e"
|
||||
# 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
|
||||
@@ -74,5 +74,3 @@ lib-features = ["hydrate"]
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
|
||||
[workspace]
|
||||
24
examples/suspense_tests/Makefile.toml
Normal file
24
examples/suspense_tests/Makefile.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/webdriver.toml" },
|
||||
{ path = "../cargo-make/cargo-leptos.toml" },
|
||||
]
|
||||
|
||||
[env]
|
||||
APP_PROCESS_NAME = "suspense_tests"
|
||||
|
||||
[tasks.integration-test]
|
||||
dependencies = [
|
||||
"install-cargo-leptos",
|
||||
"start-webdriver",
|
||||
"test-e2e-with-auto-start",
|
||||
]
|
||||
|
||||
[tasks.test-e2e-with-auto-start]
|
||||
command = "cargo"
|
||||
args = ["leptos", "end-to-end"]
|
||||
|
||||
[tasks.test-ui]
|
||||
cwd = "./e2e"
|
||||
command = "cargo"
|
||||
args = ["make", "test-ui", "${@}"]
|
||||
@@ -47,15 +47,35 @@ After running a `cargo leptos build --release` the minimum files needed are:
|
||||
|
||||
Copy these files to your remote server. The directory structure should be:
|
||||
```text
|
||||
leptos_start
|
||||
suspense_tests
|
||||
site/
|
||||
```
|
||||
Set the following enviornment variables (updating for your project as needed):
|
||||
```text
|
||||
LEPTOS_OUTPUT_NAME="leptos_start"
|
||||
LEPTOS_OUTPUT_NAME="suspense_tests"
|
||||
LEPTOS_SITE_ROOT="site"
|
||||
LEPTOS_SITE_PKG_DIR="pkg"
|
||||
LEPTOS_SITE_ADDR="127.0.0.1:3000"
|
||||
LEPTOS_RELOAD_PORT="3001"
|
||||
```
|
||||
Finally, run the server binary.
|
||||
|
||||
## Testing
|
||||
|
||||
This example includes quality checks and end-to-end testing.
|
||||
|
||||
To get started run this once.
|
||||
|
||||
```bash
|
||||
cargo make ci
|
||||
```
|
||||
|
||||
To only run the UI tests...
|
||||
|
||||
```bash
|
||||
cargo make start-webdriver
|
||||
cargo leptos watch # or cargo run...
|
||||
cargo make test-ui
|
||||
```
|
||||
|
||||
_See the [E2E README](./e2e/README.md) for more information about the testing strategy._
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user