Compare commits

..

26 Commits

Author SHA1 Message Date
Greg Johnston
8943539043 docs: clarify WASM target 2023-07-09 17:05:37 -04:00
Joseph Cruz
f6a272498d test(counters_stable/wasm): enter count (#1307) 2023-07-08 12:07:11 -04:00
Ari Seyhun
aef7c4ce8e perf: use lazy thread local for regex in router match_optionals (#1309) 2023-07-08 08:47:52 -04:00
Greg Johnston
b29eb8e032 fix: <ActionForm/> should check origin correctly before doing a full-page refresh (#1304) 2023-07-08 08:00:48 -04:00
Greg Johnston
da9183f4b5 docs: fix braces in <Show/> example (#1303) 2023-07-08 06:42:27 -04:00
Greg Johnston
ae3ddcb0e6 docs: must use View (#1302) 2023-07-08 06:42:02 -04:00
Greg Johnston
c6b8f0e8ed v0.4.2 2023-07-07 15:34:56 -04:00
g-re-g
bab9f40a81 fix: rework diff functionality for <For/> (#1296) 2023-07-07 15:32:26 -04:00
webmstk
c2cfdf3678 docs: fixed typo in parent-child doc (#1300) 2023-07-07 13:59:08 -04:00
Joseph Cruz
8967eadc02 test(counters_stable): add wasm testing (#1278)
* test(counters_stable/wasm): view counters > counts

* test(counters_stable/wasm): view counters > title

* test(counters_stable/wasm): add counter

* test(counters_stable/wasm): add 1k counters

* clear(counters_stable/wasm): clear counters

* test(counters_stable/wasm): increment counters

* test(counters_stable/wasm): decrement counter

* test(counters_stable/wasm): remove counter
2023-07-07 13:07:27 -04:00
Greg Johnston
4cc65f837f chore: add mdbook in flake (#1299)
* nix-flake: use follows for `rust-overlay` (..)

This removes the extra nixpkgs dependency by re-using the nixpkgs
already defined.

https://nixos.wiki/wiki/Flakes
https://web.archive.org/web/20230621091703/https://nixos.wiki/wiki/Flakes

> # The `follows` keyword in inputs is used for inheritance.
> # Here, `inputs.nixpkgs` of sops-nix is kept consistent with the `inputs.nixpkgs` of
> # the current flake, to avoid problems caused by different versions of nixpkgs.

* nix-flake: use nix overlays for rustc/cargo (..)

This allows the same nightly rust version to be used across the
flake (vs. strictly in the `devShell`).

* nix-flake: add `mdbook` to the development shell (..)

bumped `nixpkgs` to the latest unstable to pull in mdbook version `0.4.30`.

* nix-flake: expose all rust tools in dev environment (..)

The `oxalica / rust-overlay` docs expose all tools in the development
environment, so we should do the same:

https://github.com/oxalica/rust-overlay#use-in-devshell-for-nix-develop

---------

Co-authored-by: Jay Querie <jay@querie.cc>
2023-07-07 12:59:18 -04:00
Dessalines
22706e7371 docs: adding instructions to add a tailwind plugin to examples. (#1293) 2023-07-07 12:35:24 -04:00
webmstk
9f9302662c docs/examples: make error handling example more obvious for Chrome users (#1292) 2023-07-07 12:34:57 -04:00
Greg Johnston
6b90e1babd examples: add 404 support in Actix examples (closes #1031) (#1291) 2023-07-06 10:35:37 -04:00
sjud
7e540a8f49 feat: support Axum extractors with state other than () (#1275)
This requires state to be provided via context using a special handler, but allows for extractors that use this state, rather than only `()`, as previously.
2023-07-05 20:40:29 -04:00
Greg Johnston
f06ffd72aa fix: use once_cell::OnceCell rather than std::OnceCell (#1288) 2023-07-05 17:16:51 -04:00
Greg Johnston
83d3d7579c fix: issue with class hydration not removing classes correctly (closes #1286) (#1287) 2023-07-05 12:00:27 -04:00
Greg Johnston
39edb6eb45 fix: untracked read in <Redirect/> (#1280) 2023-07-04 11:52:13 -04:00
Greg Johnston
d81c1a929e fix: duplicate text nodes during <For/> hydration (closes #1279) (#1281) 2023-07-04 11:50:59 -04:00
Greg Johnston
f69c28df18 fix: improved diagnostics about non-reactive signal access (#1277) 2023-07-03 19:37:15 -04:00
Greg Johnston
66f54e7f1a docs: add docs on responses/redirects and clarification re: Axum State(_) extractors (#1272) 2023-07-03 09:58:02 -04:00
Greg Johnston
81e416b085 fix: error messages in dyn_classes (#1270) 2023-07-03 09:57:50 -04:00
Marc-Stefan Cassola
a5f73b441c feat: added watch helper (#1262) 2023-07-03 09:29:40 -04:00
Greg Johnston
0f1ebccad5 fix: clearing <For/> that has a previous sibling in release mode (fixes #1258) (#1267) 2023-07-02 17:27:39 -04:00
Greg Johnston
2f01df6185 fix: HtmlElement::dyn_classes() when adding classes (#1265) 2023-07-02 17:27:24 -04:00
martin frances
c4982319fe chore: ran cargo clippy --fix and reviewed changes. (#1259) 2023-07-02 17:27:14 -04:00
58 changed files with 1435 additions and 851 deletions

View File

@@ -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

View File

@@ -25,9 +25,14 @@ cargo init leptos-tutorial
> ```bash
> rustup toolchain install nightly
> rustup default nightly
> rustup target add wasm32-unknown-unknown
> ```
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
rustup target add wasm32-unknown-unknown
```
`cd` into your new `leptos-tutorial` project and add `leptos` as a dependency
```bash

View File

@@ -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.

View File

@@ -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.
Heres 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.

View File

@@ -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/>

View File

@@ -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,

View File

@@ -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>
}

View File

@@ -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]

View File

@@ -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> }
}

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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" },
]

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -12,6 +12,5 @@ test.describe("Decrement Count", () => {
await ui.decrementCount();
await expect(ui.total).toHaveText("-3");
await expect(ui.counters).toHaveText("1");
});
});

View File

@@ -26,6 +26,5 @@ test.describe("Enter Count", () => {
await ui.enterCount("50", 1);
await expect(ui.total).toHaveText("250");
await expect(ui.counters).toHaveText("3");
});
});

View File

@@ -12,6 +12,5 @@ test.describe("Increment Count", () => {
await ui.incrementCount();
await expect(ui.total).toHaveText("3");
await expect(ui.counters).toHaveText("1");
});
});

View File

@@ -12,7 +12,6 @@ test.describe("Remove Counter", () => {
await ui.removeCounter(1);
await expect(ui.total).toHaveText("0");
await expect(ui.counters).toHaveText("2");
});
});

View 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>
}
}

View File

@@ -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 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 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>{value}</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>
}
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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();
}
}

View File

@@ -0,0 +1 @@
pub mod counters_page;

View 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);
}

View 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);

View 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);
}

View 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)");
}

