mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-28 18:22:34 -05:00
Compare commits
19 Commits
gbj-patch-
...
v0.4.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9deff52a7 | ||
|
|
eb3d9b8714 | ||
|
|
18deb398ca | ||
|
|
d9abebb4be | ||
|
|
a480db8b77 | ||
|
|
1f26b68d45 | ||
|
|
937501c61b | ||
|
|
5523fb86fb | ||
|
|
7dcfcf8ca8 | ||
|
|
087c68569a | ||
|
|
6abfdd2345 | ||
|
|
cddd784e8d | ||
|
|
f6978217fb | ||
|
|
aa58cedc15 | ||
|
|
a0b0d72d19 | ||
|
|
fa8d0945e0 | ||
|
|
3ed49381e3 | ||
|
|
8ec3fb95f0 | ||
|
|
cc11430d16 |
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:
|
||||
|
||||
7
.github/workflows/publish-book.yml
vendored
7
.github/workflows/publish-book.yml
vendored
@@ -1,9 +1,8 @@
|
||||
name: Deploy book
|
||||
on:
|
||||
push:
|
||||
paths: ['docs/book/**']
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- '*-?v[0-9]+*'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -34,4 +33,4 @@ jobs:
|
||||
mv ../book/* .
|
||||
git add .
|
||||
git commit -m "Deploy book $GITHUB_SHA to gh-pages"
|
||||
git push --force --set-upstream origin gh-pages
|
||||
git push --force --set-upstream origin gh-pages
|
||||
|
||||
28
Cargo.toml
28
Cargo.toml
@@ -26,22 +26,22 @@ members = [
|
||||
exclude = ["benchmarks", "examples"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.8"
|
||||
version = "0.4.9"
|
||||
|
||||
[workspace.dependencies]
|
||||
leptos = { path = "./leptos", version = "0.4.8" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.4.8" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.4.8" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.4.8" }
|
||||
leptos_reactive = { path = "./leptos_reactive", version = "0.4.8" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.4.8" }
|
||||
server_fn = { path = "./server_fn", version = "0.4.8" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.4.8" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.4.8" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.4.8" }
|
||||
leptos_router = { path = "./router", version = "0.4.8" }
|
||||
leptos_meta = { path = "./meta", version = "0.4.8" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.4.8" }
|
||||
leptos = { path = "./leptos", version = "0.4.9" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.4.9" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.4.9" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.4.9" }
|
||||
leptos_reactive = { path = "./leptos_reactive", version = "0.4.9" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.4.9" }
|
||||
server_fn = { path = "./server_fn", version = "0.4.9" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.4.9" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.4.9" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.4.9" }
|
||||
leptos_router = { path = "./router", version = "0.4.9" }
|
||||
leptos_meta = { path = "./meta", version = "0.4.9" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.4.9" }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -44,4 +44,5 @@
|
||||
- [Progressive Enhancement and Graceful Degradation](./progressive_enhancement/README.md)
|
||||
- [`<ActionForm/>`s](./progressive_enhancement/action_form.md)
|
||||
- [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(cx, "Alice");
|
||||
|
||||
// B
|
||||
let name_upper = create_memo(cx, move |_| name.with(|n| n.to_uppercase()));
|
||||
|
||||
// C
|
||||
create_effect(cx, 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(cx, "Alice");
|
||||
|
||||
// B
|
||||
let name_upper = create_memo(cx, move |_| name.with(|n| n.to_uppercase()));
|
||||
|
||||
// C
|
||||
let name_len = create_memo(cx, move |_| name.len());
|
||||
|
||||
// D
|
||||
create_effect(cx, move |_| {
|
||||
log!("len = {}", name_len());
|
||||
});
|
||||
|
||||
// E
|
||||
create_effect(cx, 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(cx, "Alice");
|
||||
|
||||
// B
|
||||
let name_upper = create_memo(cx, move |_| name.with(|n| n.to_uppercase()));
|
||||
|
||||
// C
|
||||
let name_len = create_memo(cx, move |_| name.len());
|
||||
|
||||
// D
|
||||
create_effect(cx, 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(cx, 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(cx, move |_| {
|
||||
d()
|
||||
});
|
||||
create_effect(cx, move |_| {
|
||||
engrave_text_into_bar_of_gold(&text());
|
||||
});
|
||||
```
|
||||
@@ -53,128 +53,149 @@ pub fn TodoApp(cx: Scope) -> 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
|
||||
```
|
||||
|
||||
> 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.
|
||||
#### Sample Test
|
||||
|
||||
```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]
|
||||
#[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(),
|
||||
|cx| view! { cx, <SimpleCounter initial_value=10 step=1/> },
|
||||
);
|
||||
|
||||
let div = test_wrapper.query_selector("div").unwrap().unwrap();
|
||||
let clear = test_wrapper
|
||||
.query_selector("button")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unchecked_into::<web_sys::HtmlElement>();
|
||||
|
||||
clear.click();
|
||||
|
||||
assert_eq!(
|
||||
div.outer_html(),
|
||||
/* HTML expected */
|
||||
);
|
||||
```
|
||||
|
||||
### [`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
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
We’ll use some manual DOM operations to grab the `<div>` that wraps
|
||||
the whole component, as well as the `clear` button.
|
||||
### [Playwright with `counters_stable`](https://github.com/leptos-rs/leptos/tree/main/examples/counters_stable/e2e)
|
||||
|
||||
```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>();
|
||||
```
|
||||
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.
|
||||
|
||||
Now we can use ordinary DOM APIs to simulate user interaction.
|
||||
#### Sample Test
|
||||
|
||||
```rust
|
||||
// now let's click the `clear` button
|
||||
clear.click();
|
||||
```
|
||||
```js
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
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.
|
||||
test.describe("Increment Count", () => {
|
||||
test("should increase the total count", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
await ui.goto();
|
||||
await ui.addCounter();
|
||||
|
||||
```rust
|
||||
assert_eq!(
|
||||
div.outer_html(),
|
||||
// here we spawn a mini reactive system to render the test case
|
||||
run_scope(create_runtime(), |cx| {
|
||||
// it's as if we're creating it with a value of 0, right?
|
||||
let (value, set_value) = create_signal(cx, 0);
|
||||
await ui.incrementCount();
|
||||
await ui.incrementCount();
|
||||
await ui.incrementCount();
|
||||
|
||||
// we can remove the event listeners because they're not rendered to HTML
|
||||
view! { cx,
|
||||
<div>
|
||||
<button>"Clear"</button>
|
||||
<button>"-1"</button>
|
||||
<span>"Value: " {value} "!"</span>
|
||||
<button>"+1"</button>
|
||||
</div>
|
||||
}
|
||||
// the view returned an HtmlElement<Div>, which is a smart pointer for
|
||||
// a DOM element. So we can still just call .outer_html()
|
||||
.outer_html()
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
That test involved us manually replicating the `view` 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.
|
||||
|
||||
```rust
|
||||
assert_eq!(test_wrapper.inner_html(), {
|
||||
let comparison_wrapper = document.create_element("section").unwrap();
|
||||
leptos::mount_to(
|
||||
comparison_wrapper.clone().unchecked_into(),
|
||||
|cx| view! { cx, <SimpleCounter initial_value=0 step=1/>},
|
||||
);
|
||||
comparison_wrapper.inner_html()
|
||||
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! { cx,
|
||||
> <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)
|
||||
|
||||
|
||||
@@ -397,6 +397,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(cx, "Controlled".to_string());
|
||||
@@ -42,6 +43,33 @@ view! { cx,
|
||||
}
|
||||
```
|
||||
|
||||
> #### 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! { cx,
|
||||
|
||||
`<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
|
||||
|
||||
@@ -26,6 +26,7 @@ CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = [
|
||||
"slots",
|
||||
"ssr_modes",
|
||||
"ssr_modes_axum",
|
||||
"suspense_tests",
|
||||
"tailwind",
|
||||
"tailwind_csr_trunk",
|
||||
"timer",
|
||||
|
||||
@@ -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(cx, 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(cx, move || {
|
||||
if let Some(abort_controller) = abort_controller {
|
||||
abort_controller.abort()
|
||||
}
|
||||
});
|
||||
T::de(&json).ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -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(cx, 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(cx, move || {
|
||||
if let Some(abort_controller) = abort_controller {
|
||||
abort_controller.abort()
|
||||
}
|
||||
});
|
||||
T::de(&json).ok()
|
||||
}
|
||||
|
||||
|
||||
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"]
|
||||
@@ -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._
|
||||
18
examples/suspense_tests/e2e/Cargo.toml
Normal file
18
examples/suspense_tests/e2e/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "suspense_tests_e2e"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1.0.72"
|
||||
async-trait = "0.1.72"
|
||||
cucumber = "0.19.1"
|
||||
fantoccini = "0.19.3"
|
||||
pretty_assertions = "1.4.0"
|
||||
serde_json = "1.0.104"
|
||||
tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
url = "2.4.0"
|
||||
|
||||
[[test]]
|
||||
name = "app_suite"
|
||||
harness = false # Allow Cucumber to print output instead of libtest
|
||||
11
examples/suspense_tests/e2e/Makefile.toml
Normal file
11
examples/suspense_tests/e2e/Makefile.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
extend = { path = "../../cargo-make/main.toml" }
|
||||
|
||||
[tasks.test]
|
||||
env = { RUN_AUTOMATICALLY = false }
|
||||
condition = { env_true = ["RUN_AUTOMATICALLY"] }
|
||||
|
||||
[tasks.ci]
|
||||
|
||||
[tasks.test-ui]
|
||||
command = "cargo"
|
||||
args = ["test", "--test", "app_suite", "--", "--fail-fast", "${@}"]
|
||||
34
examples/suspense_tests/e2e/README.md
Normal file
34
examples/suspense_tests/e2e/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# E2E Testing
|
||||
|
||||
This example demonstrates e2e testing with Rust using executable requirements.
|
||||
|
||||
## Testing Stack
|
||||
|
||||
| | Role | Description |
|
||||
|---|---|---|
|
||||
| [Cucumber](https://github.com/cucumber-rs/cucumber/tree/main) | Test Runner | Run [Gherkin](https://cucumber.io/docs/gherkin/reference/) specifications as Rust tests |
|
||||
| [Fantoccini](https://github.com/jonhoo/fantoccini/tree/main) | Browser Client | Interact with web pages through WebDriver |
|
||||
| [Cargo Leptos ](https://github.com/leptos-rs/cargo-leptos) | Build Tool | Compile example and start the server and end-2-end tests |
|
||||
| [chromedriver](https://chromedriver.chromium.org/downloads) | WebDriver | Provide WebDriver for Chrome
|
||||
|
||||
## Testing Organization
|
||||
|
||||
Testing is organized around what a user can do and see/not see. Test scenarios are grouped by the **user action** and the **object** of that action. This makes it easier to locate and reason about requirements.
|
||||
|
||||
Here is a brief overview of how things fit together.
|
||||
|
||||
```bash
|
||||
features
|
||||
└── {action}_{object}.feature # Specify test scenarios
|
||||
tests
|
||||
├── fixtures
|
||||
│ ├── action.rs # Perform a user action (click, type, etc.)
|
||||
│ ├── check.rs # Assert what a user can see/not see
|
||||
│ ├── find.rs # Query page elements
|
||||
│ ├── mod.rs
|
||||
│ └── world
|
||||
│ ├── action_steps.rs # Map Gherkin steps to user actions
|
||||
│ ├── check_steps.rs # Map Gherkin steps to user expectations
|
||||
│ └── mod.rs
|
||||
└── app_suite.rs # Test main
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
@click_inside_component_count
|
||||
Feature: Click Inside Component Count
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
Scenario Outline: Should increase the count
|
||||
|
||||
Given I select the mode <Mode>
|
||||
And I select the component Inside Component
|
||||
When I click the count 3 times
|
||||
Then I see the count is 3
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
@@ -0,0 +1,19 @@
|
||||
@click_nested_count
|
||||
Feature: Click Nested Count
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
Scenario Outline: Should increase the count
|
||||
|
||||
Given I select the mode <Mode>
|
||||
And I select the component Nested
|
||||
When I click the count 3 times
|
||||
Then I see the count is 3
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
@@ -0,0 +1,19 @@
|
||||
@click_nested_inside_count
|
||||
Feature: Click Nested Inside Count
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
Scenario Outline: Should increase the count
|
||||
|
||||
Given I select the mode <Mode>
|
||||
And I select the component Nested (resource created inside)
|
||||
When I click the count 3 times
|
||||
Then I see the count is 3
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
@@ -0,0 +1,20 @@
|
||||
@click_no_resources_counts
|
||||
Feature: Click No Resources Count (1)
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
Scenario Outline: Should increase the first and second counts
|
||||
|
||||
Given I select the mode <Mode>
|
||||
And I select the component No Resources
|
||||
When I click the first count 3 times
|
||||
Then I see the first count is 3
|
||||
And I see the second count is 3
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
@@ -0,0 +1,20 @@
|
||||
@click_no_resources_counts_2
|
||||
Feature: Click No Resources Count (2)
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
Scenario Outline: Should increase the first and second counts
|
||||
|
||||
Given I select the mode <Mode>
|
||||
And I select the component No Resources
|
||||
When I click the second count 3 times
|
||||
Then I see the first count is 3
|
||||
And I see the second count is 3
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
@@ -0,0 +1,20 @@
|
||||
@click_parallel_counts_1
|
||||
Feature: Click Parallel Count (1)
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
Scenario Outline: Should increase the first and second counts
|
||||
|
||||
Given I select the mode <Mode>
|
||||
And I select the component Parallel
|
||||
When I click the first count 3 times
|
||||
Then I see the first count is 3
|
||||
And I see the second count is 3
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
@@ -0,0 +1,20 @@
|
||||
@click_parallel_counts_2
|
||||
Feature: Click Parallel Count (2)
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
Scenario Outline: Should increase the first and second counts
|
||||
|
||||
Given I select the mode <Mode>
|
||||
And I select the component Parallel
|
||||
When I click the second count 3 times
|
||||
Then I see the first count is 3
|
||||
And I see the second count is 3
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
@@ -0,0 +1,19 @@
|
||||
@click_single_count
|
||||
Feature: Click Single Count
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
Scenario Outline: Should increase the count
|
||||
|
||||
Given I select the mode <Mode>
|
||||
And I select the component Single
|
||||
When I click the count 3 times
|
||||
Then I see the count is 3
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
7
examples/suspense_tests/e2e/features/open_app.feature
Normal file
7
examples/suspense_tests/e2e/features/open_app.feature
Normal file
@@ -0,0 +1,7 @@
|
||||
@open_app
|
||||
Feature: Open App
|
||||
|
||||
@open_app-title
|
||||
Scenario: Should see the initial page title
|
||||
When I open the app
|
||||
Then I see the page title is Out-of-Order
|
||||
@@ -0,0 +1,55 @@
|
||||
@view_inside_component
|
||||
Feature: View Inside Component
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
@view_inside_component
|
||||
Scenario Outline: Should see the page title
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Inside Component
|
||||
Then I see the page title is <Mode>
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_inside_component-one
|
||||
Scenario Outline: Should see the one second message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Inside Component
|
||||
Then I see the one second message is One Second: Loaded 1!
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_inside_component-inside
|
||||
Scenario Outline: Should see the inside message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Inside Component
|
||||
Then I see the inside message is Suspense inside another component should work.
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_inside_component-following
|
||||
Scenario Outline: Should see the following message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Inside Component
|
||||
Then I see the following message is Children following Suspense should hydrate properly.
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
45
examples/suspense_tests/e2e/features/view_nested.feature
Normal file
45
examples/suspense_tests/e2e/features/view_nested.feature
Normal file
@@ -0,0 +1,45 @@
|
||||
@view_nested
|
||||
Feature: View Nested
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
@view_nested-title
|
||||
Scenario Outline: Should see the page title
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Nested
|
||||
Then I see the page title is <Mode>
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_nested-one
|
||||
Scenario Outline: Should see the one second message
|
||||
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Nested
|
||||
Then I see the one second message is One Second: Loaded 1!
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_nested-two
|
||||
Scenario Outline: Should see the two second message
|
||||
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Nested
|
||||
Then I see the two second message is Two Second: Loaded 2!
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
@view_nested_inside
|
||||
Feature: View Nested Inside
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
@view_nested_inside-title
|
||||
Scenario Outline: Should see the page title
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Nested (resource created inside)
|
||||
Then I see the page title is <Mode>
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_nested_inside-one
|
||||
Scenario Outline: Should see the one second message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Nested (resource created inside)
|
||||
Then I see the one second message is One Second: Loaded 1!
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_nested_inside-two
|
||||
Scenario Outline: Should see the two second message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Nested (resource created inside)
|
||||
Then I see the two second message is Loaded 2 (created inside first suspense)!: Ok(())
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
@@ -0,0 +1,42 @@
|
||||
@view_no_resources
|
||||
Feature: view No Resources
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
@view_no_resources-title
|
||||
Scenario Outline: Should see the page title
|
||||
Given I select the mode <Mode>
|
||||
When I select the component No Resources
|
||||
Then I see the page title is <Mode>
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_no_resources-another
|
||||
Scenario Outline: Should see the inside message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component No Resources
|
||||
Then I see the inside message is Children inside Suspense should hydrate properly.
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_no_resources-following
|
||||
Scenario Outline: Should see the following message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component No Resources
|
||||
Then I see the following message is Children following Suspense should hydrate properly.
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
42
examples/suspense_tests/e2e/features/view_parallel.feature
Normal file
42
examples/suspense_tests/e2e/features/view_parallel.feature
Normal file
@@ -0,0 +1,42 @@
|
||||
@view_parallel
|
||||
Feature: View Parallel
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
@view_parallel-title
|
||||
Scenario Outline: Should see the page title
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Parallel
|
||||
Then I see the page title is <Mode>
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_parallel-one
|
||||
Scenario Outline: Should see the one second message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Parallel
|
||||
Then I see the one second message is One Second: Loaded 1!
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_parallel-two
|
||||
Scenario Outline: Should see the two second message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Parallel
|
||||
Then I see the two second message is Two Second: Loaded 2!
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
42
examples/suspense_tests/e2e/features/view_single.feature
Normal file
42
examples/suspense_tests/e2e/features/view_single.feature
Normal file
@@ -0,0 +1,42 @@
|
||||
@view_single
|
||||
Feature: View Single
|
||||
|
||||
Background:
|
||||
|
||||
Given I see the app
|
||||
|
||||
@view_single-title
|
||||
Scenario Outline: Should see the page title
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Single
|
||||
Then I see the page title is <Mode>
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_single-one
|
||||
Scenario Outline: Should see the one second message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Single
|
||||
Then I see the one second message is One Second: Loaded 1!
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
|
||||
@view_single-following
|
||||
Scenario Outline: Should see the following message
|
||||
Given I select the mode <Mode>
|
||||
When I select the component Single
|
||||
Then I see the following message is Children following Suspense should hydrate properly.
|
||||
|
||||
Examples:
|
||||
| Mode |
|
||||
| Out-of-Order |
|
||||
| In-Order |
|
||||
| Async |
|
||||
14
examples/suspense_tests/e2e/tests/app_suite.rs
Normal file
14
examples/suspense_tests/e2e/tests/app_suite.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
mod fixtures;
|
||||
|
||||
use anyhow::Result;
|
||||
use cucumber::World;
|
||||
use fixtures::world::AppWorld;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
AppWorld::cucumber()
|
||||
.fail_on_skipped()
|
||||
.run_and_exit("./features")
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
39
examples/suspense_tests/e2e/tests/fixtures/action.rs
vendored
Normal file
39
examples/suspense_tests/e2e/tests/fixtures/action.rs
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
use super::{find, world::HOST};
|
||||
use anyhow::Result;
|
||||
use fantoccini::{Client, Locator};
|
||||
use std::result::Result::Ok;
|
||||
|
||||
pub async fn goto_path(client: &Client, path: &str) -> Result<()> {
|
||||
let url = format!("{}{}", HOST, path);
|
||||
client.goto(&url).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn click_link(client: &Client, text: &str) -> Result<()> {
|
||||
let link = client
|
||||
.wait()
|
||||
.for_element(Locator::LinkText(text))
|
||||
.await
|
||||
.expect(format!("Link not found by `{}`", text).as_str());
|
||||
|
||||
link.click().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn click_first_button(client: &Client) -> Result<()> {
|
||||
let counter_button = find::first_button(client).await?;
|
||||
|
||||
counter_button.click().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn click_second_button(client: &Client) -> Result<()> {
|
||||
let counter_button = find::second_button(client).await?;
|
||||
|
||||
counter_button.click().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
65
examples/suspense_tests/e2e/tests/fixtures/check.rs
vendored
Normal file
65
examples/suspense_tests/e2e/tests/fixtures/check.rs
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
use crate::fixtures::find;
|
||||
use anyhow::{Ok, Result};
|
||||
use fantoccini::Client;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
pub async fn page_title_is(client: &Client, expected_text: &str) -> Result<()> {
|
||||
let actual = find::page_title(client).await?;
|
||||
assert_eq!(&actual, expected_text);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn loaded_one_message_is(
|
||||
client: &Client,
|
||||
expected_text: &str,
|
||||
) -> Result<()> {
|
||||
let actual = find::loaded_one_message(client).await?;
|
||||
assert_eq!(&actual, expected_text);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn loaded_two_message_is(
|
||||
client: &Client,
|
||||
expected_text: &str,
|
||||
) -> Result<()> {
|
||||
let actual = find::loaded_two_message(client).await?;
|
||||
assert_eq!(&actual, expected_text);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn inside_message_is(
|
||||
client: &Client,
|
||||
expected_text: &str,
|
||||
) -> Result<()> {
|
||||
let actual = find::inside_message(client).await?;
|
||||
assert_eq!(&actual, expected_text);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn following_message_is(
|
||||
client: &Client,
|
||||
expected_text: &str,
|
||||
) -> Result<()> {
|
||||
let actual = find::following_message(client).await?;
|
||||
assert_eq!(&actual, expected_text);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn first_count_is(client: &Client, expected: u32) -> Result<()> {
|
||||
let actual = find::first_count(client).await?;
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn second_count_is(client: &Client, expected: u32) -> Result<()> {
|
||||
let actual = find::second_count(client).await?;
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
89
examples/suspense_tests/e2e/tests/fixtures/find.rs
vendored
Normal file
89
examples/suspense_tests/e2e/tests/fixtures/find.rs
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
use anyhow::{Ok, Result};
|
||||
use fantoccini::{elements::Element, Client, Locator};
|
||||
|
||||
pub async fn page_title(client: &Client) -> Result<String> {
|
||||
let selector = "h1";
|
||||
let element = client
|
||||
.wait()
|
||||
.for_element(Locator::Css(selector))
|
||||
.await
|
||||
.expect(
|
||||
format!("Page title not found by Css selector `{}`", selector)
|
||||
.as_str(),
|
||||
);
|
||||
|
||||
let text = element.text().await?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub async fn loaded_one_message(client: &Client) -> Result<String> {
|
||||
let text = component_message(client, "loaded-1").await?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub async fn loaded_two_message(client: &Client) -> Result<String> {
|
||||
let text = component_message(client, "loaded-2").await?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub async fn following_message(client: &Client) -> Result<String> {
|
||||
let text = component_message(client, "following-message").await?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub async fn inside_message(client: &Client) -> Result<String> {
|
||||
let text = component_message(client, "inside-message").await?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub async fn first_count(client: &Client) -> Result<u32> {
|
||||
let element = first_button(client).await?;
|
||||
let text = element.text().await?;
|
||||
let count = text.parse::<u32>().unwrap();
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub async fn first_button(client: &Client) -> Result<Element> {
|
||||
let counter_button = client
|
||||
.wait()
|
||||
.for_element(Locator::Css("button"))
|
||||
.await
|
||||
.expect("First button not found");
|
||||
|
||||
Ok(counter_button)
|
||||
}
|
||||
|
||||
pub async fn second_count(client: &Client) -> Result<u32> {
|
||||
let element = second_button(client).await?;
|
||||
let text = element.text().await?;
|
||||
let count = text.parse::<u32>().unwrap();
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub async fn second_button(client: &Client) -> Result<Element> {
|
||||
let counter_button = client
|
||||
.wait()
|
||||
.for_element(Locator::Id("second-count"))
|
||||
.await
|
||||
.expect("Second button not found");
|
||||
|
||||
Ok(counter_button)
|
||||
}
|
||||
|
||||
async fn component_message(client: &Client, id: &str) -> Result<String> {
|
||||
let element =
|
||||
client.wait().for_element(Locator::Id(id)).await.expect(
|
||||
format!("loaded message not found by id `{}`", id).as_str(),
|
||||
);
|
||||
|
||||
let text = element.text().await?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
4
examples/suspense_tests/e2e/tests/fixtures/mod.rs
vendored
Normal file
4
examples/suspense_tests/e2e/tests/fixtures/mod.rs
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod action;
|
||||
pub mod check;
|
||||
pub mod find;
|
||||
pub mod world;
|
||||
61
examples/suspense_tests/e2e/tests/fixtures/world/action_steps.rs
vendored
Normal file
61
examples/suspense_tests/e2e/tests/fixtures/world/action_steps.rs
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
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 = r"^I select the mode (.*)$")]
|
||||
async fn i_select_the_mode(world: &mut AppWorld, text: String) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_link(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[given(regex = r"^I select the component (.*)$")]
|
||||
#[when(regex = "^I select the component (.*)$")]
|
||||
async fn i_select_the_component(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
action::click_link(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I click the first count {int} times")]
|
||||
#[when(expr = "I click the count {int} times")]
|
||||
async fn i_click_the_first_button_n_times(
|
||||
world: &mut AppWorld,
|
||||
times: u32,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
|
||||
for _ in 1..=times {
|
||||
action::click_first_button(client).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[when(expr = "I click the second count {int} times")]
|
||||
async fn i_click_the_second_button_n_times(
|
||||
world: &mut AppWorld,
|
||||
times: u32,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
|
||||
for _ in 1..=times {
|
||||
action::click_second_button(client).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
81
examples/suspense_tests/e2e/tests/fixtures/world/check_steps.rs
vendored
Normal file
81
examples/suspense_tests/e2e/tests/fixtures/world/check_steps.rs
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
use crate::fixtures::{check, world::AppWorld};
|
||||
use anyhow::{Ok, Result};
|
||||
use cucumber::then;
|
||||
|
||||
#[then(regex = r"^I see the page title is (.*)$")]
|
||||
async fn i_see_the_page_title_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::page_title_is(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = r"^I see the one second message is (.*)$")]
|
||||
async fn i_see_the_one_second_message_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::loaded_one_message_is(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = r"^I see the two second message is (.*)$")]
|
||||
async fn i_see_the_two_second_message_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::loaded_two_message_is(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = r"^I see the following message is (.*)$")]
|
||||
async fn i_see_the_following_message_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::following_message_is(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(regex = r"^I see the inside message is (.*)$")]
|
||||
async fn i_see_the_inside_message_is(
|
||||
world: &mut AppWorld,
|
||||
text: String,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::inside_message_is(client, &text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(expr = "I see the first count is {int}")]
|
||||
#[then(expr = "I see the count is {int}")]
|
||||
async fn i_see_the_first_count_is(
|
||||
world: &mut AppWorld,
|
||||
expected: u32,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::first_count_is(client, expected).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[then(expr = "I see the second count is {int}")]
|
||||
async fn i_see_the_second_count_is(
|
||||
world: &mut AppWorld,
|
||||
expected: u32,
|
||||
) -> Result<()> {
|
||||
let client = &world.client;
|
||||
check::second_count_is(client, expected).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
39
examples/suspense_tests/e2e/tests/fixtures/world/mod.rs
vendored
Normal file
39
examples/suspense_tests/e2e/tests/fixtures/world/mod.rs
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
pub mod action_steps;
|
||||
pub mod check_steps;
|
||||
|
||||
use anyhow::Result;
|
||||
use cucumber::World;
|
||||
use fantoccini::{
|
||||
error::NewSessionError, wd::Capabilities, Client, ClientBuilder,
|
||||
};
|
||||
|
||||
pub const HOST: &str = "http://127.0.0.1:3000";
|
||||
|
||||
#[derive(Debug, World)]
|
||||
#[world(init = Self::new)]
|
||||
pub struct AppWorld {
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl AppWorld {
|
||||
async fn new() -> Result<Self, anyhow::Error> {
|
||||
let webdriver_client = build_client().await?;
|
||||
|
||||
Ok(Self {
|
||||
client: webdriver_client,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_client() -> Result<Client, NewSessionError> {
|
||||
let mut cap = Capabilities::new();
|
||||
let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap();
|
||||
cap.insert("goog:chromeOptions".to_string(), arg);
|
||||
|
||||
let client = ClientBuilder::native()
|
||||
.capabilities(cap)
|
||||
.connect("http://localhost:4444")
|
||||
.await?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
#[server(OneSecondFn "/api")]
|
||||
async fn one_second_fn(query: ()) -> Result<(), ServerFnError> {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
const WAIT_ONE_SECOND: u64 = 1;
|
||||
const WAIT_TWO_SECONDS: u64 = 2;
|
||||
|
||||
#[server(FirstWaitFn "/api")]
|
||||
async fn first_wait_fn(seconds: u64) -> Result<(), ServerFnError> {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(seconds)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(TwoSecondFn "/api")]
|
||||
async fn two_second_fn(query: ()) -> Result<(), ServerFnError> {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
#[server(SecondWaitFn "/api")]
|
||||
async fn second_wait_fn(seconds: u64) -> Result<(), ServerFnError> {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(seconds)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -115,23 +118,22 @@ fn SecondaryNav(cx: Scope) -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
fn Nested(cx: Scope) -> impl IntoView {
|
||||
let one_second = create_resource(cx, || (), one_second_fn);
|
||||
let two_second = create_resource(cx, || (), two_second_fn);
|
||||
let one_second = create_resource(cx, || WAIT_ONE_SECOND, first_wait_fn);
|
||||
let two_second = create_resource(cx, || WAIT_TWO_SECONDS, second_wait_fn);
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
view! { cx,
|
||||
<div>
|
||||
<Suspense fallback=|| "Loading 1...">
|
||||
"One Second: "
|
||||
{move || {
|
||||
one_second.read(cx).map(|_| "Loaded 1!")
|
||||
one_second.read(cx).map(|_| view! {cx,
|
||||
<p id="loaded-1">"One Second: Loaded 1!"</p>
|
||||
})
|
||||
}}
|
||||
<br/><br/>
|
||||
<Suspense fallback=|| "Loading 2...">
|
||||
"Two Second: "
|
||||
{move || {
|
||||
two_second.read(cx).map(|_| view! { cx,
|
||||
"Loaded 2!"
|
||||
<p id="loaded-2">"Two Second: Loaded 2!"</p>
|
||||
<button on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
{count}
|
||||
</button>
|
||||
@@ -145,27 +147,28 @@ fn Nested(cx: Scope) -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
fn NestedResourceInside(cx: Scope) -> impl IntoView {
|
||||
let one_second = create_resource(cx, || (), one_second_fn);
|
||||
let one_second = create_resource(cx, || WAIT_ONE_SECOND, first_wait_fn);
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
view! { cx,
|
||||
<div>
|
||||
<Suspense fallback=|| "Loading 1...">
|
||||
"One Second: "
|
||||
{move || {
|
||||
{move || {
|
||||
one_second.read(cx).map(|_| {
|
||||
let two_second = create_resource(cx, || (), move |_| async move {
|
||||
leptos::log!("creating two_second resource");
|
||||
two_second_fn(()).await
|
||||
second_wait_fn(WAIT_TWO_SECONDS).await
|
||||
});
|
||||
view! { cx,
|
||||
<p>{move || one_second.read(cx).map(|_| "Loaded 1!")}</p>
|
||||
{move || one_second.read(cx).map(|_|
|
||||
view! {cx,
|
||||
<p id="loaded-1">"One Second: Loaded 1!"</p>
|
||||
}
|
||||
)}
|
||||
<Suspense fallback=|| "Loading 2...">
|
||||
"Two Second: "
|
||||
{move || {
|
||||
two_second.read(cx).map(|x| view! { cx,
|
||||
"Loaded 2 (created inside first suspense)!: "
|
||||
{format!("{x:?}")}
|
||||
<span id="loaded-2">"Loaded 2 (created inside first suspense)!: " {format!("{x:?}")}</span>
|
||||
<button on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
{count}
|
||||
</button>
|
||||
@@ -182,30 +185,27 @@ fn NestedResourceInside(cx: Scope) -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
fn Parallel(cx: Scope) -> impl IntoView {
|
||||
let one_second = create_resource(cx, || (), one_second_fn);
|
||||
let two_second = create_resource(cx, || (), two_second_fn);
|
||||
let one_second = create_resource(cx, || WAIT_ONE_SECOND, first_wait_fn);
|
||||
let two_second = create_resource(cx, || WAIT_TWO_SECONDS, second_wait_fn);
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
view! { cx,
|
||||
<div>
|
||||
<Suspense fallback=|| "Loading 1...">
|
||||
"One Second: "
|
||||
{move || {
|
||||
one_second.read(cx).map(move |_| view! { cx,
|
||||
"Loaded 1"
|
||||
<p id="loaded-1">"One Second: Loaded 1!"</p>
|
||||
<button on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
{count}
|
||||
</button>
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
<br/><br/>
|
||||
<Suspense fallback=|| "Loading 2...">
|
||||
"Two Second: "
|
||||
{move || {
|
||||
two_second.read(cx).map(move |_| view! { cx,
|
||||
"Loaded 2"
|
||||
<button on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
<p id="loaded-2">"Two Second: Loaded 2!"</p>
|
||||
<button id="second-count" on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
{count}
|
||||
</button>
|
||||
})
|
||||
@@ -217,18 +217,19 @@ fn Parallel(cx: Scope) -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
fn Single(cx: Scope) -> impl IntoView {
|
||||
let one_second = create_resource(cx, || (), one_second_fn);
|
||||
let one_second = create_resource(cx, || WAIT_ONE_SECOND, first_wait_fn);
|
||||
let (count, set_count) = create_signal(cx, 0);
|
||||
|
||||
view! { cx,
|
||||
<div>
|
||||
<Suspense fallback=|| "Loading 1...">
|
||||
"One Second: "
|
||||
{move || {
|
||||
one_second.read(cx).map(|_| "Loaded 1!")
|
||||
}}
|
||||
{move || {
|
||||
one_second.read(cx).map(|_| view! {cx,
|
||||
<p id="loaded-1">"One Second: Loaded 1!"</p>
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
<p>"Children following " <code>"<Suspense/>"</code> " should hydrate properly."</p>
|
||||
<p id="following-message">"Children following Suspense should hydrate properly."</p>
|
||||
<div>
|
||||
<button on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
{count}
|
||||
@@ -244,9 +245,9 @@ fn InsideComponent(cx: Scope) -> impl IntoView {
|
||||
|
||||
view! { cx,
|
||||
<div>
|
||||
<p><code>"<Suspense/>"</code> " inside another component should work."</p>
|
||||
<p id="inside-message">"Suspense inside another component should work."</p>
|
||||
<InsideComponentChild/>
|
||||
<p>"Children following " <code>"<Suspense/>"</code> " should hydrate properly."</p>
|
||||
<p id="following-message">"Children following Suspense should hydrate properly."</p>
|
||||
<div>
|
||||
<button on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
{count}
|
||||
@@ -258,13 +259,14 @@ fn InsideComponent(cx: Scope) -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
fn InsideComponentChild(cx: Scope) -> impl IntoView {
|
||||
let one_second = create_resource(cx, || (), one_second_fn);
|
||||
let one_second = create_resource(cx, || WAIT_ONE_SECOND, first_wait_fn);
|
||||
view! { cx,
|
||||
<Suspense fallback=|| "Loading 1...">
|
||||
"One Second: "
|
||||
{move || {
|
||||
one_second.read(cx).map(|_| "Loaded 1!")
|
||||
}}
|
||||
{move || {
|
||||
one_second.read(cx).map(|_| view! {cx,
|
||||
<p id="loaded-1">"One Second: Loaded 1!"</p>
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
@@ -276,14 +278,14 @@ fn None(cx: Scope) -> impl IntoView {
|
||||
view! { cx,
|
||||
<div>
|
||||
<Suspense fallback=|| "Loading 1...">
|
||||
<div>"Children inside Suspense should hydrate properly."</div>
|
||||
<p id="inside-message">"Children inside Suspense should hydrate properly."</p>
|
||||
<button on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
{count}
|
||||
</button>
|
||||
</Suspense>
|
||||
<p>"Children following " <code>"<Suspense/>"</code> " should hydrate properly."</p>
|
||||
<p id="following-message">"Children following Suspense should hydrate properly."</p>
|
||||
<div>
|
||||
<button on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
<button id="second-count" on:click=move |_| set_count.update(|n| *n += 1)>
|
||||
{count}
|
||||
</button>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@ async fn main() -> std::io::Result<()> {
|
||||
use actix_web::*;
|
||||
use leptos::*;
|
||||
use leptos_actix::{generate_route_list, LeptosRoutes};
|
||||
use leptos_start::app::*;
|
||||
use suspense_tests::app::*;
|
||||
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"name": "end2end",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "end2end",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz",
|
||||
"integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"playwright-core": "1.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "18.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz",
|
||||
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz",
|
||||
"integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@playwright/test": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz",
|
||||
"integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"playwright-core": "1.28.0"
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "18.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz",
|
||||
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==",
|
||||
"dev": true
|
||||
},
|
||||
"playwright-core": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz",
|
||||
"integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "end2end",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.28.0"
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { PlaywrightTestConfig } from "@playwright/test";
|
||||
import { devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: "./tests",
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 5000,
|
||||
},
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// port: 3000,
|
||||
// },
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("homepage has title and links to intro page", async ({ page }) => {
|
||||
await page.goto("http://localhost:3000/");
|
||||
|
||||
await expect(page).toHaveTitle("Welcome to Leptos");
|
||||
|
||||
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
|
||||
});
|
||||
@@ -31,6 +31,7 @@ smallvec = "1"
|
||||
tracing = "0.1"
|
||||
wasm-bindgen = { version = "0.2", features = ["enable-interning"] }
|
||||
wasm-bindgen-futures = "0.4.31"
|
||||
serde = "1"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
@@ -2,6 +2,8 @@ mod into_attribute;
|
||||
mod into_class;
|
||||
mod into_property;
|
||||
mod into_style;
|
||||
#[doc(hidden)]
|
||||
pub mod tracing_property;
|
||||
pub use into_attribute::*;
|
||||
pub use into_class::*;
|
||||
pub use into_property::*;
|
||||
|
||||
166
leptos_dom/src/macro_helpers/tracing_property.rs
Normal file
166
leptos_dom/src/macro_helpers/tracing_property.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use wasm_bindgen::UnwrapThrowExt;
|
||||
|
||||
#[macro_export]
|
||||
/// Use for tracing property
|
||||
macro_rules! tracing_props {
|
||||
() => {
|
||||
::leptos::leptos_dom::tracing::span!(
|
||||
::leptos::leptos_dom::tracing::Level::INFO,
|
||||
"leptos_dom::tracing_props",
|
||||
props = String::from("[]")
|
||||
);
|
||||
};
|
||||
($($prop:tt),+ $(,)?) => {
|
||||
{
|
||||
use ::leptos::leptos_dom::tracing_property::{Match, SerializeMatch, DefaultMatch};
|
||||
let mut props = String::from('[');
|
||||
$(
|
||||
let prop = (&&Match {
|
||||
name: stringify!{$prop},
|
||||
value: std::cell::Cell::new(Some(&$prop))
|
||||
}).spez();
|
||||
props.push_str(&format!("{prop},"));
|
||||
)*
|
||||
props.pop();
|
||||
props.push(']');
|
||||
::leptos::leptos_dom::tracing::span!(
|
||||
::leptos::leptos_dom::tracing::Level::INFO,
|
||||
"leptos_dom::tracing_props",
|
||||
props
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Implementation based on spez
|
||||
// see https://github.com/m-ou-se/spez
|
||||
|
||||
pub struct Match<T> {
|
||||
pub name: &'static str,
|
||||
pub value: std::cell::Cell<Option<T>>,
|
||||
}
|
||||
|
||||
pub trait SerializeMatch {
|
||||
type Return;
|
||||
fn spez(&self) -> Self::Return;
|
||||
}
|
||||
impl<T: serde::Serialize> SerializeMatch for &Match<&T> {
|
||||
type Return = String;
|
||||
fn spez(&self) -> Self::Return {
|
||||
let name = self.name;
|
||||
serde_json::to_string(self.value.get().unwrap_throw()).map_or_else(
|
||||
|err| format!(r#"{{"name": "{name}", "error": "{err}"}}"#),
|
||||
|value| format!(r#"{{"name": "{name}", "value": {value}}}"#),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DefaultMatch {
|
||||
type Return;
|
||||
fn spez(&self) -> Self::Return;
|
||||
}
|
||||
impl<T> DefaultMatch for Match<&T> {
|
||||
type Return = String;
|
||||
fn spez(&self) -> Self::Return {
|
||||
let name = self.name;
|
||||
format!(
|
||||
r#"{{"name": "{name}", "error": "The trait `serde::Serialize` is not implemented"}}"#
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_primitive() {
|
||||
// String
|
||||
let test = String::from("string");
|
||||
let prop = (&&Match {
|
||||
name: stringify! {test},
|
||||
value: std::cell::Cell::new(Some(&test)),
|
||||
})
|
||||
.spez();
|
||||
assert_eq!(prop, r#"{"name": "test", "value": "string"}"#);
|
||||
|
||||
// &str
|
||||
let test = "string";
|
||||
let prop = (&&Match {
|
||||
name: stringify! {test},
|
||||
value: std::cell::Cell::new(Some(&test)),
|
||||
})
|
||||
.spez();
|
||||
assert_eq!(prop, r#"{"name": "test", "value": "string"}"#);
|
||||
|
||||
// u128
|
||||
let test: u128 = 1;
|
||||
let prop = (&&Match {
|
||||
name: stringify! {test},
|
||||
value: std::cell::Cell::new(Some(&test)),
|
||||
})
|
||||
.spez();
|
||||
assert_eq!(prop, r#"{"name": "test", "value": 1}"#);
|
||||
|
||||
// i128
|
||||
let test: i128 = -1;
|
||||
let prop = (&&Match {
|
||||
name: stringify! {test},
|
||||
value: std::cell::Cell::new(Some(&test)),
|
||||
})
|
||||
.spez();
|
||||
assert_eq!(prop, r#"{"name": "test", "value": -1}"#);
|
||||
|
||||
// f64
|
||||
let test = 3.14;
|
||||
let prop = (&&Match {
|
||||
name: stringify! {test},
|
||||
value: std::cell::Cell::new(Some(&test)),
|
||||
})
|
||||
.spez();
|
||||
assert_eq!(prop, r#"{"name": "test", "value": 3.14}"#);
|
||||
|
||||
// bool
|
||||
let test = true;
|
||||
let prop = (&&Match {
|
||||
name: stringify! {test},
|
||||
value: std::cell::Cell::new(Some(&test)),
|
||||
})
|
||||
.spez();
|
||||
assert_eq!(prop, r#"{"name": "test", "value": true}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_serialize() {
|
||||
use serde::Serialize;
|
||||
#[derive(Serialize)]
|
||||
struct CustomStruct {
|
||||
field: &'static str,
|
||||
}
|
||||
|
||||
let test = CustomStruct { field: "field" };
|
||||
let prop = (&&Match {
|
||||
name: stringify! {test},
|
||||
value: std::cell::Cell::new(Some(&test)),
|
||||
})
|
||||
.spez();
|
||||
assert_eq!(prop, r#"{"name": "test", "value": {"field":"field"}}"#);
|
||||
// Verification of ownership
|
||||
assert_eq!(test.field, "field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_no_serialize() {
|
||||
struct CustomStruct {
|
||||
field: &'static str,
|
||||
}
|
||||
|
||||
let test = CustomStruct { field: "field" };
|
||||
let prop = (&&Match {
|
||||
name: stringify! {test},
|
||||
value: std::cell::Cell::new(Some(&test)),
|
||||
})
|
||||
.spez();
|
||||
assert_eq!(
|
||||
prop,
|
||||
r#"{"name": "test", "error": "The trait `serde::Serialize` is not implemented"}"#
|
||||
);
|
||||
// Verification of ownership
|
||||
assert_eq!(test.field, "field");
|
||||
}
|
||||
@@ -178,27 +178,38 @@ impl ToTokens for Model {
|
||||
|
||||
let component_fn_prop_docs = generate_component_fn_prop_docs(props);
|
||||
|
||||
let (tracing_instrument_attr, tracing_span_expr, tracing_guard_expr) =
|
||||
if cfg!(feature = "tracing") {
|
||||
(
|
||||
let (
|
||||
tracing_instrument_attr,
|
||||
tracing_span_expr,
|
||||
tracing_guard_expr,
|
||||
tracing_props_expr,
|
||||
) = if cfg!(feature = "tracing") {
|
||||
(
|
||||
quote! {
|
||||
#[allow(clippy::let_with_type_underscore)]
|
||||
#[cfg_attr(
|
||||
any(debug_assertions, feature="ssr"),
|
||||
::leptos::leptos_dom::tracing::instrument(level = "info", name = #trace_name, skip_all)
|
||||
)]
|
||||
},
|
||||
quote! {
|
||||
let span = ::leptos::leptos_dom::tracing::Span::current();
|
||||
},
|
||||
quote! {
|
||||
#[cfg(debug_assertions)]
|
||||
let _guard = span.entered();
|
||||
},
|
||||
if no_props {
|
||||
quote! {}
|
||||
} else {
|
||||
quote! {
|
||||
#[allow(clippy::let_with_type_underscore)]
|
||||
#[cfg_attr(
|
||||
any(debug_assertions, feature="ssr"),
|
||||
::leptos::leptos_dom::tracing::instrument(level = "info", name = #trace_name, skip_all)
|
||||
)]
|
||||
},
|
||||
quote! {
|
||||
let span = ::leptos::leptos_dom::tracing::Span::current();
|
||||
},
|
||||
quote! {
|
||||
#[cfg(debug_assertions)]
|
||||
let _guard = span.entered();
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(quote! {}, quote! {}, quote! {})
|
||||
};
|
||||
::leptos::leptos_dom::tracing_props![#prop_names];
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(quote! {}, quote! {}, quote! {}, quote! {})
|
||||
};
|
||||
|
||||
let component = if *is_transparent {
|
||||
quote! {
|
||||
@@ -211,6 +222,8 @@ impl ToTokens for Model {
|
||||
move |cx| {
|
||||
#tracing_guard_expr
|
||||
|
||||
#tracing_props_expr
|
||||
|
||||
#body_name(cx, #prop_names)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -721,7 +721,7 @@ pub fn component(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
|
||||
/// ```
|
||||
///
|
||||
/// /// Here are some important details about how slots work within the framework:
|
||||
/// 1. Most of the same rules from [component](crate::component!) macro should also be followed on slots.
|
||||
/// 1. Most of the same rules from [`macro@component`] macro should also be followed on slots.
|
||||
///
|
||||
/// 2. Specifying only `slot` without a name (such as in `<HelloSlot slot>`) will default the chosen slot to
|
||||
/// the a snake case version of the slot struct name (`hello_slot` for `<HelloSlot>`).
|
||||
@@ -829,7 +829,7 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
|
||||
/// #[server(ReadPosts, "/api")]
|
||||
/// pub async fn read_posts(how_many: u8, query: String) -> Result<Vec<Post>, ServerFnError> {
|
||||
/// // do some work on the server to access the database
|
||||
/// todo!()
|
||||
/// todo!()
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::{
|
||||
expr_to_ident, fancy_class_name, fancy_style_name,
|
||||
ide_helper::IdeTagHelper,
|
||||
is_ambiguous_element, is_custom_element, is_math_ml_element,
|
||||
is_svg_element, parse_event_name,
|
||||
is_self_closing, is_svg_element, parse_event_name,
|
||||
slot_helper::{get_slot, slot_to_tokens},
|
||||
};
|
||||
use crate::attribute_value;
|
||||
@@ -262,6 +262,16 @@ pub(crate) fn element_to_tokens(
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if is_self_closing(node) && !node.children.is_empty() {
|
||||
proc_macro_error::abort!(
|
||||
node.name().span(),
|
||||
format!(
|
||||
"<{tag}> is a self-closing tag and cannot have children."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let children = node.children.iter().map(|node| {
|
||||
let (child, is_static) = match node {
|
||||
Node::Fragment(fragment) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_meta"
|
||||
version = "0.4.8"
|
||||
version = "0.4.9"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_router"
|
||||
version = "0.4.8"
|
||||
version = "0.4.9"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -18,7 +18,7 @@ use wasm_bindgen::JsCast;
|
||||
#[component]
|
||||
pub fn Router(
|
||||
cx: Scope,
|
||||
/// The base URL for the router. Defaults to "".
|
||||
/// The base URL for the router. Defaults to `""`.
|
||||
#[prop(optional)]
|
||||
base: Option<&'static str>,
|
||||
/// A fallback that should be shown if no route is matched.
|
||||
@@ -445,7 +445,7 @@ pub struct NavigateOptions {
|
||||
/// the "back" button will skip over the current route. (Defaults to `false`).
|
||||
pub replace: bool,
|
||||
/// If `true`, the router will scroll to the top of the window at the end of navigation.
|
||||
/// Defaults to `true.
|
||||
/// Defaults to `true`.
|
||||
pub scroll: bool,
|
||||
/// [State](https://developer.mozilla.org/en-US/docs/Web/API/History/state) that should be pushed
|
||||
/// onto the history stack during navigation.
|
||||
|
||||
Reference in New Issue
Block a user