mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-28 10:11:56 -05:00
Compare commits
55 Commits
v040
...
resource-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87381055d4 | ||
|
|
5d48911f01 | ||
|
|
8a90f97959 | ||
|
|
e9665b34e5 | ||
|
|
d4b1ceda90 | ||
|
|
a0fae88f7d | ||
|
|
03a8609680 | ||
|
|
3e40f9cc66 | ||
|
|
576bb078f7 | ||
|
|
3cdcc85c87 | ||
|
|
ec3a26dfbc | ||
|
|
c755dae6ee | ||
|
|
b67d51e019 | ||
|
|
7a34d6026f | ||
|
|
548eac8e60 | ||
|
|
05ac8e861f | ||
|
|
7a4d475cca | ||
|
|
eea8e60518 | ||
|
|
f6a272498d | ||
|
|
aef7c4ce8e | ||
|
|
b29eb8e032 | ||
|
|
da9183f4b5 | ||
|
|
ae3ddcb0e6 | ||
|
|
c6b8f0e8ed | ||
|
|
bab9f40a81 | ||
|
|
c2cfdf3678 | ||
|
|
8967eadc02 | ||
|
|
4cc65f837f | ||
|
|
22706e7371 | ||
|
|
9f9302662c | ||
|
|
6b90e1babd | ||
|
|
7e540a8f49 | ||
|
|
f06ffd72aa | ||
|
|
83d3d7579c | ||
|
|
39edb6eb45 | ||
|
|
d81c1a929e | ||
|
|
f69c28df18 | ||
|
|
66f54e7f1a | ||
|
|
81e416b085 | ||
|
|
a5f73b441c | ||
|
|
0f1ebccad5 | ||
|
|
2f01df6185 | ||
|
|
c4982319fe | ||
|
|
8fb4e88439 | ||
|
|
e821efca07 | ||
|
|
568f7b21ae | ||
|
|
d3c0f5320c | ||
|
|
5adc88bf50 | ||
|
|
67300adf41 | ||
|
|
4a3a67bf37 | ||
|
|
8150847218 | ||
|
|
8cb95b4646 | ||
|
|
df4ce904a0 | ||
|
|
1cc3a43268 | ||
|
|
d5a862a406 |
28
Cargo.toml
28
Cargo.toml
@@ -26,22 +26,22 @@ members = [
|
||||
exclude = ["benchmarks", "examples"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
|
||||
[workspace.dependencies]
|
||||
leptos = { path = "./leptos", version = "0.4.0" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.4.0" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.4.0" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.4.0" }
|
||||
leptos_reactive = { path = "./leptos_reactive", version = "0.4.0" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.4.0" }
|
||||
server_fn = { path = "./server_fn", version = "0.4.0" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.4.0" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.4.0" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.4.0" }
|
||||
leptos_router = { path = "./router", version = "0.4.0" }
|
||||
leptos_meta = { path = "./meta", version = "0.4.0" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.4.0" }
|
||||
leptos = { path = "./leptos", version = "0.4.2" }
|
||||
leptos_dom = { path = "./leptos_dom", version = "0.4.2" }
|
||||
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.4.2" }
|
||||
leptos_macro = { path = "./leptos_macro", version = "0.4.2" }
|
||||
leptos_reactive = { path = "./leptos_reactive", version = "0.4.2" }
|
||||
leptos_server = { path = "./leptos_server", version = "0.4.2" }
|
||||
server_fn = { path = "./server_fn", version = "0.4.2" }
|
||||
server_fn_macro = { path = "./server_fn_macro", version = "0.4.2" }
|
||||
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.4.2" }
|
||||
leptos_config = { path = "./leptos_config", version = "0.4.2" }
|
||||
leptos_router = { path = "./router", version = "0.4.2" }
|
||||
leptos_meta = { path = "./meta", version = "0.4.2" }
|
||||
leptos_integration_utils = { path = "./integrations/utils", version = "0.4.2" }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -97,24 +97,15 @@ args = ["+nightly", "doc"]
|
||||
cwd = "leptos_macro/example"
|
||||
install_crate = false
|
||||
|
||||
[tasks.test-examples]
|
||||
description = "Run all unit and web tests for examples"
|
||||
[tasks.ci-examples]
|
||||
cwd = "examples"
|
||||
command = "cargo"
|
||||
args = ["make", "test-unit-and-web"]
|
||||
|
||||
[tasks.verify-examples]
|
||||
description = "Run all quality checks and tests for examples"
|
||||
env = { CLEAN_AFTER_VERIFY = "true" }
|
||||
cwd = "examples"
|
||||
command = "cargo"
|
||||
args = ["make", "verify-flow"]
|
||||
args = ["make", "ci-clean"]
|
||||
|
||||
[tasks.clean-examples]
|
||||
description = "Clean all example projects"
|
||||
cwd = "examples"
|
||||
command = "cargo"
|
||||
args = ["make", "clean-all"]
|
||||
args = ["make", "clean"]
|
||||
|
||||
[env]
|
||||
RUSTFLAGS = ""
|
||||
|
||||
@@ -20,18 +20,27 @@ Create a basic Rust binary project
|
||||
cargo init leptos-tutorial
|
||||
```
|
||||
|
||||
> We recommend using `nightly` Rust, as it enables [a few nice features](https://github.com/leptos-rs/leptos#nightly-note). To use `nightly` Rust with WebAssembly, you can run
|
||||
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
|
||||
|
||||
```bash
|
||||
cargo add leptos --features=csr,nightly # or just csr if you're using stable Rust
|
||||
```
|
||||
|
||||
> Using `nightly` Rust, and the `nightly` feature in Leptos enables the function-call syntax for signal getters and setters that is used in most of this book.
|
||||
>
|
||||
> To use `nightly` Rust, you can run
|
||||
>
|
||||
> ```bash
|
||||
> rustup toolchain install nightly
|
||||
> rustup default nightly
|
||||
> rustup target add wasm32-unknown-unknown
|
||||
> ```
|
||||
>
|
||||
> If you’d rather use stable Rust with Leptos, you can do that too. In the guide and examples, you’ll just use the [`ReadSignal::get()`](https://docs.rs/leptos/latest/leptos/struct.ReadSignal.html#impl-SignalGet%3CT%3E-for-ReadSignal%3CT%3E) and [`WriteSignal::set()`](https://docs.rs/leptos/latest/leptos/struct.WriteSignal.html#impl-SignalGet%3CT%3E-for-ReadSignal%3CT%3E) methods instead of calling signal getters and setters as functions.
|
||||
|
||||
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
|
||||
Make sure you've added the `wasm32-unknown-unknown` target do that Rust can compile your code to WebAssembly to run in the browser.
|
||||
|
||||
```bash
|
||||
cargo add leptos
|
||||
rustup target add wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
Create a simple `index.html` in the root of the `leptos-tutorial` directory
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Responding to Changes with create_effect
|
||||
@@ -23,7 +23,6 @@
|
||||
- [Transition](./async/12_transition.md)
|
||||
- [Actions](./async/13_actions.md)
|
||||
- [Interlude: Projecting Children](./interlude_projecting_children.md)
|
||||
- [Responding to Changes with `create_effect`](./14_create_effect.md)
|
||||
- [Global State Management](./15_global_state.md)
|
||||
- [Router](./router/README.md)
|
||||
- [Defining `<Routes/>`](./router/16_routes.md)
|
||||
|
||||
@@ -124,7 +124,7 @@ You can go even deeper. Say you want to have tabs for each contact’s address,
|
||||
|
||||
## `<Outlet/>`
|
||||
|
||||
Parent routes do not automatically render their nested routes. After all, they are just components; they don’t know exactly where they should render their children, and “just stick at at the end of the parent component” is not a great answer.
|
||||
Parent routes do not automatically render their nested routes. After all, they are just components; they don’t know exactly where they should render their children, and “just stick it at the end of the parent component” is not a great answer.
|
||||
|
||||
Instead, you tell a parent component where to render any nested components with an `<Outlet/>` component. The `<Outlet/>` simply renders one of two things:
|
||||
|
||||
|
||||
@@ -70,9 +70,9 @@ let id = move || {
|
||||
This can get a little messy: deriving a signal that wraps an `Option<_>` or `Result<_>` can involve a couple steps. But it’s worth doing this for two reasons:
|
||||
|
||||
1. It’s correct, i.e., it forces you to consider the cases, “What if the user doesn’t pass a value for this query field? What if they pass an invalid value?”
|
||||
2. It’s performant. Specifically, when you navigate between different paths that match the same `<Route/>` with only params or the query changing, you can get fine-grained updates to different parts of your app without rerendering. For example, navigating between different contacts in our contact-list example does a targeted update to the name field (and eventually contact info) without needing to replacing or rerender the wrapping `<Contact/>`. This is what fine-grained reactivity is for.
|
||||
2. It’s performant. Specifically, when you navigate between different paths that match the same `<Route/>` with only params or the query changing, you can get fine-grained updates to different parts of your app without rerendering. For example, navigating between different contacts in our contact-list example does a targeted update to the name field (and eventually contact info) without needing to replace or rerender the wrapping `<Contact/>`. This is what fine-grained reactivity is for.
|
||||
|
||||
> This is the same example from the previous section. The router is such an integrated system that it makes sense to provide a single example highlighting multiple features, even if we haven’t explain them all yet.
|
||||
> This is the same example from the previous section. The router is such an integrated system that it makes sense to provide a single example highlighting multiple features, even if we haven’t explained them all yet.
|
||||
|
||||
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-fy4tjv?file=%2Fsrc%2Fmain.rs&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A3%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A3%7D%5D)
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# The `<Form/>` Component
|
||||
|
||||
Links and forms sometimes seem completely unrelated. But in fact, they work in very similar ways.
|
||||
Links and forms sometimes seem completely unrelated. But, in fact, they work in very similar ways.
|
||||
|
||||
In plain HTML, there are three ways to navigate to another page:
|
||||
|
||||
1. An `<a>` element that links to another page. Navigates to the URL in its `href` attribute with the `GET` HTTP method.
|
||||
2. A `<form method="GET">`. Navigates to the URL in its `action` attribute with the `GET` HTTP method and the form data from its inputs encoded in the URL query string.
|
||||
3. A `<form method="POST">`. Navigates to the URL in its `action` attribute with the `POST` HTTP method and the form data from its inputs encoded in the body of the request.
|
||||
1. An `<a>` element that links to another page: Navigates to the URL in its `href` attribute with the `GET` HTTP method.
|
||||
2. A `<form method="GET">`: Navigates to the URL in its `action` attribute with the `GET` HTTP method and the form data from its inputs encoded in the URL query string.
|
||||
3. A `<form method="POST">`: Navigates to the URL in its `action` attribute with the `POST` HTTP method and the form data from its inputs encoded in the body of the request.
|
||||
|
||||
Since we have a client-side router, we can do client-side link navigations without reloading the page, i.e., without a full round-trip to the server and back. It makes sense that we can do client-side form navigations in the same way.
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ pub async fn axum_extract(cx: Scope) -> Result<String, ServerFnError> {
|
||||
|
||||
These are relatively simple examples accessing basic data from the server. But you can use extractors to access things like headers, cookies, database connection pools, and more, using the exact same `extract()` pattern.
|
||||
|
||||
> Note: For now, the Axum `extract` function only supports extractors for which the state is `()`, i.e., you can't yet use it to extract `State(_)`. You can access `State(_)` by using a custom handler that extracts the state and then provides it via context. [Click here for an example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/main.rs#L91-L92).
|
||||
|
||||
## A Note about Data-Loading Patterns
|
||||
|
||||
Because Actix and (especially) Axum are built on the idea of a single round-trip HTTP request and response, you typically run extractors near the “top” of your application (i.e., before you start rendering) and use the extracted data to determine how that should be rendered. Before you render a `<button>`, you load all the data your app could need. And any given route handler needs to know all the data that will need to be extracted by that route.
|
||||
|
||||
@@ -1 +1,74 @@
|
||||
# Responses and Redirects
|
||||
|
||||
Extractors provide an easy way to access request data inside server functions. Leptos also provides a way to modify the HTTP response, using the `ResponseOptions` type (see docs for [Actix](https://docs.rs/leptos_actix/latest/leptos_actix/struct.ResponseOptions.html) or [Axum](https://docs.rs/leptos_axum/latest/leptos_axum/struct.ResponseOptions.html)) types and the `redirect` helper function (see docs for [Actix](https://docs.rs/leptos_actix/latest/leptos_actix/fn.redirect.html) or [Axum](https://docs.rs/leptos_axum/latest/leptos_axum/fn.redirect.html)).
|
||||
|
||||
## `ResponseOptions`
|
||||
|
||||
`ResponseOptions` is provided via context during the initial server rendering response and during any subsequent server function call. It allows you to easily set the status code for the HTTP response, or to add headers to the HTTP response, e.g., to set cookies.
|
||||
|
||||
```rust
|
||||
#[server(TeaAndCookies)]
|
||||
pub async fn tea_and_cookies(cx: Scope) -> Result<(), ServerFnError> {
|
||||
use actix_web::{cookie::Cookie, http::header, http::header::HeaderValue};
|
||||
use leptos_actix::ResponseOptions;
|
||||
|
||||
// pull ResponseOptions from context
|
||||
let response = expect_context::<ResponseOptions>(cx);
|
||||
|
||||
// set the HTTP status code
|
||||
response.set_status(StatusCode::IM_A_TEAPOT);
|
||||
|
||||
// set a cookie in the HTTP response
|
||||
let mut cookie = Cookie::build("biscuits", "yes").finish();
|
||||
if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) {
|
||||
res.insert_header(header::SET_COOKIE, cookie);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `redirect`
|
||||
|
||||
One common modification to an HTTP response is to redirect to another page. The Actix and Axum integrations provide a `redirect` function to make this easy to do. `redirect` simply sets an HTTP status code of `302 Found` and sets the `Location` header.
|
||||
|
||||
Here’s a simplified example from our [`session_auth_axum` example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/auth.rs#L154-L181).
|
||||
|
||||
```rust
|
||||
#[server(Login, "/api")]
|
||||
pub async fn login(
|
||||
cx: Scope,
|
||||
username: String,
|
||||
password: String,
|
||||
remember: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
// pull the DB pool and auth provider from context
|
||||
let pool = pool(cx)?;
|
||||
let auth = auth(cx)?;
|
||||
|
||||
// check whether the user exists
|
||||
let user: User = User::get_from_username(username, &pool)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
ServerFnError::ServerError("User does not exist.".into())
|
||||
})?;
|
||||
|
||||
// check whether the user has provided the correct password
|
||||
match verify(password, &user.password)? {
|
||||
// if the password is correct...
|
||||
true => {
|
||||
// log the user in
|
||||
auth.login_user(user.id);
|
||||
auth.remember_user(remember.is_some());
|
||||
|
||||
// and redirect to the home page
|
||||
leptos_axum::redirect(cx, "/");
|
||||
Ok(())
|
||||
}
|
||||
// if not, return an error
|
||||
false => Err(ServerFnError::ServerError(
|
||||
"Password does not match.".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This server function can then be used from your application. This `redirect` works well with the progressively-enhanced `<ActionForm/>` component: without JS/WASM, the server response will redirect because of the status code and header. With JS/WASM, the `<ActionForm/>` will detect the redirect in the server function response, and use client-side navigation to redirect to the new page.
|
||||
|
||||
@@ -198,7 +198,7 @@ let (value, set_value) = create_signal(cx, 0);
|
||||
|
||||
view! { cx,
|
||||
<Show
|
||||
when=move || value() > 5
|
||||
when=move || { value() > 5 }
|
||||
fallback=|cx| view! { cx, <Small/> }
|
||||
>
|
||||
<Big/>
|
||||
|
||||
@@ -19,7 +19,7 @@ fn NumericInput(cx: Scope) -> impl IntoView {
|
||||
view! { cx,
|
||||
<label>
|
||||
"Type a number (or not!)"
|
||||
<input type="number" on:input=on_input/>
|
||||
<input on:input=on_input/>
|
||||
<p>
|
||||
"You entered "
|
||||
<strong>{value}</strong>
|
||||
@@ -69,7 +69,7 @@ fn NumericInput(cx: Scope) -> impl IntoView {
|
||||
<h1>"Error Handling"</h1>
|
||||
<label>
|
||||
"Type a number (or something that's not a number!)"
|
||||
<input type="number" on:input=on_input/>
|
||||
<input on:input=on_input/>
|
||||
<ErrorBoundary
|
||||
// the fallback receives a signal containing current errors
|
||||
fallback=|cx, errors| view! { cx,
|
||||
|
||||
@@ -117,7 +117,7 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn ButtonC<F>(cx: Scope) -> impl IntoView {
|
||||
pub fn ButtonC(cx: Scope) -> impl IntoView {
|
||||
view! { cx,
|
||||
<button>"Toggle"</button>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[tasks.test-e2e]
|
||||
dependencies = ["setup-node", "cargo-leptos-e2e"]
|
||||
[tasks.integration-test]
|
||||
dependencies = ["cargo-leptos-e2e"]
|
||||
|
||||
[tasks.clean-all]
|
||||
dependencies = ["clean-cargo", "clean-node_modules", "clean-playwright"]
|
||||
[tasks.cargo-leptos-e2e]
|
||||
command = "cargo"
|
||||
args = ["leptos", "end-to-end"]
|
||||
|
||||
28
examples/cargo-make/clean.toml
Normal file
28
examples/cargo-make/clean.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[tasks.clean]
|
||||
dependencies = [
|
||||
"clean-cargo",
|
||||
"clean-trunk",
|
||||
"clean-node_modules",
|
||||
"clean-playwright",
|
||||
]
|
||||
|
||||
[tasks.clean-cargo]
|
||||
command = "cargo"
|
||||
args = ["clean"]
|
||||
|
||||
[tasks.clean-trunk]
|
||||
command = "trunk"
|
||||
args = ["clean"]
|
||||
|
||||
[tasks.clean-node_modules]
|
||||
script = '''
|
||||
project_dir=${PWD##*/}
|
||||
if [ "$project_dir" != "todomvc" ]; then
|
||||
find . -type d -name node_modules | xargs rm -rf
|
||||
fi
|
||||
'''
|
||||
|
||||
[tasks.clean-playwright]
|
||||
script = '''
|
||||
find . -name playwright-report -name playwright -name test-results | xargs rm -rf
|
||||
'''
|
||||
@@ -1,98 +0,0 @@
|
||||
[tasks.pre-clippy]
|
||||
env = { CARGO_MAKE_CLIPPY_ARGS = "--all-targets --all-features -- -D warnings" }
|
||||
|
||||
[tasks.check-style]
|
||||
description = "Check for style violations"
|
||||
dependencies = ["check-format-flow", "clippy-flow"]
|
||||
|
||||
[tasks.check-format]
|
||||
env = { LEPTOS_PROJECT_DIRECTORY = "../../" }
|
||||
args = ["fmt", "--", "--check", "--config-path", "${LEPTOS_PROJECT_DIRECTORY}"]
|
||||
|
||||
[tasks.clean-cargo]
|
||||
description = "Runs the cargo clean command."
|
||||
category = "Cleanup"
|
||||
command = "cargo"
|
||||
args = ["clean"]
|
||||
|
||||
[tasks.clean-trunk]
|
||||
description = "Runs the trunk clean command."
|
||||
category = "Cleanup"
|
||||
command = "trunk"
|
||||
args = ["clean"]
|
||||
|
||||
[tasks.clean-node_modules]
|
||||
description = "Delete all node_modules directories"
|
||||
category = "Cleanup"
|
||||
script = '''
|
||||
find . -type d -name node_modules | xargs rm -rf
|
||||
'''
|
||||
|
||||
[tasks.clean-playwright]
|
||||
description = "Delete playwright directories"
|
||||
category = "Cleanup"
|
||||
script = '''
|
||||
for pw_dir in $(find . -name playwright.config.ts | xargs dirname)
|
||||
do
|
||||
rm -rf $pw_dir/playwright-report pw_dir/playwright pw_dir/test-results
|
||||
done
|
||||
'''
|
||||
|
||||
[tasks.clean-all]
|
||||
description = "Delete all temporary directories"
|
||||
category = "Cleanup"
|
||||
dependencies = ["clean-cargo"]
|
||||
|
||||
[tasks.test-wasm]
|
||||
env = { CARGO_MAKE_WASM_TEST_ARGS = "--headless --chrome" }
|
||||
command = "cargo"
|
||||
args = ["make", "wasm-pack-test"]
|
||||
|
||||
[tasks.cargo-leptos-e2e]
|
||||
description = "Runs end to end tests with cargo leptos"
|
||||
command = "cargo"
|
||||
args = ["leptos", "end-to-end"]
|
||||
|
||||
[tasks.setup-node]
|
||||
description = "Install node dependencies and playwright browsers"
|
||||
env = { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1" }
|
||||
script = '''
|
||||
BOLD="\e[1m"
|
||||
GREEN="\e[0;32m"
|
||||
RED="\e[0;31m"
|
||||
RESET="\e[0m"
|
||||
|
||||
project_dir=$CARGO_MAKE_WORKING_DIRECTORY
|
||||
|
||||
# Discover commands
|
||||
if command -v pnpm; then
|
||||
NODE_CMD=pnpm
|
||||
PLAYWRIGHT_CMD=pnpm
|
||||
elif command -v npm; then
|
||||
NODE_CMD=npm
|
||||
PLAYWRIGHT_CMD=npx
|
||||
else
|
||||
echo "${RED}${BOLD}ERROR${RESET} - pnpm or npm is required by this task"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install node dependencies
|
||||
for node_path in $(find . -name package.json -not -path '*/node_modules/*')
|
||||
do
|
||||
node_dir=$(dirname $node_path)
|
||||
echo Install node dependencies for $node_dir
|
||||
cd $node_dir
|
||||
${NODE_CMD} install
|
||||
cd ${project_dir}
|
||||
done
|
||||
|
||||
# Install playwright browsers
|
||||
for pw_path in $(find . -name playwright.config.ts)
|
||||
do
|
||||
pw_dir=$(dirname $pw_path)
|
||||
echo Install playwright browsers for $pw_dir
|
||||
cd $pw_dir
|
||||
${PLAYWRIGHT_CMD} playwright install
|
||||
cd $project_dir
|
||||
done
|
||||
'''
|
||||
9
examples/cargo-make/lint.toml
Normal file
9
examples/cargo-make/lint.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[tasks.pre-clippy]
|
||||
env = { CARGO_MAKE_CLIPPY_ARGS = "--all-targets --all-features -- -D warnings" }
|
||||
|
||||
[tasks.check-style]
|
||||
dependencies = ["check-format-flow", "clippy-flow"]
|
||||
|
||||
[tasks.check-format]
|
||||
env = { LEPTOS_PROJECT_DIRECTORY = "../../" }
|
||||
args = ["fmt", "--", "--check", "--config-path", "${LEPTOS_PROJECT_DIRECTORY}"]
|
||||
@@ -1,35 +1,32 @@
|
||||
extend = [{ path = "../cargo-make/common.toml" }]
|
||||
extend = [
|
||||
{ path = "../cargo-make/clean.toml" },
|
||||
{ path = "../cargo-make/lint.toml" },
|
||||
{ path = "../cargo-make/node.toml" },
|
||||
]
|
||||
|
||||
# CI Stages
|
||||
|
||||
[tasks.ci]
|
||||
alias = "verify-flow"
|
||||
dependencies = ["prepare", "lint", "build", "test-flow", "integration-test"]
|
||||
|
||||
[tasks.ci-clean]
|
||||
dependencies = ["ci", "clean"]
|
||||
|
||||
[tasks.prepare]
|
||||
dependencies = ["setup-node"]
|
||||
|
||||
[tasks.lint]
|
||||
dependencies = ["check-style"]
|
||||
|
||||
[tasks.integration-test]
|
||||
|
||||
# ALIASES
|
||||
|
||||
[tasks.verify-flow]
|
||||
description = "Provides pre and post hooks for verify"
|
||||
dependencies = ["pre-verify", "verify", "post-verify"]
|
||||
alias = "ci"
|
||||
|
||||
[tasks.verify]
|
||||
description = "Run all quality checks and tests"
|
||||
dependencies = ["check-style", "test-unit-and-e2e"]
|
||||
[tasks.t]
|
||||
dependencies = ["test-flow"]
|
||||
|
||||
[tasks.test-unit-and-e2e]
|
||||
description = "Run all unit and e2e tests"
|
||||
dependencies = ["test-flow", "test-e2e-flow"]
|
||||
|
||||
[tasks.pre-verify]
|
||||
|
||||
[tasks.post-verify]
|
||||
dependencies = ["maybe-clean-all"]
|
||||
|
||||
[tasks.maybe-clean-all]
|
||||
description = "Used to clean up locally after call to verify-examples"
|
||||
condition = { env_true = ["CLEAN_AFTER_VERIFY"] }
|
||||
|
||||
[tasks.test-e2e-flow]
|
||||
description = "Provides pre and post hooks for test-e2e"
|
||||
dependencies = ["pre-test-e2e", "test-e2e", "post-test-e2e"]
|
||||
|
||||
[tasks.pre-test-e2e]
|
||||
|
||||
[tasks.test-e2e]
|
||||
|
||||
[tasks.post-test-e2e]
|
||||
[tasks.it]
|
||||
alias = "integration-test"
|
||||
|
||||
43
examples/cargo-make/node.toml
Normal file
43
examples/cargo-make/node.toml
Normal file
@@ -0,0 +1,43 @@
|
||||
[tasks.setup-node]
|
||||
description = "Install node dependencies and playwright browsers"
|
||||
env = { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1" }
|
||||
script = '''
|
||||
BOLD="\e[1m"
|
||||
GREEN="\e[0;32m"
|
||||
RED="\e[0;31m"
|
||||
RESET="\e[0m"
|
||||
|
||||
project_dir=$CARGO_MAKE_WORKING_DIRECTORY
|
||||
|
||||
# Discover commands
|
||||
if command -v pnpm; then
|
||||
NODE_CMD=pnpm
|
||||
PLAYWRIGHT_CMD=pnpm
|
||||
elif command -v npm; then
|
||||
NODE_CMD=npm
|
||||
PLAYWRIGHT_CMD=npx
|
||||
else
|
||||
echo "${RED}${BOLD}ERROR${RESET} - pnpm or npm is required by this task"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install node dependencies
|
||||
for node_path in $(find . -name package.json -not -path '*/node_modules/*')
|
||||
do
|
||||
node_dir=$(dirname $node_path)
|
||||
echo Install node dependencies for $node_dir
|
||||
cd $node_dir
|
||||
${NODE_CMD} install
|
||||
cd ${project_dir}
|
||||
done
|
||||
|
||||
# Install playwright browsers
|
||||
for pw_path in $(find . -name playwright.config.ts)
|
||||
do
|
||||
pw_dir=$(dirname $pw_path)
|
||||
echo Install playwright browsers for $pw_dir
|
||||
cd $pw_dir
|
||||
${PLAYWRIGHT_CMD} playwright install
|
||||
cd $project_dir
|
||||
done
|
||||
'''
|
||||
@@ -1,7 +1,4 @@
|
||||
extend = [{ path = "../cargo-make/playwright.toml" }]
|
||||
|
||||
[tasks.test-e2e]
|
||||
dependencies = ["setup-node", "test-playwright-autostart"]
|
||||
|
||||
[tasks.clean-all]
|
||||
dependencies = ["clean-cargo", "clean-node_modules", "clean-playwright"]
|
||||
[tasks.integration-test]
|
||||
dependencies = ["test-playwright-autostart"]
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
[tasks.clean-playwright]
|
||||
description = "Delete playwright directories"
|
||||
category = "Cleanup"
|
||||
script = '''
|
||||
for pw_dir in $(find . -name playwright.config.ts | xargs dirname)
|
||||
do
|
||||
rm -rf $pw_dir/playwright-report pw_dir/playwright pw_dir/test-results
|
||||
done
|
||||
'''
|
||||
|
||||
[tasks.test-playwright-autostart]
|
||||
description = "Run playwright test with server autostart"
|
||||
category = "Test"
|
||||
command = "npm"
|
||||
args = ["run", "e2e:auto-start"]
|
||||
|
||||
[tasks.test-playwright]
|
||||
description = "Run playwright test"
|
||||
category = "Test"
|
||||
script = '''
|
||||
BOLD="\e[1m"
|
||||
GREEN="\e[0;32m"
|
||||
@@ -46,8 +32,6 @@ done
|
||||
'''
|
||||
|
||||
[tasks.test-playwright-ui]
|
||||
description = "Run playwright test --ui"
|
||||
category = "Test"
|
||||
script = '''
|
||||
BOLD="\e[1m"
|
||||
GREEN="\e[0;32m"
|
||||
@@ -77,8 +61,6 @@ done
|
||||
'''
|
||||
|
||||
[tasks.test-playwright-report]
|
||||
description = "Run playwright show-report"
|
||||
category = "Test"
|
||||
script = '''
|
||||
BOLD="\e[1m"
|
||||
GREEN="\e[0;32m"
|
||||
|
||||
@@ -2,13 +2,9 @@
|
||||
command = "trunk"
|
||||
args = ["build"]
|
||||
|
||||
[tasks.clean-trunk]
|
||||
command = "trunk"
|
||||
args = ["clean"]
|
||||
|
||||
[tasks.start-trunk]
|
||||
command = "trunk"
|
||||
args = ["serve", "--open"]
|
||||
args = ["serve", "${@}"]
|
||||
|
||||
[tasks.stop-trunk]
|
||||
script = '''
|
||||
|
||||
@@ -5,5 +5,7 @@ condition = { env_true = ["RUN_CARGO_TEST"] }
|
||||
[tasks.post-test]
|
||||
dependencies = ["test-wasm"]
|
||||
|
||||
[tasks.clean-all]
|
||||
dependencies = ["clean-cargo", "clean-trunk"]
|
||||
[tasks.test-wasm]
|
||||
env = { CARGO_MAKE_WASM_TEST_ARGS = "--headless --chrome" }
|
||||
command = "cargo"
|
||||
args = ["make", "wasm-pack-test"]
|
||||
|
||||
@@ -25,7 +25,7 @@ leptos_meta = { path = "../../meta" }
|
||||
leptos_router = { path = "../../router" }
|
||||
log = "0.4"
|
||||
gloo-net = { git = "https://github.com/rustwasm/gloo" }
|
||||
wasm-bindgen = "=0.2.86"
|
||||
wasm-bindgen = "=0.2.87"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -67,24 +67,10 @@ pub fn Counters(cx: Scope) -> impl IntoView {
|
||||
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route
|
||||
path=""
|
||||
view=|cx| {
|
||||
view! { cx, <Counter/> }
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="form"
|
||||
view=|cx| {
|
||||
view! { cx, <FormCounter/> }
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="multi"
|
||||
view=|cx| {
|
||||
view! { cx, <MultiuserCounter/> }
|
||||
}
|
||||
/>
|
||||
<Route path="" view=Counter/>
|
||||
<Route path="form" view=FormCounter/>
|
||||
<Route path="multi" view=MultiuserCounter/>
|
||||
<Route path="multi" view=NotFound/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
@@ -175,13 +161,9 @@ pub fn FormCounter(cx: Scope) -> impl IntoView {
|
||||
"This counter uses forms to set the value on the server. When progressively enhanced, it should behave identically to the “Simple Counter.”"
|
||||
</p>
|
||||
<div>
|
||||
// calling a server function is the same as POSTing to its API URL
|
||||
// so we can just do that with a form and button
|
||||
<ActionForm action=clear>
|
||||
<input type="submit" value="Clear"/>
|
||||
</ActionForm>
|
||||
// We can submit named arguments to the server functions
|
||||
// by including them as input values with the same name
|
||||
<ActionForm action=adjust>
|
||||
<input type="hidden" name="delta" value="-1"/>
|
||||
<input type="hidden" name="msg" value="form value down"/>
|
||||
@@ -256,3 +238,14 @@ pub fn MultiuserCounter(cx: Scope) -> impl IntoView {
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NotFound(cx: Scope) -> impl IntoView {
|
||||
#[cfg(feature = "ssr")]
|
||||
{
|
||||
let resp = expect_context::<leptos_actix::ResponseOptions>(cx);
|
||||
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
view! { cx, <h1>"Not Found"</h1> }
|
||||
}
|
||||
|
||||
@@ -52,15 +52,36 @@ cfg_if! {
|
||||
App::new()
|
||||
.service(counter_events)
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), |cx| view! { cx, <Counters/> })
|
||||
.service(Files::new("/", site_root))
|
||||
// serve JS/WASM/CSS from `pkg`
|
||||
.service(Files::new("/pkg", format!("{site_root}/pkg")))
|
||||
// serve other assets from the `assets` directory
|
||||
.service(Files::new("/assets", site_root))
|
||||
// serve the favicon from /favicon.ico
|
||||
.service(favicon)
|
||||
.leptos_routes(
|
||||
leptos_options.to_owned(),
|
||||
routes.to_owned(),
|
||||
Counters,
|
||||
)
|
||||
.app_data(web::Data::new(leptos_options.to_owned()))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[actix_web::get("favicon.ico")]
|
||||
async fn favicon(
|
||||
leptos_options: actix_web::web::Data<leptos::LeptosOptions>,
|
||||
) -> actix_web::Result<actix_files::NamedFile> {
|
||||
let leptos_options = leptos_options.into_inner();
|
||||
let site_root = &leptos_options.site_root;
|
||||
Ok(actix_files::NamedFile::open(format!(
|
||||
"{site_root}/favicon.ico"
|
||||
))?)
|
||||
}
|
||||
}
|
||||
|
||||
// client-only main for Trunk
|
||||
else {
|
||||
|
||||
@@ -5,10 +5,23 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
leptos = { path = "../../leptos", features = ["csr"] }
|
||||
leptos_meta = { path = "../../meta", features = ["csr"] }
|
||||
log = "0.4"
|
||||
console_log = "1"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3.0"
|
||||
wasm-bindgen = "0.2.87"
|
||||
wasm-bindgen-test = "0.3.37"
|
||||
pretty_assertions = "1.3.0"
|
||||
|
||||
[dev-dependencies.web-sys]
|
||||
features = [
|
||||
"Event",
|
||||
"EventInit",
|
||||
"EventTarget",
|
||||
"HtmlElement",
|
||||
"HtmlInputElement",
|
||||
"XPathResult",
|
||||
]
|
||||
version = "0.3.64"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/wasm-test.toml" },
|
||||
{ path = "../cargo-make/trunk_server.toml" },
|
||||
{ path = "../cargo-make/playwright-test.toml" },
|
||||
]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./counters_page";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
test.describe("Add 1000 Counters", () => {
|
||||
test("should increment the total count by 1K", async ({ page }) => {
|
||||
test("should increase the number of counters", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
|
||||
await Promise.all([
|
||||
@@ -14,7 +14,6 @@ test.describe("Add 1000 Counters", () => {
|
||||
await ui.addOneThousandCounters();
|
||||
await ui.addOneThousandCounters();
|
||||
|
||||
await expect(ui.total).toHaveText("0");
|
||||
await expect(ui.counters).toHaveText("3000");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./counters_page";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
test.describe("Add Counter", () => {
|
||||
test("should increment the total count", async ({ page }) => {
|
||||
test("should increase the number of counters", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
await ui.goto();
|
||||
|
||||
@@ -10,7 +10,6 @@ test.describe("Add Counter", () => {
|
||||
await ui.addCounter();
|
||||
await ui.addCounter();
|
||||
|
||||
await expect(ui.total).toHaveText("0");
|
||||
await expect(ui.counters).toHaveText("3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./counters_page";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
test.describe("Clear Counters", () => {
|
||||
test("should reset the counts", async ({ page }) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./counters_page";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
test.describe("Decrement Count", () => {
|
||||
test("should decrement the total count", async ({ page }) => {
|
||||
test("should decrease the total count", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
await ui.goto();
|
||||
await ui.addCounter();
|
||||
@@ -12,6 +12,5 @@ test.describe("Decrement Count", () => {
|
||||
await ui.decrementCount();
|
||||
|
||||
await expect(ui.total).toHaveText("-3");
|
||||
await expect(ui.counters).toHaveText("1");
|
||||
});
|
||||
});
|
||||
|
||||
30
examples/counters_stable/e2e/tests/enter_count.spec.ts
Normal file
30
examples/counters_stable/e2e/tests/enter_count.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
test.describe("Enter Count", () => {
|
||||
test("should increase the total count", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
await ui.goto();
|
||||
await ui.addCounter();
|
||||
|
||||
await ui.enterCount("5");
|
||||
|
||||
await expect(ui.total).toHaveText("5");
|
||||
await expect(ui.counters).toHaveText("1");
|
||||
});
|
||||
|
||||
test("should decrease the total count", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
await ui.goto();
|
||||
await ui.addCounter();
|
||||
await ui.addCounter();
|
||||
await ui.addCounter();
|
||||
|
||||
await ui.enterCount("100");
|
||||
await ui.enterCount("100", 1);
|
||||
await ui.enterCount("100", 2);
|
||||
await ui.enterCount("50", 1);
|
||||
|
||||
await expect(ui.total).toHaveText("250");
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,11 @@ export class CountersPage {
|
||||
readonly addCounterButton: Locator;
|
||||
readonly addOneThousandCountersButton: Locator;
|
||||
readonly clearCountersButton: Locator;
|
||||
readonly decrementCountButton: Locator;
|
||||
|
||||
readonly incrementCountButton: Locator;
|
||||
readonly counterInput: Locator;
|
||||
readonly decrementCountButton: Locator;
|
||||
readonly removeCountButton: Locator;
|
||||
|
||||
readonly total: Locator;
|
||||
readonly counters: Locator;
|
||||
@@ -32,9 +35,15 @@ export class CountersPage {
|
||||
hasText: "+1",
|
||||
});
|
||||
|
||||
this.removeCountButton = page.locator("button", {
|
||||
hasText: "x",
|
||||
});
|
||||
|
||||
this.total = page.getByTestId("total");
|
||||
|
||||
this.counters = page.getByTestId("counters");
|
||||
|
||||
this.counterInput = page.getByRole("textbox");
|
||||
}
|
||||
|
||||
async goto() {
|
||||
@@ -52,17 +61,17 @@ export class CountersPage {
|
||||
this.addOneThousandCountersButton.click();
|
||||
}
|
||||
|
||||
async decrementCount() {
|
||||
async decrementCount(index: number = 0) {
|
||||
await Promise.all([
|
||||
this.decrementCountButton.waitFor(),
|
||||
this.decrementCountButton.click(),
|
||||
this.decrementCountButton.nth(index).waitFor(),
|
||||
this.decrementCountButton.nth(index).click(),
|
||||
]);
|
||||
}
|
||||
|
||||
async incrementCount() {
|
||||
async incrementCount(index: number = 0) {
|
||||
await Promise.all([
|
||||
this.incrementCountButton.waitFor(),
|
||||
this.incrementCountButton.click(),
|
||||
this.incrementCountButton.nth(index).waitFor(),
|
||||
this.incrementCountButton.nth(index).click(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -72,4 +81,18 @@ export class CountersPage {
|
||||
this.clearCountersButton.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
async enterCount(count: string, index: number = 0) {
|
||||
await Promise.all([
|
||||
this.counterInput.nth(index).waitFor(),
|
||||
this.counterInput.nth(index).fill(count),
|
||||
]);
|
||||
}
|
||||
|
||||
async removeCounter(index: number = 0) {
|
||||
await Promise.all([
|
||||
this.removeCountButton.nth(index).waitFor(),
|
||||
this.removeCountButton.nth(index).click(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./counters_page";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
test.describe("Increment Count", () => {
|
||||
test("should increment the total count", async ({ page }) => {
|
||||
test("should increase the total count", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
await ui.goto();
|
||||
await ui.addCounter();
|
||||
@@ -12,6 +12,5 @@ test.describe("Increment Count", () => {
|
||||
await ui.incrementCount();
|
||||
|
||||
await expect(ui.total).toHaveText("3");
|
||||
await expect(ui.counters).toHaveText("1");
|
||||
});
|
||||
});
|
||||
|
||||
17
examples/counters_stable/e2e/tests/remove_counter.spec.ts
Normal file
17
examples/counters_stable/e2e/tests/remove_counter.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
test.describe("Remove Counter", () => {
|
||||
test("should decrement the number of counters", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
await ui.goto();
|
||||
|
||||
await ui.addCounter();
|
||||
await ui.addCounter();
|
||||
await ui.addCounter();
|
||||
|
||||
await ui.removeCounter(1);
|
||||
|
||||
await expect(ui.counters).toHaveText("2");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CountersPage } from "./counters_page";
|
||||
import { CountersPage } from "./fixtures/counters_page";
|
||||
|
||||
test.describe("View Counters", () => {
|
||||
test("should_see_the_title", async ({ page }) => {
|
||||
test("should see the title", async ({ page }) => {
|
||||
const ui = new CountersPage(page);
|
||||
await ui.goto();
|
||||
|
||||
|
||||
109
examples/counters_stable/src/lib.rs
Normal file
109
examples/counters_stable/src/lib.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
|
||||
const MANY_COUNTERS: usize = 1000;
|
||||
|
||||
type CounterHolder = Vec<(usize, (ReadSignal<i32>, WriteSignal<i32>))>;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
struct CounterUpdater {
|
||||
set_counters: WriteSignal<CounterHolder>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Counters(cx: Scope) -> impl IntoView {
|
||||
let (next_counter_id, set_next_counter_id) = create_signal(cx, 0);
|
||||
let (counters, set_counters) = create_signal::<CounterHolder>(cx, vec![]);
|
||||
provide_context(cx, CounterUpdater { set_counters });
|
||||
provide_meta_context(cx);
|
||||
|
||||
let add_counter = move |_| {
|
||||
let id = next_counter_id.get();
|
||||
let sig = create_signal(cx, 0);
|
||||
set_counters.update(move |counters| counters.push((id, sig)));
|
||||
set_next_counter_id.update(|id| *id += 1);
|
||||
};
|
||||
|
||||
let add_many_counters = move |_| {
|
||||
let next_id = next_counter_id.get();
|
||||
let new_counters = (next_id..next_id + MANY_COUNTERS).map(|id| {
|
||||
let signal = create_signal(cx, 0);
|
||||
(id, signal)
|
||||
});
|
||||
|
||||
set_counters.update(move |counters| counters.extend(new_counters));
|
||||
set_next_counter_id.update(|id| *id += MANY_COUNTERS);
|
||||
};
|
||||
|
||||
let clear_counters = move |_| {
|
||||
set_counters.update(|counters| counters.clear());
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<Title text="Counters (Stable)" />
|
||||
<div>
|
||||
<button on:click=add_counter>
|
||||
"Add Counter"
|
||||
</button>
|
||||
<button on:click=add_many_counters>
|
||||
{format!("Add {MANY_COUNTERS} Counters")}
|
||||
</button>
|
||||
<button on:click=clear_counters>
|
||||
"Clear Counters"
|
||||
</button>
|
||||
<p>
|
||||
"Total: "
|
||||
<span data-testid="total">{move ||
|
||||
counters.get()
|
||||
.iter()
|
||||
.map(|(_, (count, _))| count.get())
|
||||
.sum::<i32>()
|
||||
.to_string()
|
||||
}</span>
|
||||
" from "
|
||||
<span data-testid="counters">{move || counters.with(|counters| counters.len()).to_string()}</span>
|
||||
" counters."
|
||||
</p>
|
||||
<ul>
|
||||
<For
|
||||
each={move || counters.get()}
|
||||
key={|counter| counter.0}
|
||||
view=move |cx, (id, (value, set_value))| {
|
||||
view! {
|
||||
cx,
|
||||
<Counter id value set_value/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Counter(
|
||||
cx: Scope,
|
||||
id: usize,
|
||||
value: ReadSignal<i32>,
|
||||
set_value: WriteSignal<i32>,
|
||||
) -> impl IntoView {
|
||||
let CounterUpdater { set_counters } = use_context(cx).unwrap();
|
||||
|
||||
let input = move |ev| {
|
||||
set_value
|
||||
.set(event_target_value(&ev).parse::<i32>().unwrap_or_default())
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<li>
|
||||
<button data-testid="decrement_count" on:click=move |_| set_value.update(move |value| *value -= 1)>"-1"</button>
|
||||
<input data-testid="counter_input" type="text"
|
||||
prop:value={move || value.get().to_string()}
|
||||
on:input=input
|
||||
/>
|
||||
<span>{value}</span>
|
||||
<button data-testid="increment_count" on:click=move |_| set_value.update(move |value| *value += 1)>"+1"</button>
|
||||
<button data-testid="remove_counter" on:click=move |_| set_counters.update(move |counters| counters.retain(|(counter_id, _)| counter_id != &id))>"x"</button>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use counters_stable::Counters;
|
||||
use leptos::*;
|
||||
|
||||
fn main() {
|
||||
@@ -5,108 +6,3 @@ fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(|cx| view! { cx, <Counters/> })
|
||||
}
|
||||
|
||||
const MANY_COUNTERS: usize = 1000;
|
||||
|
||||
type CounterHolder = Vec<(usize, (ReadSignal<i32>, WriteSignal<i32>))>;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
struct CounterUpdater {
|
||||
set_counters: WriteSignal<CounterHolder>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Counters(cx: Scope) -> impl IntoView {
|
||||
let (next_counter_id, set_next_counter_id) = create_signal(cx, 0);
|
||||
let (counters, set_counters) = create_signal::<CounterHolder>(cx, vec![]);
|
||||
provide_context(cx, CounterUpdater { set_counters });
|
||||
|
||||
let add_counter = move |_| {
|
||||
let id = next_counter_id.get();
|
||||
let sig = create_signal(cx, 0);
|
||||
set_counters.update(move |counters| counters.push((id, sig)));
|
||||
set_next_counter_id.update(|id| *id += 1);
|
||||
};
|
||||
|
||||
let add_many_counters = move |_| {
|
||||
let next_id = next_counter_id.get();
|
||||
let new_counters = (next_id..next_id + MANY_COUNTERS).map(|id| {
|
||||
let signal = create_signal(cx, 0);
|
||||
(id, signal)
|
||||
});
|
||||
|
||||
set_counters.update(move |counters| counters.extend(new_counters));
|
||||
set_next_counter_id.update(|id| *id += MANY_COUNTERS);
|
||||
};
|
||||
|
||||
let clear_counters = move |_| {
|
||||
set_counters.update(|counters| counters.clear());
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<div>
|
||||
<button on:click=add_counter>
|
||||
"Add Counter"
|
||||
</button>
|
||||
<button on:click=add_many_counters>
|
||||
{format!("Add {MANY_COUNTERS} Counters")}
|
||||
</button>
|
||||
<button on:click=clear_counters>
|
||||
"Clear Counters"
|
||||
</button>
|
||||
<p>
|
||||
"Total: "
|
||||
<span id="total" data-testid="total">{move ||
|
||||
counters.get()
|
||||
.iter()
|
||||
.map(|(_, (count, _))| count.get())
|
||||
.sum::<i32>()
|
||||
.to_string()
|
||||
}</span>
|
||||
" from "
|
||||
<span id="counters" data-testid="counters">{move || counters.with(|counters| counters.len()).to_string()}</span>
|
||||
" counters."
|
||||
</p>
|
||||
<ul>
|
||||
<For
|
||||
each={move || counters.get()}
|
||||
key={|counter| counter.0}
|
||||
view=move |cx, (id, (value, set_value))| {
|
||||
view! {
|
||||
cx,
|
||||
<Counter id value set_value/>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Counter(
|
||||
cx: Scope,
|
||||
id: usize,
|
||||
value: ReadSignal<i32>,
|
||||
set_value: WriteSignal<i32>,
|
||||
) -> impl IntoView {
|
||||
let CounterUpdater { set_counters } = use_context(cx).unwrap();
|
||||
|
||||
let input = move |ev| {
|
||||
set_value
|
||||
.set(event_target_value(&ev).parse::<i32>().unwrap_or_default())
|
||||
};
|
||||
|
||||
view! { cx,
|
||||
<li>
|
||||
<button id="decrement_count" on:click=move |_| set_value.update(move |value| *value -= 1)>"-1"</button>
|
||||
<input type="text"
|
||||
prop:value={move || value.get().to_string()}
|
||||
on:input=input
|
||||
/>
|
||||
<span>{move || value.get().to_string()}</span>
|
||||
<button id="increment_count" on:click=move |_| set_value.update(move |value| *value += 1)>"+1"</button>
|
||||
<button on:click=move |_| set_counters.update(move |counters| counters.retain(|(counter_id, _)| counter_id != &id))>"x"</button>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
17
examples/counters_stable/tests/web/add_1k_counters.rs
Normal file
17
examples/counters_stable/tests/web/add_1k_counters.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use super::*;
|
||||
use crate::counters_page as ui;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn should_increase_the_number_of_counters() {
|
||||
// Given
|
||||
ui::view_counters();
|
||||
|
||||
// When
|
||||
ui::add_1k_counters();
|
||||
ui::add_1k_counters();
|
||||
ui::add_1k_counters();
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::counters(), 3000);
|
||||
}
|
||||
17
examples/counters_stable/tests/web/add_counter.rs
Normal file
17
examples/counters_stable/tests/web/add_counter.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use super::*;
|
||||
use crate::counters_page as ui;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn should_increase_the_number_of_counters() {
|
||||
// Given
|
||||
ui::view_counters();
|
||||
|
||||
// When
|
||||
ui::add_counter();
|
||||
ui::add_counter();
|
||||
ui::add_counter();
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::counters(), 3);
|
||||
}
|
||||
19
examples/counters_stable/tests/web/clear_counters.rs
Normal file
19
examples/counters_stable/tests/web/clear_counters.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use super::*;
|
||||
use crate::counters_page as ui;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn should_reset_the_counts() {
|
||||
// Given
|
||||
ui::view_counters();
|
||||
ui::add_counter();
|
||||
ui::add_counter();
|
||||
ui::add_counter();
|
||||
|
||||
// When
|
||||
ui::clear_counters();
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::total(), 0);
|
||||
assert_eq!(ui::counters(), 0);
|
||||
}
|
||||
18
examples/counters_stable/tests/web/decrement_counter.rs
Normal file
18
examples/counters_stable/tests/web/decrement_counter.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use super::*;
|
||||
use crate::counters_page as ui;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn should_decrease_the_total_count() {
|
||||
// Given
|
||||
ui::view_counters();
|
||||
ui::add_counter();
|
||||
|
||||
// When
|
||||
ui::decrement_counter(1);
|
||||
ui::decrement_counter(1);
|
||||
ui::decrement_counter(1);
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::total(), -3);
|
||||
}
|
||||
34
examples/counters_stable/tests/web/enter_count.rs
Normal file
34
examples/counters_stable/tests/web/enter_count.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
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::enter_count(1, 5);
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::total(), 5);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn should_decrease_the_total_count() {
|
||||
// Given
|
||||
ui::view_counters();
|
||||
ui::add_counter();
|
||||
ui::add_counter();
|
||||
ui::add_counter();
|
||||
|
||||
// When
|
||||
ui::enter_count(1, 100);
|
||||
ui::enter_count(2, 100);
|
||||
ui::enter_count(3, 100);
|
||||
ui::enter_count(1, 50);
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::total(), 250);
|
||||
}
|
||||
112
examples/counters_stable/tests/web/fixtures/counters_page.rs
Normal file
112
examples/counters_stable/tests/web/fixtures/counters_page.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use counters_stable::Counters;
|
||||
use leptos::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{Element, Event, EventInit, HtmlElement, HtmlInputElement};
|
||||
|
||||
// Actions
|
||||
|
||||
pub fn add_1k_counters() {
|
||||
find_by_text("Add 1000 Counters").click();
|
||||
}
|
||||
|
||||
pub fn add_counter() {
|
||||
find_by_text("Add Counter").click();
|
||||
}
|
||||
|
||||
pub fn clear_counters() {
|
||||
find_by_text("Clear Counters").click();
|
||||
}
|
||||
|
||||
pub fn decrement_counter(index: u32) {
|
||||
counter_html_element(index, "decrement_count").click();
|
||||
}
|
||||
|
||||
pub fn enter_count(index: u32, count: i32) {
|
||||
let input = counter_input_element(index, "counter_input");
|
||||
input.set_value(count.to_string().as_str());
|
||||
let mut event_init = EventInit::new();
|
||||
event_init.bubbles(true);
|
||||
let event = Event::new_with_event_init_dict("input", &event_init).unwrap();
|
||||
input.dispatch_event(&event).unwrap();
|
||||
}
|
||||
|
||||
pub fn increment_counter(index: u32) {
|
||||
counter_html_element(index, "increment_count").click();
|
||||
}
|
||||
|
||||
pub fn remove_counter(index: u32) {
|
||||
counter_html_element(index, "remove_counter").click();
|
||||
}
|
||||
|
||||
pub fn view_counters() {
|
||||
remove_existing_counters();
|
||||
mount_to_body(|cx| view! { cx, <Counters/> });
|
||||
}
|
||||
|
||||
// Results
|
||||
|
||||
pub fn counters() -> i32 {
|
||||
data_test_id("counters").parse::<i32>().unwrap()
|
||||
}
|
||||
|
||||
pub fn title() -> String {
|
||||
leptos::document().title()
|
||||
}
|
||||
|
||||
pub fn total() -> i32 {
|
||||
data_test_id("total").parse::<i32>().unwrap()
|
||||
}
|
||||
|
||||
// Internal
|
||||
|
||||
fn counter_element(index: u32, text: &str) -> Element {
|
||||
let selector =
|
||||
format!("li:nth-child({}) [data-testid=\"{}\"]", index, text);
|
||||
leptos::document()
|
||||
.query_selector(&selector)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn counter_html_element(index: u32, text: &str) -> HtmlElement {
|
||||
counter_element(index, text)
|
||||
.dyn_into::<HtmlElement>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn counter_input_element(index: u32, text: &str) -> HtmlInputElement {
|
||||
counter_element(index, text)
|
||||
.dyn_into::<HtmlInputElement>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn data_test_id(id: &str) -> String {
|
||||
let selector = format!("[data-testid=\"{}\"]", id);
|
||||
leptos::document()
|
||||
.query_selector(&selector)
|
||||
.unwrap()
|
||||
.expect("counters not found")
|
||||
.text_content()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn find_by_text(text: &str) -> HtmlElement {
|
||||
let xpath = format!("//*[text()='{}']", text);
|
||||
let document = leptos::document();
|
||||
document
|
||||
.evaluate(&xpath, &document)
|
||||
.unwrap()
|
||||
.iterate_next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.dyn_into::<HtmlElement>()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn remove_existing_counters() {
|
||||
if let Some(counter) =
|
||||
leptos::document().query_selector("body div").unwrap()
|
||||
{
|
||||
counter.remove();
|
||||
}
|
||||
}
|
||||
1
examples/counters_stable/tests/web/fixtures/mod.rs
Normal file
1
examples/counters_stable/tests/web/fixtures/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod counters_page;
|
||||
18
examples/counters_stable/tests/web/increment_counter.rs
Normal file
18
examples/counters_stable/tests/web/increment_counter.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
16
examples/counters_stable/tests/web/main.rs
Normal file
16
examples/counters_stable/tests/web/main.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
// Test Suites
|
||||
pub mod add_1k_counters;
|
||||
pub mod add_counter;
|
||||
pub mod clear_counters;
|
||||
pub mod decrement_counter;
|
||||
pub mod enter_count;
|
||||
pub mod increment_counter;
|
||||
pub mod remove_counter;
|
||||
pub mod view_counters;
|
||||
|
||||
pub mod fixtures;
|
||||
pub use fixtures::*;
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
18
examples/counters_stable/tests/web/remove_counter.rs
Normal file
18
examples/counters_stable/tests/web/remove_counter.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use super::*;
|
||||
use crate::counters_page as ui;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn should_decrement_the_number_of_counters() {
|
||||
// Given
|
||||
ui::view_counters();
|
||||
ui::add_counter();
|
||||
ui::add_counter();
|
||||
ui::add_counter();
|
||||
|
||||
// When
|
||||
ui::remove_counter(2);
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::counters(), 2);
|
||||
}
|
||||
22
examples/counters_stable/tests/web/view_counters.rs
Normal file
22
examples/counters_stable/tests/web/view_counters.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use super::*;
|
||||
use crate::counters_page as ui;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn should_see_the_initial_counts() {
|
||||
// When
|
||||
ui::view_counters();
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::total(), 0);
|
||||
assert_eq!(ui::counters(), 0);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn should_see_the_title() {
|
||||
// When
|
||||
ui::view_counters();
|
||||
|
||||
// Then
|
||||
assert_eq!(ui::title(), "Counters (Stable)");
|
||||
}
|
||||
@@ -11,7 +11,7 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
<h1>"Error Handling"</h1>
|
||||
<label>
|
||||
"Type a number (or something that's not a number!)"
|
||||
<input type="number" on:input=on_input/>
|
||||
<input on:input=on_input/>
|
||||
// If an `Err(_) had been rendered inside the <ErrorBoundary/>,
|
||||
// the fallback will be displayed. Otherwise, the children of the
|
||||
// <ErrorBoundary/> will be displayed.
|
||||
|
||||
@@ -14,10 +14,6 @@ cfg_if! {
|
||||
async fn css() -> impl Responder {
|
||||
actix_files::NamedFile::open_async("./style.css").await
|
||||
}
|
||||
#[get("/favicon.ico")]
|
||||
async fn favicon() -> impl Responder {
|
||||
actix_files::NamedFile::open_async("./target/site//favicon.ico").await
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
@@ -33,17 +29,34 @@ cfg_if! {
|
||||
let site_root = &leptos_options.site_root;
|
||||
|
||||
App::new()
|
||||
.service(css)
|
||||
.service(favicon)
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), |cx| view! { cx, <App/> })
|
||||
.service(Files::new("/", site_root))
|
||||
.service(Files::new("/pkg", format!("{site_root}/pkg")))
|
||||
.service(Files::new("/assets", site_root))
|
||||
.service(favicon)
|
||||
.service(css)
|
||||
.leptos_routes(
|
||||
leptos_options.to_owned(),
|
||||
routes.to_owned(),
|
||||
|cx| view! { cx, <App/> },
|
||||
)
|
||||
.app_data(web::Data::new(leptos_options.to_owned()))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[actix_web::get("favicon.ico")]
|
||||
async fn favicon(
|
||||
leptos_options: actix_web::web::Data<leptos::LeptosOptions>,
|
||||
) -> actix_web::Result<actix_files::NamedFile> {
|
||||
let leptos_options = leptos_options.into_inner();
|
||||
let site_root = &leptos_options.site_root;
|
||||
Ok(actix_files::NamedFile::open(format!(
|
||||
"{site_root}/favicon.ico"
|
||||
))?)
|
||||
}
|
||||
} else {
|
||||
fn main() {
|
||||
use hackernews::{App};
|
||||
|
||||
21
examples/router/.gitignore
vendored
Normal file
21
examples/router/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# Support playwright testing
|
||||
node_modules/
|
||||
test-results/
|
||||
end2end/playwright-report/
|
||||
playwright/.cache/
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Support trunk
|
||||
dist
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
extend = { path = "../cargo-make/main.toml" }
|
||||
extend = [
|
||||
{ path = "../cargo-make/main.toml" },
|
||||
{ path = "../cargo-make/trunk_server.toml" },
|
||||
{ path = "../cargo-make/playwright-test.toml" },
|
||||
]
|
||||
|
||||
[tasks.build]
|
||||
command = "cargo"
|
||||
|
||||
36
examples/router/e2e/package-lock.json
generated
Normal file
36
examples/router/e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "e2e",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@playwright/test": {
|
||||
"version": "1.35.1",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"fsevents": "2.3.2",
|
||||
"playwright-core": "1.35.1"
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "20.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz",
|
||||
"integrity": "sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw==",
|
||||
"dev": true
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"playwright-core": {
|
||||
"version": "1.35.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.1.tgz",
|
||||
"integrity": "sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
7
examples/router/e2e/package.json
Normal file
7
examples/router/e2e/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"private": "true",
|
||||
"scripts": {},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.35.1"
|
||||
}
|
||||
}
|
||||
77
examples/router/e2e/playwright.config.ts
Normal file
77
examples/router/e2e/playwright.config.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* 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 ? 10 : 10,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "list",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1:8080",
|
||||
|
||||
/* 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: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: "cd ../ && trunk serve",
|
||||
// url: "http://127.0.0.1:8080",
|
||||
// reuseExistingServer: false, //!process.env.CI,
|
||||
// },
|
||||
});
|
||||
30
examples/router/e2e/tests/router.spec.ts
Normal file
30
examples/router/e2e/tests/router.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Test Router example", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
const links = [
|
||||
{ label: "Bill Smith", url: "/0" },
|
||||
{ label: "Tim Jones", url: "/1" },
|
||||
{ label: "Sally Stevens", url: "/2" },
|
||||
{ label: "About", url: "/about" },
|
||||
{ label: "Settings", url: "/settings" },
|
||||
];
|
||||
links.forEach(({ label, url }) => {
|
||||
test(`Can navigate to ${label}`, async ({ page }) => {
|
||||
await page.getByRole("link", { name: label }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: label })).toBeVisible();
|
||||
await expect(page).toHaveURL(url);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can redirect to home", async ({ page }) => {
|
||||
await page.getByRole("link", { name: "About" }).click();
|
||||
|
||||
await page.getByRole("link", { name: "Redirect to Home" }).click();
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
});
|
||||
11
examples/router/package.json
Normal file
11
examples/router/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-server": "trunk serve",
|
||||
"e2e": "cargo make test-playwright",
|
||||
"e2e:auto-start": "start-server-and-test start-server http://127.0.0.1:8080 e2e"
|
||||
},
|
||||
"devDependencies": {
|
||||
"start-server-and-test": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ pub fn App(cx: Scope) -> impl IntoView {
|
||||
view=Post
|
||||
ssr=SsrMode::Async
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/*any"
|
||||
view=NotFound
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
@@ -182,3 +187,14 @@ pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
Ok(POSTS.iter().find(|post| post.id == id).cloned())
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NotFound(cx: Scope) -> impl IntoView {
|
||||
#[cfg(feature = "ssr")]
|
||||
{
|
||||
let resp = expect_context::<leptos_actix::ResponseOptions>(cx);
|
||||
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
view! { cx, <h1>"Not Found"</h1> }
|
||||
}
|
||||
|
||||
@@ -24,12 +24,11 @@ async fn main() -> std::io::Result<()> {
|
||||
|
||||
App::new()
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(
|
||||
leptos_options.to_owned(),
|
||||
routes.to_owned(),
|
||||
|cx| view! { cx, <App/> },
|
||||
)
|
||||
.service(Files::new("/", site_root))
|
||||
.service(Files::new("/pkg", format!("{site_root}/pkg")))
|
||||
.service(Files::new("/assets", site_root))
|
||||
.service(favicon)
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
|
||||
.app_data(web::Data::new(leptos_options.to_owned()))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
@@ -37,6 +36,18 @@ async fn main() -> std::io::Result<()> {
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[actix_web::get("favicon.ico")]
|
||||
async fn favicon(
|
||||
leptos_options: actix_web::web::Data<leptos::LeptosOptions>,
|
||||
) -> actix_web::Result<actix_files::NamedFile> {
|
||||
let leptos_options = leptos_options.into_inner();
|
||||
let site_root = &leptos_options.site_root;
|
||||
Ok(actix_files::NamedFile::open(format!(
|
||||
"{site_root}/favicon.ico"
|
||||
))?)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
pub fn main() {
|
||||
// no client-side main function
|
||||
|
||||
@@ -30,6 +30,25 @@ npm install -D tailwindcss
|
||||
|
||||
If you'd rather not use `npm`, you can install the Tailwind binary [here](https://github.com/tailwindlabs/tailwindcss/releases).
|
||||
|
||||
## Adding Tailwind plugins
|
||||
|
||||
If you'd like to add [Tailwind plugins](https://tailwindcss.com/docs/plugins), such as [DaisyUI](https://daisyui.com/), you can do the following:
|
||||
|
||||
`npm install -D daisyui@latest`
|
||||
|
||||
Then add the plugin to your exports in `tailwind.config.js` :
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
//...
|
||||
plugins: [require("daisyui")],
|
||||
};
|
||||
```
|
||||
|
||||
And re-run the following to generate the css:
|
||||
|
||||
`npx tailwindcss -i ./input.css -o ./style/output.css --watch`
|
||||
|
||||
## Setting up with VS Code and Additional Tools
|
||||
|
||||
If you're using VS Code, add the following to your `settings.json`
|
||||
|
||||
@@ -52,8 +52,9 @@ If you're using VS Code, add the following to your `settings.json`
|
||||
|
||||
Install [Tailwind CSS Intellisense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss).
|
||||
|
||||
Install "VS Browser" extension, a browser at the right window.
|
||||
Allow vscode Ports forward: 3000, 3001.
|
||||
Install [VS Browser](https://marketplace.visualstudio.com/items?itemName=Phu1237.vs-browser) extension (allows you to open a browser at the right window.
|
||||
|
||||
Allow vscode Ports forward: 3000, 3001.
|
||||
|
||||
## Notes about Tooling
|
||||
|
||||
|
||||
@@ -47,14 +47,32 @@ cfg_if! {
|
||||
App::new()
|
||||
.service(css)
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), |cx| view! { cx, <TodoApp/> })
|
||||
.service(Files::new("/", site_root))
|
||||
.service(Files::new("/pkg", format!("{site_root}/pkg")))
|
||||
.service(Files::new("/assets", site_root))
|
||||
.service(favicon)
|
||||
.leptos_routes(
|
||||
leptos_options.to_owned(),
|
||||
routes.to_owned(),
|
||||
TodoApp,
|
||||
)
|
||||
.app_data(web::Data::new(leptos_options.to_owned()))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(addr)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[actix_web::get("favicon.ico")]
|
||||
async fn favicon(
|
||||
leptos_options: actix_web::web::Data<leptos::LeptosOptions>,
|
||||
) -> actix_web::Result<actix_files::NamedFile> {
|
||||
let leptos_options = leptos_options.into_inner();
|
||||
let site_root = &leptos_options.site_root;
|
||||
Ok(actix_files::NamedFile::open(format!(
|
||||
"{site_root}/favicon.ico"
|
||||
))?)
|
||||
}
|
||||
} else {
|
||||
fn main() {
|
||||
// no client-side main function
|
||||
|
||||
@@ -92,10 +92,8 @@ pub fn TodoApp(cx: Scope) -> impl IntoView {
|
||||
</header>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="" view=|cx| view! {
|
||||
cx,
|
||||
<Todos/>
|
||||
}/>
|
||||
<Route path="" view=Todos/>
|
||||
<Route path="/*any" view=NotFound/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
@@ -200,3 +198,14 @@ pub fn Todos(cx: Scope) -> impl IntoView {
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NotFound(cx: Scope) -> impl IntoView {
|
||||
#[cfg(feature = "ssr")]
|
||||
{
|
||||
let resp = expect_context::<leptos_actix::ResponseOptions>(cx);
|
||||
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
view! { cx, <h1>"Not Found"</h1> }
|
||||
}
|
||||
|
||||
26
flake.lock
generated
26
flake.lock
generated
@@ -38,11 +38,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1681920287,
|
||||
"narHash": "sha256-+/d6XQQfhhXVfqfLROJoqj3TuG38CAeoT6jO1g9r1k0=",
|
||||
"lastModified": 1687898314,
|
||||
"narHash": "sha256-B4BHon3uMXQw8ZdbwxRK1BmxVOGBV4viipKpGaIlGwk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "645bc49f34fa8eff95479f0345ff57e55b53437e",
|
||||
"rev": "e18dc963075ed115afb3e312b64643bf8fd4b474",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -52,22 +52,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681358109,
|
||||
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
@@ -78,7 +62,9 @@
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1682043560,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
@@ -22,7 +23,8 @@
|
||||
openssl
|
||||
pkg-config
|
||||
cacert
|
||||
(rust-bin.selectLatestNightlyWith( toolchain: toolchain.default.override {
|
||||
mdbook
|
||||
(rust-bin.selectLatestNightlyWith(toolchain: toolchain.default.override {
|
||||
extensions= [ "rust-src" "rust-analyzer" ];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
}))
|
||||
|
||||
@@ -223,26 +223,30 @@ pub fn handle_server_fns_with_context(
|
||||
let res_options =
|
||||
use_context::<ResponseOptions>(cx).unwrap();
|
||||
|
||||
let mut res: HttpResponseBuilder;
|
||||
let mut res: HttpResponseBuilder =
|
||||
HttpResponse::Ok();
|
||||
let res_parts = res_options.0.write();
|
||||
|
||||
if accept_header == Some("application/json")
|
||||
|| accept_header
|
||||
== Some("application/x-www-form-urlencoded")
|
||||
|| accept_header == Some("application/cbor")
|
||||
// if accept_header isn't set to one of these, it's a form submit
|
||||
// redirect back to the referrer if not redirect has been set
|
||||
if accept_header != Some("application/json")
|
||||
&& accept_header
|
||||
!= Some("application/x-www-form-urlencoded")
|
||||
&& accept_header != Some("application/cbor")
|
||||
{
|
||||
res = HttpResponse::Ok();
|
||||
}
|
||||
// otherwise, it's probably a <form> submit or something: redirect back to the referrer
|
||||
else {
|
||||
let referer = req
|
||||
.headers()
|
||||
.get("Referer")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("/");
|
||||
res = HttpResponse::SeeOther();
|
||||
res.insert_header(("Location", referer))
|
||||
.content_type("application/json");
|
||||
// Location will already be set if redirect() has been used
|
||||
let has_location_set =
|
||||
res_parts.headers.get("Location").is_some();
|
||||
if !has_location_set {
|
||||
let referer = req
|
||||
.headers()
|
||||
.get("Referer")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("/");
|
||||
res = HttpResponse::SeeOther();
|
||||
res.insert_header(("Location", referer))
|
||||
.content_type("application/json");
|
||||
}
|
||||
};
|
||||
// Override StatusCode if it was set in a Resource or Element
|
||||
if let Some(status) = res_parts.status {
|
||||
|
||||
@@ -1276,18 +1276,24 @@ impl ExtractorHelper {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extract<F, T, U>(&self, f: F) -> Result<U, T::Rejection>
|
||||
pub async fn extract<F, T, U, S>(
|
||||
&self,
|
||||
f: F,
|
||||
s: S,
|
||||
) -> Result<U, T::Rejection>
|
||||
where
|
||||
F: Extractor<T, U>,
|
||||
T: std::fmt::Debug + Send + FromRequestParts<()> + 'static,
|
||||
S: Sized,
|
||||
F: Extractor<T, U, S>,
|
||||
T: std::fmt::Debug + Send + FromRequestParts<S> + 'static,
|
||||
T::Rejection: std::fmt::Debug + Send + 'static,
|
||||
{
|
||||
let mut parts = self.parts.lock().await;
|
||||
let data = T::from_request_parts(&mut parts, &()).await?;
|
||||
let data = T::from_request_parts(&mut parts, &s).await?;
|
||||
Ok(f.call(data).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// Getting ExtractorHelper from a request will return an ExtractorHelper whose state is ().
|
||||
impl<B> From<Request<B>> for ExtractorHelper {
|
||||
fn from(req: Request<B>) -> Self {
|
||||
// TODO provide body for extractors there, too?
|
||||
@@ -1315,37 +1321,80 @@ impl<B> From<Request<B>> for ExtractorHelper {
|
||||
/// .map_err(|e| ServerFnError::ServerError("Could not extract method and query...".to_string()))
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// > Note: For now, the Axum `extract` function only supports extractors for
|
||||
/// which the state is `()`, i.e., you can't yet use it to extract `State(_)`.
|
||||
/// You can access `State(_)` by using a custom handler that extracts the state
|
||||
/// and then provides it via context.
|
||||
/// [Click here for an example](https://github.com/leptos-rs/leptos/blob/a5f73b441c079f9138102b3a7d8d4828f045448c/examples/session_auth_axum/src/main.rs#L91-L92).
|
||||
#[tracing::instrument(level = "trace", fields(error), skip_all)]
|
||||
pub async fn extract<T, U>(
|
||||
cx: Scope,
|
||||
f: impl Extractor<T, U>,
|
||||
f: impl Extractor<T, U, ()>,
|
||||
) -> Result<U, T::Rejection>
|
||||
where
|
||||
T: std::fmt::Debug + Send + FromRequestParts<()> + 'static,
|
||||
T::Rejection: std::fmt::Debug + Send + 'static,
|
||||
{
|
||||
extract_with_state(cx, (), f).await
|
||||
}
|
||||
|
||||
/// A helper to make it easier to use Axum extractors in server functions. This takes
|
||||
/// a handler function and state as its arguments. The handler rules similar to Axum
|
||||
/// [handlers](https://docs.rs/axum/latest/axum/extract/index.html#intro): it is an async function
|
||||
/// whose arguments are “extractors.”
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// #[server(QueryExtract, "/api")]
|
||||
/// pub async fn query_extract(cx: Scope) -> Result<String, ServerFnError> {
|
||||
/// use axum::{extract::Query, http::Method};
|
||||
/// use leptos_axum::extract;
|
||||
/// let state: ServerState = use_context::<crate::ServerState>(cx)
|
||||
/// .ok_or(ServerFnError::ServerError("No server state".to_string()))?;
|
||||
///
|
||||
/// extract_with_state(cx, state, |method: Method, res: Query<MyQuery>| async move {
|
||||
/// format!("{method:?} and {}", res.q)
|
||||
/// },
|
||||
/// )
|
||||
/// .await
|
||||
/// .map_err(|e| ServerFnError::ServerError("Could not extract method and query...".to_string()))
|
||||
/// }
|
||||
/// ```
|
||||
#[tracing::instrument(level = "trace", fields(error), skip_all)]
|
||||
pub async fn extract_with_state<T, U, S>(
|
||||
cx: Scope,
|
||||
state: S,
|
||||
f: impl Extractor<T, U, S>,
|
||||
) -> Result<U, T::Rejection>
|
||||
where
|
||||
S: Sized,
|
||||
T: std::fmt::Debug + Send + FromRequestParts<S> + 'static,
|
||||
T::Rejection: std::fmt::Debug + Send + 'static,
|
||||
{
|
||||
use_context::<ExtractorHelper>(cx)
|
||||
.expect(
|
||||
"should have had ExtractorHelper provided by the leptos_axum \
|
||||
integration",
|
||||
)
|
||||
.extract(f)
|
||||
.extract(f, state)
|
||||
.await
|
||||
}
|
||||
|
||||
pub trait Extractor<T, U>
|
||||
pub trait Extractor<T, U, S>
|
||||
where
|
||||
T: FromRequestParts<()>,
|
||||
S: Sized,
|
||||
T: FromRequestParts<S>,
|
||||
{
|
||||
fn call(self, args: T) -> Pin<Box<dyn Future<Output = U>>>;
|
||||
}
|
||||
|
||||
macro_rules! factory_tuple ({ $($param:ident)* } => {
|
||||
impl<Func, Fut, U, $($param,)*> Extractor<($($param,)*), U> for Func
|
||||
impl<Func, Fut, U, S, $($param,)*> Extractor<($($param,)*), U, S> for Func
|
||||
where
|
||||
$($param: FromRequestParts<()> + Send,)*
|
||||
$($param: FromRequestParts<S> + Send,)*
|
||||
Func: FnOnce($($param),*) -> Fut + 'static,
|
||||
Fut: Future<Output = U> + 'static,
|
||||
S: Sized + Send + Sync,
|
||||
{
|
||||
#[inline]
|
||||
#[allow(non_snake_case)]
|
||||
|
||||
@@ -87,10 +87,10 @@ pub fn html_parts_separated(
|
||||
let output_name = &options.output_name;
|
||||
|
||||
// Because wasm-pack adds _bg to the end of the WASM filename, and we want to mantain compatibility with it's default options
|
||||
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME
|
||||
// Otherwise we need to add _bg because wasm_pack always does. This is not the same as options.output_name, which is set regardless
|
||||
// we add _bg to the wasm files if cargo-leptos doesn't set the env var LEPTOS_OUTPUT_NAME at compile time
|
||||
// Otherwise we need to add _bg because wasm_pack always does.
|
||||
let mut wasm_output_name = output_name.clone();
|
||||
if std::env::var("LEPTOS_OUTPUT_NAME").is_err() {
|
||||
if std::option_env!("LEPTOS_OUTPUT_NAME").is_none() {
|
||||
wasm_output_name.push_str("_bg");
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ where
|
||||
|
||||
let child = DynChild::new({
|
||||
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
|
||||
let current_id = current_id.clone();
|
||||
let current_id = current_id;
|
||||
|
||||
let children = Rc::new(orig_children(cx).into_view(cx));
|
||||
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
|
||||
@@ -94,10 +94,10 @@ where
|
||||
// run the child; we'll probably throw this away, but it will register resource reads
|
||||
//let after_original_child = HydrationCtx::peek();
|
||||
|
||||
let initial = {
|
||||
{
|
||||
// no resources were read under this, so just return the child
|
||||
if context.pending_resources.get() == 0 {
|
||||
HydrationCtx::continue_from(current_id.clone());
|
||||
HydrationCtx::continue_from(current_id);
|
||||
DynChild::new({
|
||||
let children = Rc::clone(&children);
|
||||
move || (*children).clone()
|
||||
@@ -115,9 +115,7 @@ where
|
||||
{
|
||||
let orig_children = Rc::clone(&orig_children);
|
||||
move || {
|
||||
HydrationCtx::continue_from(
|
||||
current_id.clone(),
|
||||
);
|
||||
HydrationCtx::continue_from(current_id);
|
||||
DynChild::new({
|
||||
let orig_children =
|
||||
orig_children(cx).into_view(cx);
|
||||
@@ -132,9 +130,7 @@ where
|
||||
{
|
||||
let orig_children = Rc::clone(&orig_children);
|
||||
move || {
|
||||
HydrationCtx::continue_from(
|
||||
current_id.clone(),
|
||||
);
|
||||
HydrationCtx::continue_from(current_id);
|
||||
DynChild::new({
|
||||
let orig_children =
|
||||
orig_children(cx).into_view(cx);
|
||||
@@ -149,9 +145,7 @@ where
|
||||
// return the fallback for now, wrapped in fragment identifier
|
||||
fallback().into_view(cx)
|
||||
}
|
||||
};
|
||||
|
||||
initial
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -289,4 +289,4 @@ fn None(cx: Scope) -> impl IntoView {
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use typed_builder::TypedBuilder;
|
||||
/// A Struct to allow us to parse LeptosOptions from the file. Not really needed, most interactions should
|
||||
/// occur with LeptosOptions
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct ConfFile {
|
||||
pub leptos_options: LeptosOptions,
|
||||
}
|
||||
@@ -23,31 +24,37 @@ pub struct ConfFile {
|
||||
/// correct path for WASM, JS, and Websockets, as well as other configuration tasks.
|
||||
/// It shares keys with cargo-leptos, to allow for easy interoperability
|
||||
#[derive(TypedBuilder, Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct LeptosOptions {
|
||||
/// The name of the WASM and JS files generated by wasm-bindgen. Defaults to the crate name with underscores instead of dashes
|
||||
#[builder(setter(into))]
|
||||
pub output_name: String,
|
||||
/// The path of the all the files generated by cargo-leptos. This defaults to '.' for convenience when integrating with other
|
||||
/// tools.
|
||||
#[builder(setter(into), default=".".to_string())]
|
||||
#[builder(setter(into), default=default_site_root())]
|
||||
#[serde(default = "default_site_root")]
|
||||
pub site_root: String,
|
||||
/// The path of the WASM and JS files generated by wasm-bindgen from the root of your app
|
||||
/// By default, wasm-bindgen puts them in `pkg`.
|
||||
#[builder(setter(into), default="pkg".to_string())]
|
||||
#[builder(setter(into), default=default_site_pkg_dir())]
|
||||
#[serde(default = "default_site_pkg_dir")]
|
||||
pub site_pkg_dir: String,
|
||||
/// Used to configure the running environment of Leptos. Can be used to load dev constants and keys v prod, or change
|
||||
/// things based on the deployment environment
|
||||
/// I recommend passing in the result of `env::var("LEPTOS_ENV")`
|
||||
#[builder(setter(into), default=Env::DEV)]
|
||||
#[builder(setter(into), default=default_env())]
|
||||
#[serde(default = "default_env")]
|
||||
pub env: Env,
|
||||
/// Provides a way to control the address leptos is served from.
|
||||
/// Using an env variable here would allow you to run the same code in dev and prod
|
||||
/// Defaults to `127.0.0.1:3000`
|
||||
#[builder(setter(into), default=SocketAddr::from(([127,0,0,1], 3000)))]
|
||||
#[builder(setter(into), default=default_site_addr())]
|
||||
#[serde(default = "default_site_addr")]
|
||||
pub site_addr: SocketAddr,
|
||||
/// The port the Websocket watcher listens on. Should match the `reload_port` in cargo-leptos(if using).
|
||||
/// Defaults to `3001`
|
||||
#[builder(default = 3001)]
|
||||
#[builder(default = default_reload_port())]
|
||||
#[serde(default = "default_reload_port")]
|
||||
pub reload_port: u32,
|
||||
}
|
||||
|
||||
@@ -81,6 +88,26 @@ impl LeptosOptions {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_site_root() -> String {
|
||||
".".to_string()
|
||||
}
|
||||
|
||||
fn default_site_pkg_dir() -> String {
|
||||
"pkg".to_string()
|
||||
}
|
||||
|
||||
fn default_env() -> Env {
|
||||
Env::DEV
|
||||
}
|
||||
|
||||
fn default_site_addr() -> SocketAddr {
|
||||
SocketAddr::from(([127, 0, 0, 1], 3000))
|
||||
}
|
||||
|
||||
fn default_reload_port() -> u32 {
|
||||
3001
|
||||
}
|
||||
|
||||
fn env_w_default(
|
||||
key: &str,
|
||||
default: &str,
|
||||
@@ -95,7 +122,7 @@ fn env_w_default(
|
||||
/// An enum that can be used to define the environment Leptos is running in.
|
||||
/// Setting this to the `PROD` variant will not include the WebSocket code for `cargo-leptos` watch mode.
|
||||
/// Defaults to `DEV`.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
pub enum Env {
|
||||
PROD,
|
||||
DEV,
|
||||
@@ -153,17 +180,29 @@ impl TryFrom<String> for Env {
|
||||
/// If an env var is specified, like `LEPTOS_ENV`, it will override a setting in the file.
|
||||
pub fn get_config_from_str(text: &str) -> Result<ConfFile, LeptosConfigError> {
|
||||
let re: Regex = Regex::new(r#"(?m)^\[package.metadata.leptos\]"#).unwrap();
|
||||
let start = match re.find(text) {
|
||||
Some(found) => found.start(),
|
||||
None => return Err(LeptosConfigError::ConfigSectionNotFound),
|
||||
let re_workspace: Regex =
|
||||
Regex::new(r#"(?m)^\[\[workspace.metadata.leptos\]\]"#).unwrap();
|
||||
|
||||
let metadata_name;
|
||||
let start;
|
||||
match re.find(text) {
|
||||
Some(found) => {
|
||||
metadata_name = "[package.metadata.leptos]";
|
||||
start = found.start();
|
||||
}
|
||||
None => match re_workspace.find(text) {
|
||||
Some(found) => {
|
||||
metadata_name = "[[workspace.metadata.leptos]]";
|
||||
start = found.start();
|
||||
}
|
||||
None => return Err(LeptosConfigError::ConfigSectionNotFound),
|
||||
},
|
||||
};
|
||||
|
||||
// so that serde error messages have right line number
|
||||
let newlines = text[..start].matches('\n').count();
|
||||
let input = "\n".repeat(newlines) + &text[start..];
|
||||
let toml = input
|
||||
.replace("[package.metadata.leptos]", "[leptos_options]")
|
||||
.replace('-', "_");
|
||||
let toml = input.replace(metadata_name, "[leptos-options]");
|
||||
let settings = Config::builder()
|
||||
// Read the "default" configuration file
|
||||
.add_source(File::from_str(&toml, FileFormat::Toml))
|
||||
|
||||
@@ -46,7 +46,7 @@ async fn get_configuration_from_file_ok() {
|
||||
.unwrap()
|
||||
.leptos_options;
|
||||
|
||||
assert_eq!(config.output_name, "app_test");
|
||||
assert_eq!(config.output_name, "app-test");
|
||||
assert_eq!(config.site_root, "my_target/site");
|
||||
assert_eq!(config.site_pkg_dir, "my_pkg");
|
||||
assert_eq!(
|
||||
@@ -93,7 +93,7 @@ async fn get_config_from_file_ok() {
|
||||
.unwrap()
|
||||
.leptos_options;
|
||||
|
||||
assert_eq!(config.output_name, "app_test");
|
||||
assert_eq!(config.output_name, "app-test");
|
||||
assert_eq!(config.site_root, "my_target/site");
|
||||
assert_eq!(config.site_pkg_dir, "my_pkg");
|
||||
assert_eq!(
|
||||
@@ -128,7 +128,7 @@ fn get_config_from_str_content() {
|
||||
let config = get_config_from_str(CARGO_TOML_CONTENT_OK)
|
||||
.unwrap()
|
||||
.leptos_options;
|
||||
assert_eq!(config.output_name, "app_test");
|
||||
assert_eq!(config.output_name, "app-test");
|
||||
assert_eq!(config.site_root, "my_target/site");
|
||||
assert_eq!(config.site_pkg_dir, "my_pkg");
|
||||
assert_eq!(
|
||||
@@ -141,14 +141,14 @@ fn get_config_from_str_content() {
|
||||
#[tokio::test]
|
||||
async fn get_config_from_env() {
|
||||
// Test config values from environment variables
|
||||
std::env::set_var("LEPTOS_OUTPUT_NAME", "app_test");
|
||||
std::env::set_var("LEPTOS_OUTPUT_NAME", "app-test");
|
||||
std::env::set_var("LEPTOS_SITE_ROOT", "my_target/site");
|
||||
std::env::set_var("LEPTOS_SITE_PKG_DIR", "my_pkg");
|
||||
std::env::set_var("LEPTOS_SITE_ADDR", "0.0.0.0:80");
|
||||
std::env::set_var("LEPTOS_RELOAD_PORT", "8080");
|
||||
|
||||
let config = get_configuration(None).await.unwrap().leptos_options;
|
||||
assert_eq!(config.output_name, "app_test");
|
||||
assert_eq!(config.output_name, "app-test");
|
||||
|
||||
assert_eq!(config.site_root, "my_target/site");
|
||||
assert_eq!(config.site_pkg_dir, "my_pkg");
|
||||
@@ -176,8 +176,8 @@ async fn get_config_from_env() {
|
||||
|
||||
#[test]
|
||||
fn leptos_options_builder_default() {
|
||||
let conf = LeptosOptions::builder().output_name("app_test").build();
|
||||
assert_eq!(conf.output_name, "app_test");
|
||||
let conf = LeptosOptions::builder().output_name("app-test").build();
|
||||
assert_eq!(conf.output_name, "app-test");
|
||||
assert!(matches!(conf.env, Env::DEV));
|
||||
assert_eq!(conf.site_pkg_dir, "pkg");
|
||||
assert_eq!(conf.site_root, ".");
|
||||
|
||||
@@ -14,7 +14,7 @@ drain_filter_polyfill = "0.1"
|
||||
educe = "0.4"
|
||||
futures = "0.3"
|
||||
html-escape = "0.2"
|
||||
indexmap = "1.9"
|
||||
indexmap = "2"
|
||||
itertools = "0.10"
|
||||
js-sys = "0.3"
|
||||
leptos_reactive = { workspace = true }
|
||||
@@ -49,6 +49,7 @@ features = [
|
||||
"Range",
|
||||
"Text",
|
||||
"HtmlCollection",
|
||||
"ShadowRoot",
|
||||
"TreeWalker",
|
||||
|
||||
# Events we cast to in leptos_macro -- added here so we don't force users to import them
|
||||
|
||||
@@ -55,6 +55,8 @@ fn view_fn(cx: Scope) -> impl IntoView {
|
||||
<hr/>
|
||||
<Test from=[1, 4, 3, 2, 5] to=[1, 2, 3, 4, 5]/>
|
||||
<Test from=[4, 5, 3, 1, 2] to=[1, 2, 3, 4, 5]/>
|
||||
<Test from=[0, 1, 2, 3] to=[1, 3]/> // issue #1274
|
||||
<Test from=[] to=[3, 9, 17] then=vec![3, 5, 7, 9, 17, 23]/> // issue #1297
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,8 +271,9 @@ where
|
||||
let mut repr = ComponentRepr::new_with_id(name, id);
|
||||
|
||||
// disposed automatically when the parent scope is disposed
|
||||
let (child, _) = cx
|
||||
.run_child_scope(|cx| cx.untrack(|| children_fn(cx).into_view(cx)));
|
||||
let (child, _) = cx.run_child_scope(|cx| {
|
||||
cx.untrack_with_diagnostics(|| children_fn(cx).into_view(cx))
|
||||
});
|
||||
|
||||
repr.children.push(child);
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ mod web {
|
||||
};
|
||||
pub use drain_filter_polyfill::VecExt as VecDrainFilterExt;
|
||||
pub use leptos_reactive::create_effect;
|
||||
pub use std::cell::OnceCell;
|
||||
pub use once_cell::unsync::OnceCell;
|
||||
pub use wasm_bindgen::JsCast;
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
#[allow(dead_code)] // not used in SSR
|
||||
type FxIndexSet<T> =
|
||||
indexmap::IndexSet<T, std::hash::BuildHasherDefault<rustc_hash::FxHasher>>;
|
||||
|
||||
@@ -230,7 +230,12 @@ impl EachItem {
|
||||
fragment.append_with_node_1(&closing.node).unwrap();
|
||||
}
|
||||
|
||||
mount_child(MountKind::Before(&closing.node), &child);
|
||||
// if child view is Text and if we are hydrating, we do not
|
||||
// need to mount it. otherwise, mount it here
|
||||
if !HydrationCtx::is_hydrating() || !matches!(child, View::Text(_))
|
||||
{
|
||||
mount_child(MountKind::Before(&closing.node), &child);
|
||||
}
|
||||
|
||||
Some(fragment)
|
||||
} else {
|
||||
@@ -494,8 +499,8 @@ where
|
||||
#[educe(Debug)]
|
||||
struct HashRun<T>(#[educe(Debug(ignore))] T);
|
||||
|
||||
/// Calculates the operations need to get from `a` to `b`.
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
/// Calculates the operations needed to get from `from` to `to`.
|
||||
#[allow(dead_code)] // not used in SSR but useful to have available for testing
|
||||
fn diff<K: Eq + Hash>(from: &FxIndexSet<K>, to: &FxIndexSet<K>) -> Diff {
|
||||
if from.is_empty() && to.is_empty() {
|
||||
return Diff::default();
|
||||
@@ -518,207 +523,90 @@ fn diff<K: Eq + Hash>(from: &FxIndexSet<K>, to: &FxIndexSet<K>) -> Diff {
|
||||
};
|
||||
}
|
||||
|
||||
// Get removed items
|
||||
let removed = from.difference(to);
|
||||
let mut removed = vec![];
|
||||
let mut moved = vec![];
|
||||
let mut added = vec![];
|
||||
let max_len = std::cmp::max(from.len(), to.len());
|
||||
|
||||
let remove_cmds = removed
|
||||
.clone()
|
||||
.map(|k| from.get_full(k).unwrap().0)
|
||||
.map(|idx| DiffOpRemove { at: idx });
|
||||
for index in 0..max_len {
|
||||
let from_item = from.get_index(index);
|
||||
let to_item = to.get_index(index);
|
||||
|
||||
// Get added items
|
||||
let added = to.difference(from);
|
||||
// if they're the same, do nothing
|
||||
if from_item != to_item {
|
||||
// if it's only in old, not new, remove it
|
||||
if from_item.is_some() && !to.contains(from_item.unwrap()) {
|
||||
let op = DiffOpRemove { at: index };
|
||||
removed.push(op);
|
||||
}
|
||||
// if it's only in new, not old, add it
|
||||
if to_item.is_some() && !from.contains(to_item.unwrap()) {
|
||||
let op = DiffOpAdd {
|
||||
at: index,
|
||||
mode: DiffOpAddMode::Normal,
|
||||
};
|
||||
added.push(op);
|
||||
}
|
||||
// if it's in both old and new, it can either
|
||||
// 1) be moved (and need to move in the DOM)
|
||||
// 2) be moved (but not need to move in the DOM)
|
||||
// * this would happen if, for example, 2 items
|
||||
// have been added before it, and it has moved by 2
|
||||
if let Some(from_item) = from_item {
|
||||
if let Some(to_item) = to.get_full(from_item) {
|
||||
let moves_forward_by = (to_item.0 as i32) - (index as i32);
|
||||
let move_in_dom = moves_forward_by
|
||||
!= (added.len() as i32) - (removed.len() as i32);
|
||||
|
||||
let add_cmds =
|
||||
added
|
||||
.clone()
|
||||
.map(|k| to.get_full(k).unwrap().0)
|
||||
.map(|idx| DiffOpAdd {
|
||||
at: idx,
|
||||
mode: Default::default(),
|
||||
});
|
||||
let op = DiffOpMove {
|
||||
from: index,
|
||||
len: 1,
|
||||
to: to_item.0,
|
||||
move_in_dom,
|
||||
};
|
||||
moved.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get items that might have moved
|
||||
let from_moved = from.intersection(&to).collect::<FxIndexSet<_>>();
|
||||
let to_moved = to.intersection(&from).collect::<FxIndexSet<_>>();
|
||||
moved = group_adjacent_moves(moved);
|
||||
|
||||
let move_cmds = find_ranges(from_moved, to_moved, from, to);
|
||||
|
||||
let mut diff = Diff {
|
||||
removed: remove_cmds.collect(),
|
||||
items_to_move: move_cmds.iter().map(|range| range.len).sum(),
|
||||
moved: move_cmds,
|
||||
added: add_cmds.collect(),
|
||||
Diff {
|
||||
removed,
|
||||
items_to_move: moved.iter().map(|m| m.len).sum(),
|
||||
moved,
|
||||
added,
|
||||
clear: false,
|
||||
};
|
||||
|
||||
apply_opts(from, to, &mut diff);
|
||||
|
||||
#[cfg(test)]
|
||||
{
|
||||
let mut adds_sorted = diff.added.clone();
|
||||
adds_sorted.sort_unstable_by_key(|add| add.at);
|
||||
|
||||
assert_eq!(diff.added, adds_sorted, "adds must be sorted");
|
||||
|
||||
let mut moves_sorted = diff.moved.clone();
|
||||
moves_sorted.sort_unstable_by_key(|move_| move_.to);
|
||||
|
||||
assert_eq!(diff.moved, moves_sorted, "moves must be sorted by `to`");
|
||||
}
|
||||
|
||||
diff
|
||||
}
|
||||
|
||||
/// Builds and returns the ranges of items that need to
|
||||
/// move sorted by `to`.
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
fn find_ranges<K: Eq + Hash>(
|
||||
from_moved: FxIndexSet<&K>,
|
||||
to_moved: FxIndexSet<&K>,
|
||||
from: &FxIndexSet<K>,
|
||||
to: &FxIndexSet<K>,
|
||||
) -> Vec<DiffOpMove> {
|
||||
let mut ranges = Vec::with_capacity(from.len());
|
||||
let mut prev_to_moved_index = 0;
|
||||
let mut range = DiffOpMove::default();
|
||||
|
||||
for (i, k) in from_moved.into_iter().enumerate() {
|
||||
let to_moved_index = to_moved.get_index_of(k).unwrap();
|
||||
|
||||
if i == 0 {
|
||||
range.from = from.get_index_of(k).unwrap();
|
||||
range.to = to.get_index_of(k).unwrap();
|
||||
}
|
||||
// The range continues
|
||||
else if to_moved_index == prev_to_moved_index + 1 {
|
||||
range.len += 1;
|
||||
}
|
||||
// We're done with this range, start a new one
|
||||
else {
|
||||
ranges.push(std::mem::take(&mut range));
|
||||
|
||||
range.from = from.get_index_of(k).unwrap();
|
||||
range.to = to.get_index_of(k).unwrap();
|
||||
}
|
||||
|
||||
prev_to_moved_index = to_moved_index;
|
||||
}
|
||||
|
||||
ranges.push(std::mem::take(&mut range));
|
||||
|
||||
// We need to remove ranges that didn't move relative to each other
|
||||
// as well as marking items that don't need to move in the DOM
|
||||
let mut to_ranges = ranges.clone();
|
||||
to_ranges.sort_unstable_by_key(|range| range.to);
|
||||
|
||||
let mut filtered_ranges = vec![];
|
||||
|
||||
let to_ranges_len = to_ranges.len();
|
||||
|
||||
for (i, range) in to_ranges.into_iter().enumerate() {
|
||||
if range != ranges[i] {
|
||||
filtered_ranges.push(range);
|
||||
}
|
||||
// The item did move, just not in the DOM
|
||||
else if range.from != range.to {
|
||||
filtered_ranges.push(DiffOpMove {
|
||||
move_in_dom: false,
|
||||
..range
|
||||
});
|
||||
} else if to_ranges_len > 2 {
|
||||
// TODO: Remove this else case...this is one of the biggest
|
||||
// optimizations we can do, but we're skipping this right now
|
||||
// until we figure out a way to handle moving around ranges
|
||||
// that did not move
|
||||
filtered_ranges.push(range);
|
||||
}
|
||||
}
|
||||
|
||||
filtered_ranges
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
fn apply_opts<K: Eq + Hash>(
|
||||
from: &FxIndexSet<K>,
|
||||
to: &FxIndexSet<K>,
|
||||
cmds: &mut Diff,
|
||||
) {
|
||||
optimize_moves(&mut cmds.moved);
|
||||
|
||||
// We can optimize the case of replacing all items
|
||||
if !from.is_empty()
|
||||
&& !to.is_empty()
|
||||
&& cmds.removed.len() == from.len()
|
||||
&& cmds.moved.is_empty()
|
||||
{
|
||||
cmds.clear = true;
|
||||
cmds.removed.clear();
|
||||
|
||||
cmds.added
|
||||
.iter_mut()
|
||||
.for_each(|op| op.mode = DiffOpAddMode::Append);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// We can optimize appends.
|
||||
if !cmds.added.is_empty()
|
||||
&& cmds.moved.is_empty()
|
||||
&& cmds.removed.is_empty()
|
||||
&& cmds.added[0].at >= from.len()
|
||||
{
|
||||
cmds.added
|
||||
.iter_mut()
|
||||
.for_each(|op| op.mode = DiffOpAddMode::Append);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
fn optimize_moves(moves: &mut Vec<DiffOpMove>) {
|
||||
if moves.is_empty() || moves.len() == 1 {
|
||||
// Do nothing
|
||||
}
|
||||
// This is the easiest optimal move case, which is to
|
||||
// simply swap the 2 ranges. We only need to move the range
|
||||
// that is smallest.
|
||||
else if moves.len() == 2 {
|
||||
if moves[1].len < moves[0].len {
|
||||
moves[0].move_in_dom = false;
|
||||
} else {
|
||||
moves[1].move_in_dom = false;
|
||||
/// Group adjacent items that are being moved as a group.
|
||||
/// For example from `[2, 3, 5, 6]` to `[1, 2, 3, 4, 5, 6]` should result
|
||||
/// in a move for `2,3` and `5,6` rather than 4 individual moves.
|
||||
fn group_adjacent_moves(moved: Vec<DiffOpMove>) -> Vec<DiffOpMove> {
|
||||
let mut prev: Option<DiffOpMove> = None;
|
||||
let mut new_moved = Vec::with_capacity(moved.len());
|
||||
for m in moved {
|
||||
match prev {
|
||||
Some(mut p) => {
|
||||
if (m.from == p.from + p.len) && (m.to == p.to + p.len) {
|
||||
p.len += 1;
|
||||
prev = Some(p);
|
||||
} else {
|
||||
new_moved.push(prev.take().unwrap());
|
||||
prev = Some(m);
|
||||
}
|
||||
}
|
||||
None => prev = Some(m),
|
||||
}
|
||||
}
|
||||
// Interestingly enoughs, there are NO configuration that are possible
|
||||
// for ranges of 3.
|
||||
//
|
||||
// For example, take A, B, C. Here are all possible configurations and
|
||||
// reasons for why they are impossible:
|
||||
// - A B C # identity, would be removed by ranges that didn't move
|
||||
// - A C B # `A` would be removed, thus it's a case of length 2
|
||||
// - B A C # `C` would be removed, thus it's a case of length 2
|
||||
// - B C A # `B C` are congiguous, so this is would have been a single range
|
||||
// - C A B # `A B` are congiguous, so this is would have been a single range
|
||||
// - C B A # `B` would be removed, thus it's a case of length 2
|
||||
//
|
||||
// We can add more pre-computed tables here if benchmarking or
|
||||
// user demand needs it...nevertheless, it is unlikely for us
|
||||
// to implement this algorithm to handle N ranges, because this
|
||||
// becomes exponentially more expensive to compute. It's faster,
|
||||
// for the most part, to assume the ranges are random and move
|
||||
// all the ranges around than to try and figure out the best way
|
||||
// to move them
|
||||
else {
|
||||
// The idea here is that for N ranges, we never need to
|
||||
// move the largest range, rather, have all ranges move
|
||||
// around it.
|
||||
let move_ = moves.iter_mut().max_by_key(|move_| move_.len).unwrap();
|
||||
|
||||
move_.move_in_dom = false;
|
||||
if let Some(prev) = prev {
|
||||
new_moved.push(prev)
|
||||
}
|
||||
new_moved
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct Diff {
|
||||
removed: Vec<DiffOpRemove>,
|
||||
@@ -728,7 +616,6 @@ struct Diff {
|
||||
clear: bool,
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct DiffOpMove {
|
||||
/// The index this range is starting relative to `from`.
|
||||
@@ -742,7 +629,6 @@ struct DiffOpMove {
|
||||
move_in_dom: bool,
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
impl Default for DiffOpMove {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -754,20 +640,18 @@ impl Default for DiffOpMove {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
struct DiffOpAdd {
|
||||
at: usize,
|
||||
mode: DiffOpAddMode,
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct DiffOpRemove {
|
||||
at: usize,
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
#[allow(dead_code)] // Append not used in SSR but useful
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum DiffOpAddMode {
|
||||
Normal,
|
||||
@@ -776,7 +660,6 @@ enum DiffOpAddMode {
|
||||
_Prepend,
|
||||
}
|
||||
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web"))]
|
||||
impl Default for DiffOpAddMode {
|
||||
fn default() -> Self {
|
||||
Self::Normal
|
||||
@@ -822,7 +705,11 @@ fn apply_diff<T, EF, V>(
|
||||
#[cfg(not(debug_assertions))]
|
||||
parent.append_with_node_1(closing).unwrap();
|
||||
} else {
|
||||
#[cfg(debug_assertions)]
|
||||
range.set_start_after(opening).unwrap();
|
||||
#[cfg(not(debug_assertions))]
|
||||
range.set_start_before(opening).unwrap();
|
||||
|
||||
range.set_end_before(closing).unwrap();
|
||||
|
||||
range.delete_contents().unwrap();
|
||||
@@ -982,404 +869,173 @@ fn unpack_moves(diff: &Diff) -> (Vec<DiffOpMove>, Vec<DiffOpAdd>) {
|
||||
(moves, adds)
|
||||
}
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod test_utils {
|
||||
// use super::*;
|
||||
#[cfg(test)]
|
||||
mod test_utils {
|
||||
use super::*;
|
||||
|
||||
// pub trait IntoFxIndexSet<K> {
|
||||
// fn into_fx_index_set(self) -> FxIndexSet<K>;
|
||||
// }
|
||||
pub trait IntoFxIndexSet<K> {
|
||||
fn into_fx_index_set(self) -> FxIndexSet<K>;
|
||||
}
|
||||
|
||||
// impl<T, K> IntoFxIndexSet<K> for T
|
||||
// where
|
||||
// T: IntoIterator<Item = K>,
|
||||
// K: Eq + Hash,
|
||||
// {
|
||||
// fn into_fx_index_set(self) -> FxIndexSet<K> {
|
||||
// self.into_iter().collect()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
impl<T, K> IntoFxIndexSet<K> for T
|
||||
where
|
||||
T: IntoIterator<Item = K>,
|
||||
K: Eq + Hash,
|
||||
{
|
||||
fn into_fx_index_set(self) -> FxIndexSet<K> {
|
||||
self.into_iter().collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #[cfg(test)]
|
||||
// use test_utils::*;
|
||||
#[cfg(test)]
|
||||
use test_utils::*;
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod find_ranges {
|
||||
// use super::*;
|
||||
#[cfg(test)]
|
||||
mod diff {
|
||||
use super::*;
|
||||
|
||||
// // Single range tests will be empty because of removing ranges
|
||||
// // that didn't move
|
||||
// #[test]
|
||||
// fn single_range() {
|
||||
// let ranges = find_ranges(
|
||||
// [1, 2, 3, 4].iter().into_fx_index_set(),
|
||||
// [1, 2, 3, 4].iter().into_fx_index_set(),
|
||||
// &[1, 2, 3, 4].into_fx_index_set(),
|
||||
// &[1, 2, 3, 4].into_fx_index_set(),
|
||||
// );
|
||||
#[test]
|
||||
fn only_adds() {
|
||||
let diff =
|
||||
diff(&[].into_fx_index_set(), &[1, 2, 3].into_fx_index_set());
|
||||
|
||||
// assert_eq!(ranges, vec![]);
|
||||
// }
|
||||
assert_eq!(
|
||||
diff,
|
||||
Diff {
|
||||
added: vec![
|
||||
DiffOpAdd {
|
||||
at: 0,
|
||||
mode: DiffOpAddMode::Append
|
||||
},
|
||||
DiffOpAdd {
|
||||
at: 1,
|
||||
mode: DiffOpAddMode::Append
|
||||
},
|
||||
DiffOpAdd {
|
||||
at: 2,
|
||||
mode: DiffOpAddMode::Append
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn single_range_with_adds() {
|
||||
// let ranges = find_ranges(
|
||||
// [1, 2, 3, 4].iter().into_fx_index_set(),
|
||||
// [1, 2, 3, 4].iter().into_fx_index_set(),
|
||||
// &[1, 2, 3, 4].into_fx_index_set(),
|
||||
// &[1, 2, 5, 3, 4].into_fx_index_set(),
|
||||
// );
|
||||
#[test]
|
||||
fn only_removes() {
|
||||
let diff =
|
||||
diff(&[1, 2, 3].into_fx_index_set(), &[3].into_fx_index_set());
|
||||
|
||||
// assert_eq!(ranges, vec![]);
|
||||
// }
|
||||
assert_eq!(
|
||||
diff,
|
||||
Diff {
|
||||
removed: vec![DiffOpRemove { at: 0 }, DiffOpRemove { at: 1 }],
|
||||
moved: vec![DiffOpMove {
|
||||
from: 2,
|
||||
len: 1,
|
||||
to: 0,
|
||||
move_in_dom: false
|
||||
}],
|
||||
items_to_move: 1,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn single_range_with_removals() {
|
||||
// let ranges = find_ranges(
|
||||
// [1, 2, 3, 4].iter().into_fx_index_set(),
|
||||
// [1, 2, 3, 4].iter().into_fx_index_set(),
|
||||
// &[1, 2, 5, 3, 4].into_fx_index_set(),
|
||||
// &[1, 2, 3, 4].into_fx_index_set(),
|
||||
// );
|
||||
#[test]
|
||||
fn adds_with_no_move() {
|
||||
let diff =
|
||||
diff(&[3].into_fx_index_set(), &[1, 2, 3].into_fx_index_set());
|
||||
|
||||
// assert_eq!(ranges, vec![]);
|
||||
// }
|
||||
assert_eq!(
|
||||
diff,
|
||||
Diff {
|
||||
added: vec![
|
||||
DiffOpAdd {
|
||||
at: 0,
|
||||
..Default::default()
|
||||
},
|
||||
DiffOpAdd {
|
||||
at: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
moved: vec![DiffOpMove {
|
||||
from: 0,
|
||||
len: 1,
|
||||
to: 2,
|
||||
move_in_dom: true
|
||||
}],
|
||||
items_to_move: 1,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn two_ranges() {
|
||||
// let ranges = find_ranges(
|
||||
// [1, 2, 3, 4].iter().into_fx_index_set(),
|
||||
// [3, 4, 1, 2].iter().into_fx_index_set(),
|
||||
// &[1, 2, 3, 4].into_fx_index_set(),
|
||||
// &[3, 4, 1, 2].into_fx_index_set(),
|
||||
// );
|
||||
#[test]
|
||||
fn move_as_group() {
|
||||
let diff = diff(
|
||||
&[2, 3, 4, 5].into_fx_index_set(),
|
||||
&[1, 2, 3, 4, 5].into_fx_index_set(),
|
||||
);
|
||||
|
||||
// assert_eq!(
|
||||
// ranges,
|
||||
// vec![
|
||||
// DiffOpMove {
|
||||
// from: 2,
|
||||
// to: 0,
|
||||
// len: 2,
|
||||
// move_in_dom: true,
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 2,
|
||||
// len: 2,
|
||||
// move_in_dom: true,
|
||||
// },
|
||||
// ]
|
||||
// );
|
||||
// }
|
||||
assert_eq!(
|
||||
diff,
|
||||
Diff {
|
||||
added: vec![DiffOpAdd {
|
||||
at: 0,
|
||||
..Default::default()
|
||||
},],
|
||||
moved: vec![DiffOpMove {
|
||||
from: 0,
|
||||
len: 4,
|
||||
to: 1,
|
||||
move_in_dom: false
|
||||
},],
|
||||
items_to_move: 4,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn two_ranges_with_adds() {
|
||||
// let ranges = find_ranges(
|
||||
// [1, 2, 3, 4].iter().into_fx_index_set(),
|
||||
// [3, 4, 1, 2].iter().into_fx_index_set(),
|
||||
// &[1, 2, 3, 4].into_fx_index_set(),
|
||||
// &[3, 4, 5, 1, 6, 2].into_fx_index_set(),
|
||||
// );
|
||||
#[test]
|
||||
fn move_as_group_with_gap() {
|
||||
let diff = diff(
|
||||
&[2, 3, 5, 6].into_fx_index_set(),
|
||||
&[1, 2, 3, 4, 5, 6].into_fx_index_set(),
|
||||
);
|
||||
|
||||
// assert_eq!(
|
||||
// ranges,
|
||||
// vec![
|
||||
// DiffOpMove {
|
||||
// from: 2,
|
||||
// to: 0,
|
||||
// len: 2,
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 3,
|
||||
// len: 2,
|
||||
// },
|
||||
// ]
|
||||
// );
|
||||
// }
|
||||
// #[test]
|
||||
// fn two_ranges_with_removals() {
|
||||
// let ranges = find_ranges(
|
||||
// [1, 2, 3, 4].iter().into_fx_index_set(),
|
||||
// [3, 4, 1, 2].iter().into_fx_index_set(),
|
||||
// &[1, 5, 2, 6, 3, 4].into_fx_index_set(),
|
||||
// &[3, 4, 1, 2].into_fx_index_set(),
|
||||
// );
|
||||
|
||||
// assert_eq!(
|
||||
// ranges,
|
||||
// vec![
|
||||
// DiffOpMove {
|
||||
// from: 4,
|
||||
// to: 0,
|
||||
// len: 2,
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 2,
|
||||
// len: 2,
|
||||
// },
|
||||
// ]
|
||||
// );
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn remove_ranges_that_did_not_move() {
|
||||
// // Here, 'C' doesn't change
|
||||
// let ranges = find_ranges(
|
||||
// ['A', 'B', 'C', 'D'].iter().into_fx_index_set(),
|
||||
// ['B', 'D', 'C', 'A'].iter().into_fx_index_set(),
|
||||
// &['A', 'B', 'C', 'D'].into_fx_index_set(),
|
||||
// &['B', 'D', 'C', 'A'].into_fx_index_set(),
|
||||
// );
|
||||
|
||||
// assert_eq!(
|
||||
// ranges,
|
||||
// vec![
|
||||
// DiffOpMove {
|
||||
// from: 1,
|
||||
// to: 0,
|
||||
// len: 1,
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 3,
|
||||
// to: 1,
|
||||
// len: 1,
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 3,
|
||||
// len: 1,
|
||||
// },
|
||||
// ]
|
||||
// );
|
||||
|
||||
// // Now we're going to to the same as above, just with more items
|
||||
// //
|
||||
// // A = 1
|
||||
// // B = 2, 3
|
||||
// // C = 4, 5, 6
|
||||
// // D = 7, 8, 9, 0
|
||||
|
||||
// let ranges = find_ranges(
|
||||
// //A B C D
|
||||
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 0].iter().into_fx_index_set(),
|
||||
// //B D C A
|
||||
// [2, 3, 7, 8, 9, 0, 4, 5, 6, 1].iter().into_fx_index_set(),
|
||||
// //A B C D
|
||||
// &[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].into_fx_index_set(),
|
||||
// //B D C A
|
||||
// &[2, 3, 7, 8, 9, 0, 4, 5, 6, 1].into_fx_index_set(),
|
||||
// );
|
||||
|
||||
// assert_eq!(
|
||||
// ranges,
|
||||
// vec![
|
||||
// DiffOpMove {
|
||||
// from: 1,
|
||||
// to: 0,
|
||||
// len: 2,
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 6,
|
||||
// to: 2,
|
||||
// len: 4,
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 9,
|
||||
// len: 1,
|
||||
// },
|
||||
// ]
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod optimize_moves {
|
||||
// use super::*;
|
||||
|
||||
// #[test]
|
||||
// fn swap() {
|
||||
// let mut moves = vec![
|
||||
// DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 6,
|
||||
// len: 2,
|
||||
// ..Default::default()
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 6,
|
||||
// to: 0,
|
||||
// len: 7,
|
||||
// ..Default::default()
|
||||
// },
|
||||
// ];
|
||||
|
||||
// optimize_moves(&mut moves);
|
||||
|
||||
// assert_eq!(
|
||||
// moves,
|
||||
// vec![DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 6,
|
||||
// len: 2,
|
||||
// ..Default::default()
|
||||
// }]
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod add_or_move {
|
||||
// use super::*;
|
||||
|
||||
// #[test]
|
||||
// fn simple_range() {
|
||||
// let cmds = AddOrMove::from_diff(&Diff {
|
||||
// moved: vec![DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 0,
|
||||
// len: 3,
|
||||
// }],
|
||||
// ..Default::default()
|
||||
// });
|
||||
|
||||
// assert_eq!(
|
||||
// cmds,
|
||||
// vec![
|
||||
// DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 0,
|
||||
// len: 1,
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 1,
|
||||
// to: 1,
|
||||
// len: 1,
|
||||
// },
|
||||
// DiffOpMove {
|
||||
// from: 2,
|
||||
// to: 2,
|
||||
// len: 1,
|
||||
// },
|
||||
// ]
|
||||
// );
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn range_with_add() {
|
||||
// let cmds = AddOrMove::from_diff(&Diff {
|
||||
// moved: vec![DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 0,
|
||||
// len: 3,
|
||||
// move_in_dom: true,
|
||||
// }],
|
||||
// added: vec![DiffOpAdd {
|
||||
// at: 2,
|
||||
// ..Default::default()
|
||||
// }],
|
||||
// ..Default::default()
|
||||
// });
|
||||
|
||||
// assert_eq!(
|
||||
// cmds,
|
||||
// vec![
|
||||
// AddOrMove::Move(DiffOpMove {
|
||||
// from: 0,
|
||||
// to: 0,
|
||||
// len: 1,
|
||||
// move_in_dom: true,
|
||||
// }),
|
||||
// AddOrMove::Move(DiffOpMove {
|
||||
// from: 1,
|
||||
// to: 1,
|
||||
// len: 1,
|
||||
// move_in_dom: true,
|
||||
// }),
|
||||
// AddOrMove::Add(DiffOpAdd {
|
||||
// at: 2,
|
||||
// ..Default::default()
|
||||
// }),
|
||||
// AddOrMove::Move(DiffOpMove {
|
||||
// from: 3,
|
||||
// to: 3,
|
||||
// len: 1,
|
||||
// move_in_dom: true,
|
||||
// }),
|
||||
// ]
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod diff {
|
||||
// use super::*;
|
||||
|
||||
// #[test]
|
||||
// fn only_adds() {
|
||||
// let diff =
|
||||
// diff(&[].into_fx_index_set(), &[1, 2, 3].into_fx_index_set());
|
||||
|
||||
// assert_eq!(
|
||||
// diff,
|
||||
// Diff {
|
||||
// added: vec![
|
||||
// DiffOpAdd {
|
||||
// at: 0,
|
||||
// mode: DiffOpAddMode::Append
|
||||
// },
|
||||
// DiffOpAdd {
|
||||
// at: 1,
|
||||
// mode: DiffOpAddMode::Append
|
||||
// },
|
||||
// DiffOpAdd {
|
||||
// at: 2,
|
||||
// mode: DiffOpAddMode::Append
|
||||
// },
|
||||
// ],
|
||||
// ..Default::default()
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn only_removes() {
|
||||
// let diff =
|
||||
// diff(&[1, 2, 3].into_fx_index_set(), &[3].into_fx_index_set());
|
||||
|
||||
// assert_eq!(
|
||||
// diff,
|
||||
// Diff {
|
||||
// removed: vec![DiffOpRemove { at: 0 }, DiffOpRemove { at: 1 }],
|
||||
// ..Default::default()
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
|
||||
// #[test]
|
||||
// fn adds_with_no_move() {
|
||||
// let diff =
|
||||
// diff(&[3].into_fx_index_set(), &[1, 2, 3].into_fx_index_set());
|
||||
|
||||
// assert_eq!(
|
||||
// diff,
|
||||
// Diff {
|
||||
// added: vec![
|
||||
// DiffOpAdd {
|
||||
// at: 0,
|
||||
// ..Default::default()
|
||||
// },
|
||||
// DiffOpAdd {
|
||||
// at: 1,
|
||||
// ..Default::default()
|
||||
// },
|
||||
// ],
|
||||
// ..Default::default()
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
assert_eq!(
|
||||
diff,
|
||||
Diff {
|
||||
added: vec![
|
||||
DiffOpAdd {
|
||||
at: 0,
|
||||
..Default::default()
|
||||
},
|
||||
DiffOpAdd {
|
||||
at: 3,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
moved: vec![
|
||||
DiffOpMove {
|
||||
from: 0,
|
||||
len: 2,
|
||||
to: 1,
|
||||
move_in_dom: false
|
||||
},
|
||||
DiffOpMove {
|
||||
from: 2,
|
||||
len: 2,
|
||||
to: 4,
|
||||
move_in_dom: true
|
||||
}
|
||||
],
|
||||
items_to_move: 4,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,19 +158,13 @@ pub(crate) fn add_delegated_event_listener(
|
||||
}
|
||||
|
||||
// navigate up tree
|
||||
let host =
|
||||
js_sys::Reflect::get(&node, &JsValue::from_str("host"))
|
||||
.unwrap_throw();
|
||||
if host.is_truthy()
|
||||
&& host != node
|
||||
&& host.dyn_ref::<web_sys::Node>().is_some()
|
||||
{
|
||||
node = host;
|
||||
} else if let Some(parent) =
|
||||
node.unchecked_into::<web_sys::Node>().parent_node()
|
||||
if let Some(parent) =
|
||||
node.unchecked_ref::<web_sys::Node>().parent_node()
|
||||
{
|
||||
node = parent.into()
|
||||
} else {
|
||||
} else if let Some(root) = node.dyn_ref::<web_sys::ShadowRoot>() {
|
||||
node = root.host().unchecked_into();
|
||||
} else {
|
||||
node = JsValue::null()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,22 +733,24 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
|
||||
.collect::<SmallVec<[Cow<'static, str>; 4]>>(
|
||||
);
|
||||
|
||||
let mut new_classes = classes
|
||||
let new_classes = classes
|
||||
.iter()
|
||||
.flat_map(|classes| classes.split_whitespace());
|
||||
|
||||
if let Some(prev_classes) = prev_classes {
|
||||
let new_classes =
|
||||
new_classes.collect::<SmallVec<[_; 4]>>();
|
||||
let mut old_classes = prev_classes
|
||||
.iter()
|
||||
.flat_map(|classes| classes.split_whitespace());
|
||||
|
||||
// Remove old classes
|
||||
for prev_class in old_classes.clone() {
|
||||
if !new_classes.any(|c| c == prev_class) {
|
||||
if !new_classes.iter().any(|c| c == &prev_class) {
|
||||
class_list.remove_1(prev_class).unwrap_or_else(
|
||||
|err| {
|
||||
panic!(
|
||||
"failed to add class \
|
||||
"failed to remove class \
|
||||
`{prev_class}`, error: {err:#?}"
|
||||
)
|
||||
},
|
||||
@@ -761,7 +763,7 @@ impl<El: ElementDescriptor + 'static> HtmlElement<El> {
|
||||
if !old_classes.any(|c| c == class) {
|
||||
class_list.add_1(class).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to remove class `{class}`, \
|
||||
"failed to add class `{class}`, \
|
||||
error: {err:#?}"
|
||||
)
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ pub use html::HtmlElement;
|
||||
use html::{AnyElement, ElementDescriptor};
|
||||
pub use hydration::{HydrationCtx, HydrationKey};
|
||||
use leptos_reactive::Scope;
|
||||
#[cfg(feature = "stable")]
|
||||
#[cfg(not(feature = "nightly"))]
|
||||
use leptos_reactive::{
|
||||
MaybeSignal, Memo, ReadSignal, RwSignal, Signal, SignalGet,
|
||||
};
|
||||
@@ -143,7 +143,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "stable")]
|
||||
#[cfg(not(feature = "nightly"))]
|
||||
impl<T> IntoView for ReadSignal<T>
|
||||
where
|
||||
T: IntoView + Clone,
|
||||
@@ -156,7 +156,7 @@ where
|
||||
DynChild::new(move || self.get()).into_view(cx)
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "stable")]
|
||||
#[cfg(not(feature = "nightly"))]
|
||||
impl<T> IntoView for RwSignal<T>
|
||||
where
|
||||
T: IntoView + Clone,
|
||||
@@ -169,7 +169,7 @@ where
|
||||
DynChild::new(move || self.get()).into_view(cx)
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "stable")]
|
||||
#[cfg(not(feature = "nightly"))]
|
||||
impl<T> IntoView for Memo<T>
|
||||
where
|
||||
T: IntoView + Clone,
|
||||
@@ -182,7 +182,7 @@ where
|
||||
DynChild::new(move || self.get()).into_view(cx)
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "stable")]
|
||||
#[cfg(not(feature = "nightly"))]
|
||||
impl<T> IntoView for Signal<T>
|
||||
where
|
||||
T: IntoView + Clone,
|
||||
@@ -195,7 +195,7 @@ where
|
||||
DynChild::new(move || self.get()).into_view(cx)
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "stable")]
|
||||
#[cfg(not(feature = "nightly"))]
|
||||
impl<T> IntoView for MaybeSignal<T>
|
||||
where
|
||||
T: IntoView + Clone,
|
||||
@@ -483,6 +483,9 @@ impl Text {
|
||||
|
||||
/// A leptos view which can be mounted to the DOM.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
#[must_use = "You are creating a View but not using it. An unused view can \
|
||||
cause your view to be rendered as () unexpectedly, and it can \
|
||||
also cause issues with client-side hydration."]
|
||||
pub enum View {
|
||||
/// HTML element node.
|
||||
Element(Element),
|
||||
@@ -729,7 +732,7 @@ impl View {
|
||||
c.children.iter().cloned().for_each(|c| {
|
||||
let event_handler = event_handler.clone();
|
||||
|
||||
c.on(event.clone(), Box::new(move |e| event_handler.borrow_mut()(e)));
|
||||
_ = c.on(event.clone(), Box::new(move |e| event_handler.borrow_mut()(e)));
|
||||
});
|
||||
}
|
||||
Self::CoreComponent(c) => match c {
|
||||
@@ -838,9 +841,7 @@ where
|
||||
crate::console_warn(
|
||||
"You have both `csr` and `ssr` or `hydrate` and `ssr` enabled as \
|
||||
features, which may cause issues like <Suspense/>` failing to work \
|
||||
silently. `csr` is enabled by default on `leptos`, and can be \
|
||||
disabled by adding `default-features = false` to your `leptos` \
|
||||
dependency.",
|
||||
silently.",
|
||||
);
|
||||
|
||||
cfg_if! {
|
||||
|
||||
@@ -79,6 +79,7 @@ pub fn class_helper(
|
||||
name: Cow<'static, str>,
|
||||
value: Class,
|
||||
) {
|
||||
use crate::HydrationCtx;
|
||||
use leptos_reactive::create_render_effect;
|
||||
|
||||
let class_list = el.class_list();
|
||||
@@ -86,7 +87,9 @@ pub fn class_helper(
|
||||
Class::Fn(cx, f) => {
|
||||
create_render_effect(cx, move |old| {
|
||||
let new = f();
|
||||
if old.as_ref() != Some(&new) && (old.is_some() || new) {
|
||||
if old.as_ref() != Some(&new)
|
||||
&& (old.is_some() || new || HydrationCtx::is_hydrating())
|
||||
{
|
||||
class_expression(&class_list, &name, new, true)
|
||||
}
|
||||
new
|
||||
|
||||
@@ -296,7 +296,7 @@ fn ooo_body_stream_recurse(
|
||||
fragments.chain(resources).chain(
|
||||
futures::stream::once(async move {
|
||||
let pending = cx.pending_fragments();
|
||||
if pending.len() > 0 {
|
||||
if !pending.is_empty() {
|
||||
let fragments = FuturesUnordered::new();
|
||||
let serializers = cx.serialization_resolvers();
|
||||
for (fragment_id, data) in pending {
|
||||
@@ -368,9 +368,7 @@ impl View {
|
||||
crate::console_error(
|
||||
"\n[DANGER] You have both `csr` and `ssr` or `hydrate` and `ssr` \
|
||||
enabled as features, which may cause issues like <Suspense/>` \
|
||||
failing to work silently. `csr` is enabled by default on \
|
||||
`leptos`, and can be disabled by adding `default-features = \
|
||||
false` to your `leptos` dependency.\n",
|
||||
failing to work silently.\n",
|
||||
);
|
||||
|
||||
self.render_to_string_helper(false)
|
||||
|
||||
@@ -59,9 +59,7 @@ pub fn render_to_stream_in_order_with_prefix(
|
||||
crate::console_error(
|
||||
"\n[DANGER] You have both `csr` and `ssr` or `hydrate` and `ssr` \
|
||||
enabled as features, which may cause issues like <Suspense/>` \
|
||||
failing to work silently. `csr` is enabled by default on `leptos`, \
|
||||
and can be disabled by adding `default-features = false` to your \
|
||||
`leptos` dependency.\n",
|
||||
failing to work silently.\n",
|
||||
);
|
||||
|
||||
let (stream, runtime, _) =
|
||||
|
||||
@@ -24,4 +24,4 @@ proc-macro2 = { version = "1", features = ["span-locations", "nightly"] }
|
||||
parking_lot = "0.12"
|
||||
walkdir = "2"
|
||||
camino = "1.1.3"
|
||||
indexmap = "1.9.2"
|
||||
indexmap = "2"
|
||||
|
||||
@@ -70,7 +70,7 @@ impl ViewMacros {
|
||||
let mut views = Vec::new();
|
||||
for view in visitor.views {
|
||||
let span = view.span();
|
||||
let id = span_to_stable_id(path, span);
|
||||
let id = span_to_stable_id(path, span.start().line);
|
||||
let mut tokens = view.tokens.clone().into_iter();
|
||||
tokens.next(); // cx
|
||||
tokens.next(); // ,
|
||||
@@ -148,15 +148,11 @@ impl<'ast> Visit<'ast> for ViewMacroVisitor<'ast> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn span_to_stable_id(
|
||||
path: impl AsRef<Path>,
|
||||
site: proc_macro2::Span,
|
||||
) -> String {
|
||||
pub fn span_to_stable_id(path: impl AsRef<Path>, line: usize) -> String {
|
||||
let file = path
|
||||
.as_ref()
|
||||
.to_str()
|
||||
.unwrap_or_default()
|
||||
.replace(['/', '\\'], "-");
|
||||
let start = site.start();
|
||||
format!("{}-{:?}", file, start.line)
|
||||
format!("{file}-{line}")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ function patch(json) {
|
||||
try {
|
||||
const views = JSON.parse(json);
|
||||
for (const [id, patches] of views) {
|
||||
console.log("[HOT RELOAD]", patches);
|
||||
console.log("[HOT RELOAD]", id, patches);
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_COMMENT),
|
||||
open = `leptos-view|${id}|open`,
|
||||
close = `leptos-view|${id}|close`;
|
||||
|
||||
@@ -438,15 +438,18 @@ impl Docs {
|
||||
let mut attrs = attrs
|
||||
.iter()
|
||||
.filter_map(|attr| {
|
||||
let Meta::NameValue(attr ) = &attr.meta else {
|
||||
return None
|
||||
let Meta::NameValue(attr) = &attr.meta else {
|
||||
return None;
|
||||
};
|
||||
if !attr.path.is_ident("doc") {
|
||||
return None
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(val) = value_to_string(&attr.value) else {
|
||||
abort!(attr, "expected string literal in value of doc comment");
|
||||
abort!(
|
||||
attr,
|
||||
"expected string literal in value of doc comment"
|
||||
);
|
||||
};
|
||||
|
||||
Some((val, attr.path.span()))
|
||||
|
||||
@@ -384,7 +384,7 @@ fn normalized_call_site(site: proc_macro::Span) -> Option<String> {
|
||||
if #[cfg(all(debug_assertions, feature = "nightly"))] {
|
||||
Some(leptos_hot_reload::span_to_stable_id(
|
||||
site.source_file().path(),
|
||||
site.into()
|
||||
site.start().line()
|
||||
))
|
||||
} else {
|
||||
_ = site;
|
||||
@@ -793,15 +793,18 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
|
||||
/// If you call a server function from the client (i.e., when the `csr` or `hydrate` features
|
||||
/// are enabled), it will instead make a network request to the server.
|
||||
///
|
||||
/// You can specify one, two, or three arguments to the server function:
|
||||
/// You can specify one, two, three, or four arguments to the server function:
|
||||
/// 1. **Required**: A type name that will be used to identify and register the server function
|
||||
/// (e.g., `MyServerFn`).
|
||||
/// 2. *Optional*: A URL prefix at which the function will be mounted when it’s registered
|
||||
/// (e.g., `"/api"`). Defaults to `"/"`.
|
||||
/// 3. *Optional*: either `"Cbor"` (specifying that it should use the binary `cbor` format for
|
||||
/// serialization) or `"Url"` (specifying that it should be use a URL-encoded form-data string).
|
||||
/// Defaults to `"Url"`. If you want to use this server function to power a `<form>` that will
|
||||
/// work without WebAssembly, the encoding must be `"Url"`.
|
||||
/// 3. *Optional*: The encoding for the server function (`"Url"`, `"Cbor"`, `"GetJson"`, or `"GetCbor`". See **Server Function Encodings** below.)
|
||||
/// 4. *Optional*: A specific endpoint path to be used in the URL. (By default, a unique path will be generated.)
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// // will generate a server function at `/api-prefix/hello`
|
||||
/// #[server(MyServerFnType, "/api-prefix", "Url", "hello")]
|
||||
/// ```
|
||||
///
|
||||
/// The server function itself can take any number of arguments, each of which should be serializable
|
||||
/// and deserializable with `serde`. Optionally, its first argument can be a Leptos
|
||||
@@ -821,17 +824,16 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
|
||||
/// ```
|
||||
///
|
||||
/// Note the following:
|
||||
/// - You must **register** the server function by calling `T::register()` somewhere in your main function.
|
||||
/// - **Server functions must be `async`.** Even if the work being done inside the function body
|
||||
/// can run synchronously on the server, from the client’s perspective it involves an asynchronous
|
||||
/// function call.
|
||||
/// - **Server functions must return `Result<T, ServerFnError>`.** Even if the work being done
|
||||
/// inside the function body can’t fail, the processes of serialization/deserialization and the
|
||||
/// network call are fallible.
|
||||
/// - **Return types must be [Serializable](https://docs.rs/leptos/latest/leptos/trait.Serializable.html).**
|
||||
/// - **Return types must implement [`Serialize`](https://docs.rs/serde/latest/serde/trait.Serialize.html).**
|
||||
/// This should be fairly obvious: we have to serialize arguments to send them to the server, and we
|
||||
/// need to deserialize the result to return it to the client.
|
||||
/// - **Arguments must be implement [`Serialize`](https://docs.rs/serde/latest/serde/trait.Serialize.html)
|
||||
/// - **Arguments must implement [`Serialize`](https://docs.rs/serde/latest/serde/trait.Serialize.html)
|
||||
/// and [`DeserializeOwned`](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html).**
|
||||
/// They are serialized as an `application/x-www-form-urlencoded`
|
||||
/// form data using [`serde_qs`](https://docs.rs/serde_qs/latest/serde_qs/) or as `application/cbor`
|
||||
@@ -840,6 +842,9 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
|
||||
/// - **The `Scope` comes from the server.** Optionally, the first argument of a server function
|
||||
/// can be a Leptos `Scope`. This scope can be used to inject dependencies like the HTTP request
|
||||
/// or response or other server-only dependencies, but it does *not* have access to reactive state that exists in the client.
|
||||
/// - Your server must be ready to handle the server functions at the API prefix you list. The easiest way to do this
|
||||
/// is to use the `handle_server_fns` function from [`leptos_actix`](https://docs.rs/leptos_actix/latest/leptos_actix/fn.handle_server_fns.html)
|
||||
/// or [`leptos_axum`](https://docs.rs/leptos_axum/latest/leptos_axum/fn.handle_server_fns.html).
|
||||
///
|
||||
/// ## Server Function Encodings
|
||||
///
|
||||
|
||||
@@ -582,6 +582,13 @@ fn attribute_to_tokens_ssr<'a>(
|
||||
{
|
||||
// ignore props for SSR
|
||||
// ignore classes and styles: we'll handle these separately
|
||||
if name.starts_with("prop:") {
|
||||
let value = attr.value();
|
||||
exprs_for_compiler.push(quote! {
|
||||
#[allow(unused_braces)]
|
||||
{ _ = #value; }
|
||||
});
|
||||
}
|
||||
} else if name == "inner_html" {
|
||||
return attr.value();
|
||||
} else {
|
||||
@@ -1367,8 +1374,8 @@ pub(crate) fn parse_event_name(name: &str) -> (TokenStream, bool, bool) {
|
||||
let is_custom = event_type == "Custom";
|
||||
|
||||
let Ok(event_type) = event_type.parse::<TokenStream>() else {
|
||||
abort!(event_type, "couldn't parse event name");
|
||||
};
|
||||
abort!(event_type, "couldn't parse event name");
|
||||
};
|
||||
|
||||
let event_type = if is_custom {
|
||||
quote! { Custom::new(#name) }
|
||||
@@ -1397,7 +1404,10 @@ pub(crate) fn slot_to_tokens(
|
||||
let span = node.name().span();
|
||||
|
||||
let Some(parent_slots) = parent_slots else {
|
||||
proc_macro_error::emit_error!(span, "slots cannot be used inside HTML elements");
|
||||
proc_macro_error::emit_error!(
|
||||
span,
|
||||
"slots cannot be used inside HTML elements"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ web-sys = { version = "0.3", optional = true, features = [
|
||||
"Window",
|
||||
] }
|
||||
cfg-if = "1"
|
||||
indexmap = "1"
|
||||
indexmap = "2"
|
||||
self_cell = "1.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -95,6 +95,7 @@ mod spawn_microtask;
|
||||
mod stored_value;
|
||||
pub mod suspense;
|
||||
mod trigger;
|
||||
mod watch;
|
||||
|
||||
pub use context::*;
|
||||
pub use diagnostics::SpecialNonReactiveZone;
|
||||
@@ -116,6 +117,7 @@ pub use spawn_microtask::*;
|
||||
pub use stored_value::*;
|
||||
pub use suspense::{GlobalSuspenseContext, SuspenseContext};
|
||||
pub use trigger::*;
|
||||
pub use watch::*;
|
||||
|
||||
mod macros {
|
||||
macro_rules! debug_warn {
|
||||
|
||||
@@ -863,6 +863,21 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// on cleanup of this component, remove this read from parent `<Suspense/>`
|
||||
// it will be added back in when this is rendered again
|
||||
if let Some(s) = suspense_cx {
|
||||
crate::on_cleanup(cx, {
|
||||
let suspense_contexts = Rc::clone(&suspense_contexts);
|
||||
move || {
|
||||
if let Ok(ref mut contexts) =
|
||||
suspense_contexts.try_borrow_mut()
|
||||
{
|
||||
contexts.remove(&s);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let increment = move |_: Option<()>| {
|
||||
if let Some(s) = &suspense_cx {
|
||||
if let Ok(ref mut contexts) = suspense_contexts.try_borrow_mut()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use crate::{
|
||||
hydration::SharedContext,
|
||||
node::{NodeId, ReactiveNode, ReactiveNodeState, ReactiveNodeType},
|
||||
AnyComputation, AnyResource, Effect, Memo, MemoState, ReadSignal,
|
||||
ResourceId, ResourceState, RwSignal, Scope, ScopeDisposer, ScopeId,
|
||||
ScopeProperty, SerializableResource, StoredValueId, Trigger,
|
||||
UnserializableResource, WriteSignal,
|
||||
ScopeProperty, SerializableResource, SpecialNonReactiveZone, StoredValueId,
|
||||
Trigger, UnserializableResource, WriteSignal,
|
||||
};
|
||||
use cfg_if::cfg_if;
|
||||
use core::hash::BuildHasherDefault;
|
||||
@@ -358,6 +359,7 @@ impl Debug for Runtime {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the selected runtime from the thread-local set of runtimes. On the server,
|
||||
/// this will return the correct runtime. In the browser, there should only be one runtime.
|
||||
#[cfg_attr(
|
||||
@@ -472,6 +474,43 @@ impl RuntimeId {
|
||||
ret
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
any(debug_assertions, features = "ssr"),
|
||||
instrument(level = "trace", skip_all,)
|
||||
)]
|
||||
#[inline(always)]
|
||||
pub(crate) fn untrack<T>(
|
||||
self,
|
||||
f: impl FnOnce() -> T,
|
||||
diagnostics: bool,
|
||||
) -> T {
|
||||
with_runtime(self, |runtime| {
|
||||
let untracked_result;
|
||||
|
||||
if !diagnostics {
|
||||
SpecialNonReactiveZone::enter();
|
||||
}
|
||||
|
||||
let prev_observer =
|
||||
SetObserverOnDrop(self, runtime.observer.take());
|
||||
|
||||
untracked_result = f();
|
||||
|
||||
runtime.observer.set(prev_observer.1);
|
||||
std::mem::forget(prev_observer); // avoid Drop
|
||||
|
||||
if !diagnostics {
|
||||
SpecialNonReactiveZone::exit();
|
||||
}
|
||||
|
||||
untracked_result
|
||||
})
|
||||
.expect(
|
||||
"tried to run untracked function in a runtime that has been \
|
||||
disposed",
|
||||
)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
#[inline(always)] // only because it's placed here to fit in with the other create methods
|
||||
pub(crate) fn create_trigger(self) -> Trigger {
|
||||
@@ -681,6 +720,81 @@ impl RuntimeId {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn watch<W, T>(
|
||||
self,
|
||||
deps: impl Fn() -> W + 'static,
|
||||
callback: impl Fn(&W, Option<&W>, Option<T>) -> T + Clone + 'static,
|
||||
immediate: bool,
|
||||
) -> (NodeId, impl Fn() + Clone)
|
||||
where
|
||||
W: Clone + 'static,
|
||||
T: 'static,
|
||||
{
|
||||
let cur_deps_value = Rc::new(RefCell::new(None::<W>));
|
||||
let prev_deps_value = Rc::new(RefCell::new(None::<W>));
|
||||
let prev_callback_value = Rc::new(RefCell::new(None::<T>));
|
||||
|
||||
let wrapped_callback = {
|
||||
let cur_deps_value = Rc::clone(&cur_deps_value);
|
||||
let prev_deps_value = Rc::clone(&prev_deps_value);
|
||||
let prev_callback_value = Rc::clone(&prev_callback_value);
|
||||
|
||||
move || {
|
||||
callback(
|
||||
cur_deps_value.borrow().as_ref().expect(
|
||||
"this will not be called before there is deps value",
|
||||
),
|
||||
prev_deps_value.borrow().as_ref(),
|
||||
prev_callback_value.take(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let effect_fn = {
|
||||
let prev_callback_value = Rc::clone(&prev_callback_value);
|
||||
|
||||
move |did_run_before: Option<()>| {
|
||||
let deps_value = deps();
|
||||
|
||||
let did_run_before = did_run_before.is_some();
|
||||
|
||||
if !immediate && !did_run_before {
|
||||
prev_deps_value.replace(Some(deps_value));
|
||||
return;
|
||||
}
|
||||
|
||||
cur_deps_value.replace(Some(deps_value.clone()));
|
||||
|
||||
let callback_value =
|
||||
Some(self.untrack(wrapped_callback.clone(), false));
|
||||
|
||||
prev_callback_value.replace(callback_value);
|
||||
|
||||
prev_deps_value.replace(Some(deps_value));
|
||||
}
|
||||
};
|
||||
|
||||
let id = self.create_concrete_effect(
|
||||
Rc::new(RefCell::new(None::<()>)),
|
||||
Rc::new(Effect {
|
||||
f: effect_fn,
|
||||
ty: PhantomData,
|
||||
#[cfg(any(debug_assertions, feature = "ssr"))]
|
||||
defined_at: std::panic::Location::caller(),
|
||||
}),
|
||||
);
|
||||
|
||||
(id, move || {
|
||||
with_runtime(self, |runtime| {
|
||||
runtime.nodes.borrow_mut().remove(id);
|
||||
runtime.node_sources.borrow_mut().remove(id);
|
||||
})
|
||||
.expect(
|
||||
"tried to stop a watch in a runtime that has been disposed",
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
#[inline(always)]
|
||||
pub(crate) fn create_memo<T>(
|
||||
@@ -828,3 +942,13 @@ impl std::hash::Hash for Runtime {
|
||||
std::ptr::hash(&self, state);
|
||||
}
|
||||
}
|
||||
|
||||
struct SetObserverOnDrop(RuntimeId, Option<NodeId>);
|
||||
|
||||
impl Drop for SetObserverOnDrop {
|
||||
fn drop(&mut self) {
|
||||
_ = with_runtime(self.0, |rt| {
|
||||
rt.observer.set(self.1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ use crate::{
|
||||
node::NodeId,
|
||||
runtime::{with_runtime, RuntimeId},
|
||||
suspense::StreamChunk,
|
||||
PinnedFuture, ResourceId, SpecialNonReactiveZone, StoredValueId,
|
||||
SuspenseContext,
|
||||
PinnedFuture, ResourceId, StoredValueId, SuspenseContext,
|
||||
};
|
||||
use futures::stream::FuturesUnordered;
|
||||
use std::{
|
||||
@@ -209,37 +208,14 @@ impl Scope {
|
||||
)]
|
||||
#[inline(always)]
|
||||
pub fn untrack<T>(&self, f: impl FnOnce() -> T) -> T {
|
||||
with_runtime(self.runtime, |runtime| {
|
||||
let untracked_result;
|
||||
|
||||
SpecialNonReactiveZone::enter();
|
||||
|
||||
let prev_observer =
|
||||
SetObserverOnDrop(self.runtime, runtime.observer.take());
|
||||
|
||||
untracked_result = f();
|
||||
|
||||
runtime.observer.set(prev_observer.1);
|
||||
std::mem::forget(prev_observer); // avoid Drop
|
||||
|
||||
SpecialNonReactiveZone::exit();
|
||||
|
||||
untracked_result
|
||||
})
|
||||
.expect(
|
||||
"tried to run untracked function in a runtime that has been \
|
||||
disposed",
|
||||
)
|
||||
self.runtime.untrack(f, false)
|
||||
}
|
||||
}
|
||||
|
||||
struct SetObserverOnDrop(RuntimeId, Option<NodeId>);
|
||||
|
||||
impl Drop for SetObserverOnDrop {
|
||||
fn drop(&mut self) {
|
||||
_ = with_runtime(self.0, |rt| {
|
||||
rt.observer.set(self.1);
|
||||
});
|
||||
#[doc(hidden)]
|
||||
/// Suspends reactive tracking but keeps the diagnostic warnings for
|
||||
/// untracked functions.
|
||||
pub fn untrack_with_diagnostics<T>(&self, f: impl FnOnce() -> T) -> T {
|
||||
self.runtime.untrack(f, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +325,27 @@ impl Scope {
|
||||
any(debug_assertions, features = "ssr"),
|
||||
instrument(level = "trace", skip_all,)
|
||||
)]
|
||||
pub(crate) fn remove_scope_property(&self, prop: ScopeProperty) {
|
||||
_ = with_runtime(self.runtime, |runtime| {
|
||||
let scopes = runtime.scopes.borrow();
|
||||
|
||||
if let Some(scope) = scopes.get(self.id) {
|
||||
let mut scope = scope.borrow_mut();
|
||||
if let Some(index) = scope.iter().position(|p| p == &prop) {
|
||||
scope.swap_remove(index);
|
||||
}
|
||||
} else {
|
||||
console_warn(
|
||||
"tried to remove property to a scope that has been \
|
||||
disposed",
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg_attr(
|
||||
any(debug_assertions, features = "ssr"),
|
||||
instrument(level = "trace", skip_all,)
|
||||
)]
|
||||
/// Returns the the parent Scope, if any.
|
||||
pub fn parent(&self) -> Option<Scope> {
|
||||
match with_runtime(self.runtime, |runtime| {
|
||||
@@ -392,7 +389,7 @@ slotmap::new_key_type! {
|
||||
pub struct ScopeId;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
|
||||
pub(crate) enum ScopeProperty {
|
||||
Trigger(NodeId),
|
||||
Signal(NodeId),
|
||||
|
||||
@@ -68,6 +68,7 @@ use crate::{
|
||||
/// // setting name only causes name to log, not count
|
||||
/// set_name.set("Bob".into());
|
||||
/// ```
|
||||
#[track_caller]
|
||||
pub fn create_slice<T, O, S>(
|
||||
cx: Scope,
|
||||
signal: RwSignal<T>,
|
||||
@@ -85,6 +86,7 @@ where
|
||||
|
||||
/// Takes a memoized, read-only slice of a signal. This is equivalent to the
|
||||
/// read-only half of [`create_slice`].
|
||||
#[track_caller]
|
||||
pub fn create_read_slice<T, O>(
|
||||
cx: Scope,
|
||||
signal: RwSignal<T>,
|
||||
@@ -98,6 +100,7 @@ where
|
||||
|
||||
/// Creates a setter to access one slice of a signal. This is equivalent to the
|
||||
/// write-only half of [`create_slice`].
|
||||
#[track_caller]
|
||||
pub fn create_write_slice<T, O>(
|
||||
cx: Scope,
|
||||
signal: RwSignal<T>,
|
||||
|
||||
114
leptos_reactive/src/watch.rs
Normal file
114
leptos_reactive/src/watch.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use crate::{Scope, ScopeProperty};
|
||||
|
||||
/// A version of [`create_effect`] that listens to any dependency that is accessed inside `deps` and returns
|
||||
/// a stop handler.
|
||||
/// The return value of `deps` is passed into `callback` as an argument together with the previous value.
|
||||
/// Additionally the last return value of `callback` is provided as a third argument as is done in [`create_effect`].
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```
|
||||
/// # use leptos_reactive::*;
|
||||
/// # use log;
|
||||
/// # create_scope(create_runtime(), |cx| {
|
||||
/// let (num, set_num) = create_signal(cx, 0);
|
||||
///
|
||||
/// let stop = watch(
|
||||
/// cx,
|
||||
/// move || num.get(),
|
||||
/// move |num, prev_num, _| {
|
||||
/// log::debug!("Number: {}; Prev: {:?}", num, prev_num);
|
||||
/// },
|
||||
/// false,
|
||||
/// );
|
||||
///
|
||||
/// set_num.set(1); // > "Number: 1; Prev: Some(0)"
|
||||
///
|
||||
/// stop(); // stop watching
|
||||
///
|
||||
/// set_num.set(2); // (nothing happens)
|
||||
/// # }).dispose();
|
||||
/// ```
|
||||
///
|
||||
/// The callback itself doesn't track any signal that is accessed within it.
|
||||
///
|
||||
/// ```
|
||||
/// # use leptos_reactive::*;
|
||||
/// # use log;
|
||||
/// # create_scope(create_runtime(), |cx| {
|
||||
/// let (num, set_num) = create_signal(cx, 0);
|
||||
/// let (cb_num, set_cb_num) = create_signal(cx, 0);
|
||||
///
|
||||
/// watch(
|
||||
/// cx,
|
||||
/// move || num.get(),
|
||||
/// move |num, _, _| {
|
||||
/// log::debug!("Number: {}; Cb: {}", num, cb_num.get());
|
||||
/// },
|
||||
/// false,
|
||||
/// );
|
||||
///
|
||||
/// set_num.set(1); // > "Number: 1; Cb: 0"
|
||||
///
|
||||
/// set_cb_num.set(1); // (nothing happens)
|
||||
///
|
||||
/// set_num.set(2); // > "Number: 2; Cb: 1"
|
||||
/// # }).dispose();
|
||||
/// ```
|
||||
///
|
||||
/// ## Immediate
|
||||
///
|
||||
/// If the final parameter `immediate` is true, the `callback` will run immediately.
|
||||
/// If it's `false`, the `callback` will run only after
|
||||
/// the first change is detected of any signal that is accessed in `deps`.
|
||||
///
|
||||
/// ```
|
||||
/// # use leptos_reactive::*;
|
||||
/// # use log;
|
||||
/// # create_scope(create_runtime(), |cx| {
|
||||
/// let (num, set_num) = create_signal(cx, 0);
|
||||
///
|
||||
/// watch(
|
||||
/// cx,
|
||||
/// move || num.get(),
|
||||
/// move |num, prev_num, _| {
|
||||
/// log::debug!("Number: {}; Prev: {:?}", num, prev_num);
|
||||
/// },
|
||||
/// true,
|
||||
/// ); // > "Number: 0; Prev: None"
|
||||
///
|
||||
/// set_num.set(1); // > "Number: 1; Prev: Some(0)"
|
||||
/// # }).dispose();
|
||||
/// ```
|
||||
#[cfg_attr(
|
||||
any(debug_assertions, feature="ssr"),
|
||||
instrument(
|
||||
level = "trace",
|
||||
skip_all,
|
||||
fields(
|
||||
scope = ?cx.id,
|
||||
ty = %std::any::type_name::<T>()
|
||||
)
|
||||
)
|
||||
)]
|
||||
#[track_caller]
|
||||
#[inline(always)]
|
||||
pub fn watch<W, T>(
|
||||
cx: Scope,
|
||||
deps: impl Fn() -> W + 'static,
|
||||
callback: impl Fn(&W, Option<&W>, Option<T>) -> T + Clone + 'static,
|
||||
immediate: bool,
|
||||
) -> impl Fn() + Clone
|
||||
where
|
||||
W: Clone + 'static,
|
||||
T: 'static,
|
||||
{
|
||||
let (e, stop) = cx.runtime.watch(deps, callback, immediate);
|
||||
let prop = ScopeProperty::Effect(e);
|
||||
cx.push_scope_property(prop);
|
||||
|
||||
move || {
|
||||
stop();
|
||||
cx.remove_scope_property(prop);
|
||||
}
|
||||
}
|
||||
140
leptos_reactive/tests/watch.rs
Normal file
140
leptos_reactive/tests/watch.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use leptos_reactive::{
|
||||
create_runtime, create_scope, create_signal, watch, SignalGet, SignalSet,
|
||||
};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
#[test]
|
||||
fn watch_runs() {
|
||||
create_scope(create_runtime(), |cx| {
|
||||
let (a, set_a) = create_signal(cx, -1);
|
||||
|
||||
// simulate an arbitrary side effect
|
||||
let b = Rc::new(RefCell::new(String::new()));
|
||||
|
||||
let stop = watch(
|
||||
cx,
|
||||
move || a.get(),
|
||||
{
|
||||
let b = b.clone();
|
||||
|
||||
move |a, prev_a, prev_ret| {
|
||||
let formatted = format!(
|
||||
"Value is {}; Prev is {:?}; Prev return is {:?}",
|
||||
a, prev_a, prev_ret
|
||||
);
|
||||
*b.borrow_mut() = formatted;
|
||||
|
||||
a + 10
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(b.borrow().as_str(), "");
|
||||
|
||||
set_a.set(1);
|
||||
|
||||
assert_eq!(
|
||||
b.borrow().as_str(),
|
||||
"Value is 1; Prev is Some(-1); Prev return is None"
|
||||
);
|
||||
|
||||
set_a.set(2);
|
||||
|
||||
assert_eq!(
|
||||
b.borrow().as_str(),
|
||||
"Value is 2; Prev is Some(1); Prev return is Some(11)"
|
||||
);
|
||||
|
||||
stop();
|
||||
|
||||
*b.borrow_mut() = "nothing happened".to_string();
|
||||
set_a.set(3);
|
||||
|
||||
assert_eq!(b.borrow().as_str(), "nothing happened");
|
||||
})
|
||||
.dispose()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_runs_immediately() {
|
||||
create_scope(create_runtime(), |cx| {
|
||||
let (a, set_a) = create_signal(cx, -1);
|
||||
|
||||
// simulate an arbitrary side effect
|
||||
let b = Rc::new(RefCell::new(String::new()));
|
||||
|
||||
let _ = watch(
|
||||
cx,
|
||||
move || a.get(),
|
||||
{
|
||||
let b = b.clone();
|
||||
|
||||
move |a, prev_a, prev_ret| {
|
||||
let formatted = format!(
|
||||
"Value is {}; Prev is {:?}; Prev return is {:?}",
|
||||
a, prev_a, prev_ret
|
||||
);
|
||||
*b.borrow_mut() = formatted;
|
||||
|
||||
a + 10
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
b.borrow().as_str(),
|
||||
"Value is -1; Prev is None; Prev return is None"
|
||||
);
|
||||
|
||||
set_a.set(1);
|
||||
|
||||
assert_eq!(
|
||||
b.borrow().as_str(),
|
||||
"Value is 1; Prev is Some(-1); Prev return is Some(9)"
|
||||
);
|
||||
})
|
||||
.dispose()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_ignores_callback() {
|
||||
create_scope(create_runtime(), |cx| {
|
||||
let (a, set_a) = create_signal(cx, -1);
|
||||
let (b, set_b) = create_signal(cx, 0);
|
||||
|
||||
// simulate an arbitrary side effect
|
||||
let s = Rc::new(RefCell::new(String::new()));
|
||||
|
||||
let _ = watch(
|
||||
cx,
|
||||
move || a.get(),
|
||||
{
|
||||
let s = s.clone();
|
||||
|
||||
move |a, _, _| {
|
||||
let formatted =
|
||||
format!("Value a is {}; Value b is {}", a, b.get());
|
||||
*s.borrow_mut() = formatted;
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
set_a.set(1);
|
||||
|
||||
assert_eq!(s.borrow().as_str(), "Value a is 1; Value b is 0");
|
||||
|
||||
*s.borrow_mut() = "nothing happened".to_string();
|
||||
|
||||
set_b.set(10);
|
||||
|
||||
assert_eq!(s.borrow().as_str(), "nothing happened");
|
||||
|
||||
set_a.set(2);
|
||||
|
||||
assert_eq!(s.borrow().as_str(), "Value a is 2; Value b is 10");
|
||||
})
|
||||
.dispose()
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_meta"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -12,7 +12,7 @@ cfg-if = "1"
|
||||
leptos = { workspace = true }
|
||||
tracing = "0.1"
|
||||
wasm-bindgen = "0.2"
|
||||
indexmap = "1"
|
||||
indexmap = "2"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "leptos_router"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
authors = ["Greg Johnston"]
|
||||
license = "MIT"
|
||||
@@ -17,6 +17,7 @@ gloo-net = { version = "0.2", features = ["http"] }
|
||||
lazy_static = "1"
|
||||
linear-map = { version = "1", features = ["serde_impl"] }
|
||||
log = "0.4"
|
||||
once_cell = "1.18"
|
||||
regex = { version = "1", optional = true }
|
||||
url = { version = "2", optional = true }
|
||||
percent-encoding = "2"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user