View File

@@ -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.

View File

@@ -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};

View File

@@ -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> }
}

View File

@@ -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

View File

@@ -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`

View File

@@ -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

View File

@@ -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
View File

@@ -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,

View File

@@ -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" ];
}))

View File

@@ -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)]

View File

@@ -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>
}
}

View File

@@ -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);

View File

@@ -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()
}
);
}
}

View File

@@ -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:#?}"
)
});

View File

@@ -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),

View File

@@ -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

View File

@@ -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 {

View File

@@ -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);
});
}
}

View File

@@ -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),

View File

@@ -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>,

View 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);
}
}

View 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()
}

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_meta"
version = "0.4.0"
version = "0.4.2"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"

View File

@@ -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"

View File

@@ -143,10 +143,7 @@ where
match Url::try_from(resp_url.as_str()) {
Ok(url) => {
if url.origin
!= window()
.location()
.origin()
.unwrap_or_default()
!= current_window_origin()
{
_ = window()
.location()
@@ -227,10 +224,7 @@ where
match Url::try_from(resp_url.as_str()) {
Ok(url) => {
if url.origin
!= window()
.location()
.hostname()
.unwrap_or_default()
!= current_window_origin()
{
_ = window()
.location()
@@ -328,6 +322,20 @@ where
)
}
fn current_window_origin() -> String {
let location = window().location();
let protocol = location.protocol().unwrap_or_default();
let hostname = location.hostname().unwrap_or_default();
let port = location.port().unwrap_or_default();
format!(
"{}//{}{}{}",
protocol,
hostname,
if port.is_empty() { "" } else { ":" },
port
)
}
/// Automatically turns a server [Action](leptos_server::Action) into an HTML
/// [`form`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form)
/// progressively enhanced to use client-side routing.

View File

@@ -33,7 +33,7 @@ where
{
// resolve relative path
let path = use_resolved_path(cx, move || path.to_string());
let path = path.get().unwrap_or_else(|| "/".to_string());
let path = path.get_untracked().unwrap_or_else(|| "/".to_string());
// redirect on the server
if let Some(redirect_fn) = use_context::<ServerRedirectFunction>(cx) {

View File

@@ -3,14 +3,20 @@ use std::borrow::Cow;
#[doc(hidden)]
#[cfg(not(feature = "ssr"))]
pub fn expand_optionals(pattern: &str) -> Vec<Cow<str>> {
use js_sys::RegExp;
use once_cell::unsync::Lazy;
use wasm_bindgen::JsValue;
#[allow(non_snake_case)]
let OPTIONAL_RE = js_sys::RegExp::new(OPTIONAL, "");
#[allow(non_snake_case)]
let OPTIONAL_RE_2 = js_sys::RegExp::new(OPTIONAL_2, "");
thread_local! {
static OPTIONAL_RE: Lazy<RegExp> = Lazy::new(|| {
RegExp::new(OPTIONAL, "")
});
static OPTIONAL_RE_2: Lazy<RegExp> = Lazy::new(|| {
RegExp::new(OPTIONAL_2, "")
});
}
let captures = OPTIONAL_RE.exec(pattern);
let captures = OPTIONAL_RE.with(|re| re.exec(pattern));
match captures {
None => vec![pattern.into()],
Some(matched) => {
@@ -28,7 +34,7 @@ pub fn expand_optionals(pattern: &str) -> Vec<Cow<str>> {
prefixes.push(prefix.clone());
while let Some(matched) =
OPTIONAL_RE_2.exec(suffix.trim_start_matches('?'))
OPTIONAL_RE_2.with(|re| re.exec(suffix.trim_start_matches('?')))
{
prefix += &matched.get(1).as_string().unwrap();
prefixes.push(prefix.clone());