Compare commits

..

1 Commits
v0.7.8 ... 3509

Author SHA1 Message Date
Greg Johnston
50a1da08eb change: allow IntoFuture for Suspend::new() (closes #3509) 2025-01-31 12:33:26 -05:00
96 changed files with 882 additions and 2540 deletions

View File

@@ -18,7 +18,7 @@ jobs:
test:
needs: [get-leptos-changed]
if: needs.get-leptos-changed.outputs.leptos_changed == 'true' && github.event.pull_request.labels[0].name != 'breaking'
name: Run semver check (nightly-2025-03-05)
name: Run semver check (nightly-2024-08-01)
runs-on: ubuntu-latest
steps:
- name: Install Glib
@@ -30,4 +30,4 @@ jobs:
- name: Semver Checks
uses: obi1kenobi/cargo-semver-checks-action@v2
with:
rust-toolchain: nightly-2025-03-05
rust-toolchain: nightly-2024-08-01

View File

@@ -26,4 +26,4 @@ jobs:
with:
directory: ${{ matrix.directory }}
cargo_make_task: "ci"
toolchain: nightly-2025-03-05
toolchain: nightly-2024-08-01

View File

@@ -55,7 +55,7 @@ jobs:
- name: Install wasm-bindgen
run: cargo binstall wasm-bindgen-cli --no-confirm
- name: Install cargo-leptos
run: cargo binstall cargo-leptos --locked --no-confirm
run: cargo binstall cargo-leptos --no-confirm
- name: Install Trunk
uses: jetli/trunk-action@v0.5.0
with:

585
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@ members = [
exclude = ["benchmarks", "examples", "projects"]
[workspace.package]
version = "0.7.8"
version = "0.7.5"
edition = "2021"
rust-version = "1.76"
@@ -48,31 +48,28 @@ rust-version = "1.76"
throw_error = { path = "./any_error/", version = "0.2.0" }
any_spawner = { path = "./any_spawner/", version = "0.2.0" }
const_str_slice_concat = { path = "./const_str_slice_concat", version = "0.1" }
either_of = { path = "./either_of/", version = "0.1.5" }
either_of = { path = "./either_of/", version = "0.1.0" }
hydration_context = { path = "./hydration_context", version = "0.2.0" }
itertools = "0.14.0"
leptos = { path = "./leptos", version = "0.7.8" }
leptos_config = { path = "./leptos_config", version = "0.7.8" }
leptos_dom = { path = "./leptos_dom", version = "0.7.8" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.8" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.8" }
leptos_macro = { path = "./leptos_macro", version = "0.7.8" }
leptos_router = { path = "./router", version = "0.7.8" }
leptos_router_macro = { path = "./router_macro", version = "0.7.8" }
leptos_server = { path = "./leptos_server", version = "0.7.8" }
leptos_meta = { path = "./meta", version = "0.7.8" }
leptos = { path = "./leptos", version = "0.7.5" }
leptos_config = { path = "./leptos_config", version = "0.7.5" }
leptos_dom = { path = "./leptos_dom", version = "0.7.5" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.5" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.5" }
leptos_macro = { path = "./leptos_macro", version = "0.7.5" }
leptos_router = { path = "./router", version = "0.7.5" }
leptos_router_macro = { path = "./router_macro", version = "0.7.5" }
leptos_server = { path = "./leptos_server", version = "0.7.5" }
leptos_meta = { path = "./meta", version = "0.7.5" }
next_tuple = { path = "./next_tuple", version = "0.1.0" }
oco_ref = { path = "./oco", version = "0.2.0" }
or_poisoned = { path = "./or_poisoned", version = "0.1.0" }
reactive_graph = { path = "./reactive_graph", version = "0.1.7" }
reactive_stores = { path = "./reactive_stores", version = "0.1.7" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.7" }
serde_json = "1.0.0"
server_fn = { path = "./server_fn", version = "0.7.8" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.8" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.8" }
tachys = { path = "./tachys", version = "0.1.7" }
wasm-bindgen = { version = "0.2.100" }
reactive_graph = { path = "./reactive_graph", version = "0.1.5" }
reactive_stores = { path = "./reactive_stores", version = "0.1.3" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0" }
server_fn = { path = "./server_fn", version = "0.7.5" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.5" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.5" }
tachys = { path = "./tachys", version = "0.1.5" }
[profile.release]
codegen-units = 1

View File

@@ -21,7 +21,7 @@ use leptos::*;
#[component]
pub fn SimpleCounter(initial_value: i32) -> impl IntoView {
// create a reactive signal with the initial value
let (value, set_value) = signal(initial_value);
let (value, set_value) = create_signal(initial_value);
// create event handlers for our buttons
// note that `value` and `set_value` are `Copy`, so it's super easy to move them into closures
@@ -46,7 +46,7 @@ pub fn SimpleCounter(initial_value: i32) -> impl IntoView {
pub fn SimpleCounterWithBuilder(initial_value: i32) -> impl IntoView {
use leptos::html::*;
let (value, set_value) = signal(initial_value);
let (value, set_value) = create_signal(initial_value);
let clear = move |_| set_value(0);
let decrement = move |_| set_value.update(|value| *value -= 1);
let increment = move |_| set_value.update(|value| *value += 1);

View File

@@ -9,7 +9,7 @@ use std::{
error,
fmt::{self, Display},
future::Future,
ops,
mem, ops,
pin::Pin,
sync::Arc,
task::{Context, Poll},
@@ -109,7 +109,7 @@ pub fn get_error_hook() -> Option<Arc<dyn ErrorHook>> {
/// Sets the current thread-local error hook, which will be invoked when [`throw`] is called.
pub fn set_error_hook(hook: Arc<dyn ErrorHook>) -> ResetErrorHookOnDrop {
ResetErrorHookOnDrop(
ERROR_HOOK.with_borrow_mut(|this| Option::replace(this, hook)),
ERROR_HOOK.with_borrow_mut(|this| mem::replace(this, Some(hook))),
)
}

View File

@@ -17,7 +17,7 @@ tokio = { version = "1.41", optional = true, default-features = false, features
"rt",
] }
tracing = { version = "0.1.41", optional = true }
wasm-bindgen-futures = { version = "0.4.50", optional = true }
wasm-bindgen-futures = { version = "0.4.47", optional = true }
[features]
async-executor = ["dep:async-executor"]

View File

@@ -23,7 +23,7 @@ tokio-test = "0.4.0"
miniserde = "0.1.0"
gloo = "0.8.0"
uuid = { version = "1.0", features = ["serde", "v4", "wasm-bindgen"] }
wasm-bindgen = "0.2.100"
wasm-bindgen = "0.2.0"
lazy_static = "1.0"
log = "0.4.0"
strum = "0.24.0"

View File

@@ -1,6 +1,5 @@
[tasks.install-cargo-leptos]
install_crate = { crate_name = "cargo-leptos", binary = "cargo-leptos", test_arg = "--help" }
args = ["--locked"]
[tasks.cargo-leptos-e2e]
command = "cargo"

View File

@@ -25,7 +25,7 @@ pub fn RouterExample() -> impl IntoView {
// contexts are passed down through the route tree
provide_context(ExampleContext(0));
// this signal will be used to set whether we are allowed to access a protected route
// this signal will be ued to set whether we are allowed to access a protected route
let (logged_in, set_logged_in) = signal(true);
let (is_routing, set_is_routing) = signal(false);

View File

@@ -22,20 +22,20 @@ log = "0.4.22"
simple_logger = "5.0"
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.7.5", optional = true }
tower = { version = "0.5.2", optional = true }
tower-http = { version = "0.6.2", features = [
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = [
"fs",
"tracing",
"trace",
], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }
thiserror = "2.0.11"
thiserror = "1.0"
wasm-bindgen = "0.2.93"
serde_toml = "0.0.1"
toml = "0.8.19"
web-sys = { version = "0.3.70", features = ["FileList", "File"] }
strum = { version = "0.27.1", features = ["strum_macros", "derive"] }
notify = { version = "8.0", optional = true }
strum = { version = "0.26.3", features = ["strum_macros", "derive"] }
notify = { version = "6.1", optional = true }
pin-project-lite = "0.2.14"
dashmap = { version = "6.0", optional = true }
once_cell = { version = "1.19", optional = true }

View File

@@ -1,2 +1,2 @@
[toolchain]
channel = "nightly-2025-03-05"
channel = "nightly-2024-01-29"

View File

@@ -1,99 +0,0 @@
@check_instrumented_issue_3719
Feature: Using instrumented counters to test regression from #3502.
Check that the suspend/suspense and the underlying resources are
called with the expected number of times. If this was already in
place by #3502 (5c43c18) it should have caught this regression.
For a better minimum demonstration see #3719.
Background:
Given I see the app
And I select the mode Instrumented
Scenario: follow all paths via CSR avoids #3502
Given I select the following links
| Item Listing |
| Item 1 |
| Inspect path2 |
| Inspect path2/field3 |
And I click on Reset CSR Counters
When I select the following links
| Inspect path2/field1 |
| Inspect path2/field2 |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 0 |
| item_overview | 0 |
| item_inspect | 2 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 2 |
# To show that starting directly from within a param will simply
# cause the problem.
Scenario: Quicker way to demonstrate regression caused by #3502
Given I select the link Target 123
# And I click on Reset CSR Counters
When I select the following links
| Inspect path2/field1 |
| Inspect path2/field2 |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 0 |
| item_overview | 0 |
| item_inspect | 3 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 1 |
| get_item | 1 |
| inspect_item_root | 0 |
| inspect_item_field | 4 |
Scenario: Follow paths ordinarily down to a target
Given I select the following links
| Item Listing |
| Item 1 |
And I click on Reset CSR Counters
When I select the following links
| Target 4## |
| Target 3## |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 0 |
| item_overview | 2 |
| item_inspect | 0 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 2 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
Scenario: Same as above, but add a refresh to test hydration
Given I select the following links
| Item Listing |
| Item 1 |
And I refresh the page
And I click on Reset CSR Counters
When I select the following links
| Target 4## |
| Target 3## |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 0 |
| item_overview | 2 |
| item_inspect | 0 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 2 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |

View File

@@ -3,28 +3,12 @@ mod fixtures;
use anyhow::Result;
use cucumber::World;
use fixtures::world::AppWorld;
use std::{ffi::OsStr, fs::read_dir};
#[tokio::main]
async fn main() -> Result<()> {
// Normally the below is done, but it's now gotten to the point of
// having a sufficient number of tests where the resource contention
// of the concurrently running browsers will cause failures on CI.
// AppWorld::cucumber()
// .fail_on_skipped()
// .run_and_exit("./features")
// .await;
// Mitigate the issue by manually stepping through each feature,
// rather than letting cucumber glob them and dispatch all at once.
for entry in read_dir("./features")? {
let path = entry?.path();
if path.extension() == Some(OsStr::new("feature")) {
AppWorld::cucumber()
.fail_on_skipped()
.run_and_exit(path)
.await;
}
}
AppWorld::cucumber()
.fail_on_skipped()
.run_and_exit("./features")
.await;
Ok(())
}

View File

@@ -4,7 +4,7 @@ use leptos_router::{
hooks::use_params,
nested_router::Outlet,
params::Params,
ParamSegment, SsrMode, StaticSegment, WildcardSegment,
MatchNestedRoutes, ParamSegment, SsrMode, StaticSegment, WildcardSegment,
};
#[cfg(feature = "ssr")]
@@ -21,7 +21,6 @@ pub(super) mod counter {
pub struct Counter(AtomicU32);
impl Counter {
#[allow(dead_code)]
pub const fn new() -> Self {
Self(AtomicU32::new(0))
}
@@ -204,20 +203,20 @@ pub struct SuspenseCounters {
}
#[component]
pub fn InstrumentedRoutes() -> impl leptos_router::MatchNestedRoutes + Clone {
pub fn InstrumentedRoutes() -> impl MatchNestedRoutes + Clone {
// TODO should make this mode configurable via feature flag?
let ssr = SsrMode::Async;
view! {
<ParentRoute path=StaticSegment("instrumented") view=InstrumentedRoot ssr>
<Route path=StaticSegment("/") view=InstrumentedTop />
<Route path=StaticSegment("/") view=InstrumentedTop/>
<ParentRoute path=StaticSegment("item") view=ItemRoot>
<Route path=StaticSegment("/") view=ItemListing />
<Route path=StaticSegment("/") view=ItemListing/>
<ParentRoute path=ParamSegment("id") view=ItemTop>
<Route path=StaticSegment("/") view=ItemOverview />
<Route path=WildcardSegment("path") view=ItemInspect />
<Route path=StaticSegment("/") view=ItemOverview/>
<Route path=WildcardSegment("path") view=ItemInspect/>
</ParentRoute>
</ParentRoute>
<Route path=StaticSegment("counters") view=ShowCounters />
<Route path=StaticSegment("counters") view=ShowCounters/>
</ParentRoute>
}
.into_inner()
@@ -280,41 +279,32 @@ fn InstrumentedRoot() -> impl IntoView {
<section id="instrumented">
<nav>
<a href="/">"Site Root"</a>
<A href="./" exact=true>
"Instrumented Root"
</A>
<A href="item/" strict_trailing_slash=true>
"Item Listing"
</A>
<A href="counters" strict_trailing_slash=true>
"Counters"
</A>
<A href="./" exact=true>"Instrumented Root"</A>
<A href="item/" strict_trailing_slash=true>"Item Listing"</A>
<A href="counters" strict_trailing_slash=true>"Counters"</A>
</nav>
<FieldNavPortlet />
<Outlet />
<Suspense>
{move || Suspend::new(async move {
<FieldNavPortlet/>
<Outlet/>
<Suspense>{
move || Suspend::new(async move {
let clear_suspense_counters = move |_| {
counters.update(|c| *c = SuspenseCounters::default());
};
csr_ticket
.get()
.map(|ticket| {
let ticket = ticket.0;
view! {
<ActionForm action=reset_counters>
<input type="hidden" name="ticket" value=format!("{ticket}") />
<input
id="reset-csr-counters"
type="submit"
value="Reset CSR Counters"
on:click=clear_suspense_counters
/>
</ActionForm>
}
})
})}
</Suspense>
csr_ticket.get().map(|ticket| {
let ticket = ticket.0;
view! {
<ActionForm action=reset_counters>
<input type="hidden" name="ticket" value=format!("{ticket}") />
<input
id="reset-csr-counters"
type="submit"
value="Reset CSR Counters"
on:click=clear_suspense_counters/>
</ActionForm>
}
})
})
}</Suspense>
<footer>
<nav>
<A href="item/3/">"Target 3##"</A>
@@ -333,17 +323,11 @@ fn InstrumentedRoot() -> impl IntoView {
fn InstrumentedTop() -> impl IntoView {
view! {
<h1>"Instrumented Tests"</h1>
<p>
"These tests validates the number of invocations of server functions and suspenses per access."
</p>
<p>"These tests validates the number of invocations of server functions and suspenses per access."</p>
<ul>
// not using `A` because currently some bugs with artix
<li>
<a href="item/">"Item Listing"</a>
</li>
<li>
<a href="item/4/path1/">"Target 41#"</a>
</li>
<li><a href="item/">"Item Listing"</a></li>
<li><a href="item/4/path1/">"Target 41#"</a></li>
</ul>
}
}
@@ -358,7 +342,7 @@ fn ItemRoot() -> impl IntoView {
view! {
<h2>"<ItemRoot/>"</h2>
<Outlet />
<Outlet/>
}
}
@@ -376,9 +360,7 @@ fn ItemListing() -> impl IntoView {
// adding an extra `/` in artix; manually construct `a` instead.
// <li><A href=format!("./{item}/")>"Item "{item}</A></li>
view! {
<li>
<a href=format!("/instrumented/item/{item}/")>"Item "{item}</a>
</li>
<li><a href=format!("/instrumented/item/{item}/")>"Item "{item}</a></li>
}
)
.collect_view()
@@ -391,7 +373,9 @@ fn ItemListing() -> impl IntoView {
view! {
<h3>"<ItemListing/>"</h3>
<ul>
<Suspense>{item_listing}</Suspense>
<Suspense>
{item_listing}
</Suspense>
</ul>
}
}
@@ -418,7 +402,7 @@ fn ItemTop() -> impl IntoView {
));
view! {
<h4>"<ItemTop/>"</h4>
<Outlet />
<Outlet/>
}
}
@@ -428,29 +412,24 @@ fn ItemOverview() -> impl IntoView {
let resource = expect_context::<Resource<Option<GetItemResult>>>();
let item_view = move || {
Suspend::new(async move {
let result = resource.await.map(|GetItemResult(item, names)| {
view! {
<p>{format!("Viewing {item:?}")}</p>
<ul>
{names
.into_iter()
.map(|name| {
let id = item.id;
// FIXME seems like relative link isn't working, it is currently
// adding an extra `/` in artix; manually construct `a` instead.
// <li><A href=format!("./{name}/")>{format!("Inspect {name}")}</A></li>
view! {
<li>
<a href=format!(
"/instrumented/item/{id}/{name}/",
)>"Inspect "{name.clone()}</a>
</li>
}
})
.collect_view()}
</ul>
}
});
let result = resource.await.map(|GetItemResult(item, names)| view! {
<p>{format!("Viewing {item:?}")}</p>
<ul>{
names.into_iter()
.map(|name| {
// FIXME seems like relative link isn't working, it is currently
// adding an extra `/` in artix; manually construct `a` instead.
// <li><A href=format!("./{name}/")>{format!("Inspect {name}")}</A></li>
let id = item.id;
view! {
<li><a href=format!("/instrumented/item/{id}/{name}/")>
"Inspect "{name.clone()}
</a></li>
}
})
.collect_view()
}</ul>
});
suspense_counters.update_untracked(|c| c.item_overview += 1);
result
})
@@ -458,7 +437,9 @@ fn ItemOverview() -> impl IntoView {
view! {
<h5>"<ItemOverview/>"</h5>
<Suspense>{item_view}</Suspense>
<Suspense>
{item_view}
</Suspense>
}
}
@@ -492,9 +473,8 @@ fn ItemInspect() -> impl IntoView {
// result
},
);
let ws = use_context::<WriteSignal<Option<FieldNavCtx>>>();
on_cleanup(move || {
if let Some(c) = ws {
on_cleanup(|| {
if let Some(c) = use_context::<WriteSignal<Option<FieldNavCtx>>>() {
c.set(None);
}
});
@@ -516,26 +496,23 @@ fn ItemInspect() -> impl IntoView {
));
view! {
<p>{format!("Inspecting {item:?}")}</p>
<ul>
{fields
.iter()
<ul>{
fields.iter()
.map(|field| {
// FIXME seems like relative link to root for a wildcard isn't
// working as expected, so manually construct `a` instead.
// let text = format!("Inspect {name}/{field}");
// view! {
// <li><A href=format!("{field}")>{text}</A></li>
// <li><A href=format!("{field}")>{text}</A></li>
// }
view! {
<li>
<a href=format!(
"/instrumented/item/{id}/{name}/{field}",
)>{format!("Inspect {name}/{field}")}</a>
</li>
<li><a href=format!("/instrumented/item/{id}/{name}/{field}")>{
format!("Inspect {name}/{field}")
}</a></li>
}
})
.collect_view()}
</ul>
.collect_view()
}</ul>
}
});
suspense_counters.update_untracked(|c| c.item_inspect += 1);
@@ -550,7 +527,9 @@ fn ItemInspect() -> impl IntoView {
view! {
<h5>"<ItemInspect/>"</h5>
<Suspense>{inspect_view}</Suspense>
<Suspense>
{inspect_view}
</Suspense>
}
}
@@ -611,8 +590,7 @@ fn ShowCounters() -> impl IntoView {
id="reset-counters"
type="submit"
value="Reset Counters"
on:click=clear_suspense_counters
/>
on:click=clear_suspense_counters/>
</ActionForm>
}
})
@@ -623,23 +601,20 @@ fn ShowCounters() -> impl IntoView {
<h2>"Counters"</h2>
<h3 id="suspend-calls">"Suspend Calls"</h3>
{move || {
suspense_counters
.with(|c| {
view! {
<dl>
<dt>"item_listing"</dt>
<dd id="item_listing">{c.item_listing}</dd>
<dt>"item_overview"</dt>
<dd id="item_overview">{c.item_overview}</dd>
<dt>"item_inspect"</dt>
<dd id="item_inspect">{c.item_inspect}</dd>
</dl>
}
})
}}
{move || suspense_counters.with(|c| view! {
<dl>
<dt>"item_listing"</dt>
<dd id="item_listing">{c.item_listing}</dd>
<dt>"item_overview"</dt>
<dd id="item_overview">{c.item_overview}</dd>
<dt>"item_inspect"</dt>
<dd id="item_inspect">{c.item_inspect}</dd>
</dl>
})}
<Suspense>{counter_view}</Suspense>
<Suspense>
{counter_view}
</Suspense>
}
}
@@ -667,17 +642,17 @@ pub fn FieldNavPortlet() -> impl IntoView {
view! {
<div id="FieldNavPortlet">
<span>"FieldNavPortlet:"</span>
<nav>
{ctx
.0
.map(|ctx| {
ctx.into_iter()
.map(|FieldNavItem { href, text }| {
view! { <A href=href>{text}</A> }
})
.collect_view()
})}
</nav>
<nav>{
ctx.0.map(|ctx| {
ctx.into_iter()
.map(|FieldNavItem { href, text }| {
view! {
<A href=href>{text}</A>
}
})
.collect_view()
})
}</nav>
</div>
}
})

View File

@@ -1 +1,3 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1 +1,3 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -14,7 +14,7 @@ throw_error = { workspace = true }
or_poisoned = { workspace = true }
futures = "0.3.31"
serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = { version = "0.2.100", optional = true }
wasm-bindgen = { version = "0.2.97", optional = true }
js-sys = { version = "0.3.74", optional = true }
once_cell = "1.20"
pin-project-lite = "0.2.15"

View File

@@ -21,10 +21,10 @@ leptos_macro = { workspace = true, features = ["actix"] }
leptos_meta = { workspace = true, features = ["nonce"] }
leptos_router = { workspace = true, features = ["ssr"] }
server_fn = { workspace = true, features = ["actix"] }
serde_json = { workspace = true }
serde_json = "1.0"
parking_lot = "0.12.3"
tracing = { version = "0.1", optional = true }
tokio = { version = "1.43", features = ["rt", "fs"] }
tokio = { version = "1.41", features = ["rt", "fs"] }
send_wrapper = "0.6.0"
dashmap = "6"
once_cell = "1"

View File

@@ -274,13 +274,14 @@ pub fn redirect(path: &str) {
///
/// This can then be set up at an appropriate route in your application:
///
/// ```no_run
/// ```
/// use actix_web::*;
///
/// fn register_server_functions() {
/// // call ServerFn::register() for each of the server functions you've defined
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// // make sure you actually register your server functions
@@ -296,6 +297,7 @@ pub fn redirect(path: &str) {
/// .run()
/// .await
/// }
/// # }
/// ```
///
/// ## Provided Context Types
@@ -431,7 +433,7 @@ pub fn handle_server_fns_with_context(
/// but requires some client-side JavaScript.
///
/// This can then be set up at an appropriate route in your application:
/// ```no_run
/// ```
/// use actix_web::{App, HttpServer};
/// use leptos::prelude::*;
/// use leptos_router::Method;
@@ -442,6 +444,7 @@ pub fn handle_server_fns_with_context(
/// view! { <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let conf = get_configuration(Some("Cargo.toml")).unwrap();
@@ -461,6 +464,7 @@ pub fn handle_server_fns_with_context(
/// .run()
/// .await
/// }
/// # }
/// ```
///
/// ## Provided Context Types
@@ -488,7 +492,7 @@ where
/// sending down its HTML. The app will become interactive once it has fully loaded.
///
/// This can then be set up at an appropriate route in your application:
/// ```no_run
/// ```
/// use actix_web::{App, HttpServer};
/// use leptos::prelude::*;
/// use leptos_router::Method;
@@ -499,6 +503,7 @@ where
/// view! { <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let conf = get_configuration(Some("Cargo.toml")).unwrap();
@@ -521,6 +526,7 @@ where
/// .run()
/// .await
/// }
/// # }
/// ```
///
/// ## Provided Context Types
@@ -546,7 +552,7 @@ where
/// `async` resources have loaded.
///
/// This can then be set up at an appropriate route in your application:
/// ```no_run
/// ```
/// use actix_web::{App, HttpServer};
/// use leptos::prelude::*;
/// use leptos_router::Method;
@@ -557,6 +563,7 @@ where
/// view! { <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[actix_web::main]
/// async fn main() -> std::io::Result<()> {
/// let conf = get_configuration(Some("Cargo.toml")).unwrap();
@@ -576,6 +583,7 @@ where
/// .run()
/// .await
/// }
/// # }
/// ```
///
/// ## Provided Context Types

View File

@@ -24,14 +24,14 @@ leptos_router = { workspace = true, features = ["ssr"] }
leptos_integration_utils = { workspace = true }
once_cell = "1"
parking_lot = "0.12.3"
tokio = { version = "1.43", default-features = false }
tokio = { version = "1.41", default-features = false }
tower = { version = "0.5.1", features = ["util"] }
tower-http = "0.6.2"
tracing = { version = "0.1.41", optional = true }
[dev-dependencies]
axum = "0.7.9"
tokio = { version = "1.43", features = ["net", "rt-multi-thread"] }
tokio = { version = "1.41", features = ["net", "rt-multi-thread"] }
[features]
wasm = []

View File

@@ -1,6 +1,5 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![allow(clippy::type_complexity)]
//! Provides functions to easily integrate Leptos with Axum.
//!
@@ -279,11 +278,12 @@ pub fn generate_request_and_parts(
///
/// This can then be set up at an appropriate route in your application:
///
/// ```no_run
/// ```
/// use axum::{handler::Handler, routing::post, Router};
/// use leptos::prelude::*;
/// use std::net::SocketAddr;
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[cfg(feature = "default")]
/// #[tokio::main]
/// async fn main() {
@@ -299,9 +299,7 @@ pub fn generate_request_and_parts(
/// .await
/// .unwrap();
/// }
///
/// # #[cfg(not(feature = "default"))]
/// # fn main() { }
/// # }
/// ```
/// Leptos provides a generic implementation of `handle_server_fns`. If access to more specific parts of the Request is desired,
/// you can specify your own server fn handler based on this one and give it it's own route in the server macro.
@@ -444,7 +442,7 @@ pub type PinnedHtmlStream =
/// to route it using [leptos_router], serving an HTML stream of your application.
///
/// This can then be set up at an appropriate route in your application:
/// ```no_run
/// ```
/// use axum::{handler::Handler, Router};
/// use leptos::{config::get_configuration, prelude::*};
/// use std::{env, net::SocketAddr};
@@ -454,6 +452,7 @@ pub type PinnedHtmlStream =
/// view! { <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[cfg(feature = "default")]
/// #[tokio::main]
/// async fn main() {
@@ -472,9 +471,7 @@ pub type PinnedHtmlStream =
/// .await
/// .unwrap();
/// }
///
/// # #[cfg(not(feature = "default"))]
/// # fn main() { }
/// # }
/// ```
///
/// ## Provided Context Types
@@ -533,7 +530,7 @@ where
/// sending down its HTML. The app will become interactive once it has fully loaded.
///
/// This can then be set up at an appropriate route in your application:
/// ```no_run
/// ```
/// use axum::{handler::Handler, Router};
/// use leptos::{config::get_configuration, prelude::*};
/// use std::{env, net::SocketAddr};
@@ -543,6 +540,7 @@ where
/// view! { <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[cfg(feature = "default")]
/// #[tokio::main]
/// async fn main() {
@@ -561,9 +559,7 @@ where
/// .await
/// .unwrap();
/// }
///
/// # #[cfg(not(feature = "default"))]
/// # fn main() { }
/// # }
/// ```
///
/// ## Provided Context Types
@@ -941,7 +937,7 @@ fn provide_contexts(
/// `async` resources have loaded.
///
/// This can then be set up at an appropriate route in your application:
/// ```no_run
/// ```
/// use axum::{handler::Handler, Router};
/// use leptos::{config::get_configuration, prelude::*};
/// use std::{env, net::SocketAddr};
@@ -951,6 +947,7 @@ fn provide_contexts(
/// view! { <main>"Hello, world!"</main> }
/// }
///
/// # if false { // don't actually try to run a server in a doctest...
/// #[cfg(feature = "default")]
/// #[tokio::main]
/// async fn main() {
@@ -970,9 +967,7 @@ fn provide_contexts(
/// .await
/// .unwrap();
/// }
///
/// # #[cfg(not(feature = "default"))]
/// # fn main() { }
/// # }
/// ```
///
/// ## Provided Context Types
@@ -1981,67 +1976,6 @@ where
.map_err(|e| ServerFnError::ServerError(format!("{e:?}")))
}
/// A reasonable handler for serving static files (like JS/WASM/CSS) and 404 errors.
///
/// This is provided as a convenience, but is a fairly simple function. If you need to adapt it,
/// simply reuse the source code of this function in your own application.
#[cfg(feature = "default")]
pub fn file_and_error_handler_with_context<S, IV>(
additional_context: impl Fn() + 'static + Clone + Send,
shell: fn(LeptosOptions) -> IV,
) -> impl Fn(
Uri,
State<S>,
Request<Body>,
) -> Pin<Box<dyn Future<Output = Response<Body>> + Send + 'static>>
+ Clone
+ Send
+ 'static
where
IV: IntoView + 'static,
S: Send + Sync + Clone + 'static,
LeptosOptions: FromRef<S>,
{
move |uri: Uri, State(state): State<S>, req: Request<Body>| {
Box::pin({
let additional_context = additional_context.clone();
async move {
let options = LeptosOptions::from_ref(&state);
let res =
get_static_file(uri, &options.site_root, req.headers());
let res = res.await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let mut res = handle_response_inner(
move || {
additional_context();
provide_context(state.clone());
},
move || shell(options),
req,
|app, chunks| {
Box::pin(async move {
let app = app
.to_html_stream_in_order()
.collect::<String>()
.await;
let chunks = chunks();
Box::pin(once(async move { app }).chain(chunks))
as PinnedStream<String>
})
},
)
.await;
*res.status_mut() = StatusCode::NOT_FOUND;
res
}
}
})
}
}
/// A reasonable handler for serving static files (like JS/WASM/CSS) and 404 errors.
///
/// This is provided as a convenience, but is a fairly simple function. If you need to adapt it,
@@ -2062,7 +1996,39 @@ where
S: Send + Sync + Clone + 'static,
LeptosOptions: FromRef<S>,
{
file_and_error_handler_with_context(move || (), shell)
move |uri: Uri, State(state): State<S>, req: Request<Body>| {
Box::pin(async move {
let options = LeptosOptions::from_ref(&state);
let res = get_static_file(uri, &options.site_root, req.headers());
let res = res.await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let mut res = handle_response_inner(
move || {
provide_context(state.clone());
},
move || shell(options),
req,
|app, chunks| {
Box::pin(async move {
let app = app
.to_html_stream_in_order()
.collect::<String>()
.await;
let chunks = chunks();
Box::pin(once(async move { app }).chain(chunks))
as PinnedStream<String>
})
},
)
.await;
*res.status_mut() = StatusCode::NOT_FOUND;
res
}
})
}
}
#[cfg(feature = "default")]

View File

@@ -52,11 +52,12 @@ web-sys = { version = "0.3.72", features = [
"ShadowRootInit",
"ShadowRootMode",
] }
wasm-bindgen = { workspace = true }
wasm-bindgen = "0.2.97"
serde_qs = "0.13.0"
slotmap = "1.0"
futures = "0.3.31"
send_wrapper = "0.6.0"
getrandom = { version = "0.2", features = ["js"], optional = true }
[features]
hydration = [
@@ -65,12 +66,13 @@ hydration = [
"hydration_context/browser",
"leptos_dom/hydration",
]
csr = ["leptos_macro/csr", "reactive_graph/effects"]
csr = ["leptos_macro/csr", "reactive_graph/effects", "dep:getrandom"]
hydrate = [
"leptos_macro/hydrate",
"hydration",
"tachys/hydrate",
"reactive_graph/effects",
"dep:getrandom",
]
default-tls = ["server_fn/default-tls"]
rustls = ["server_fn/rustls"]
@@ -82,7 +84,10 @@ ssr = [
"tachys/ssr",
]
nightly = ["leptos_macro/nightly", "reactive_graph/nightly", "tachys/nightly"]
rkyv = ["server_fn/rkyv", "leptos_server/rkyv"]
rkyv = [
"server_fn/rkyv",
"leptos_server/rkyv"
]
tracing = [
"dep:tracing",
"reactive_graph/tracing",

View File

@@ -273,7 +273,7 @@ mod tests {
#[test]
fn callback_matches_same() {
let callback1 = Callback::new(|x: i32| x * 2);
let callback2 = callback1;
let callback2 = callback1.clone();
assert!(callback1.matches(&callback2));
}
@@ -287,7 +287,7 @@ mod tests {
#[test]
fn unsync_callback_matches_same() {
let callback1 = UnsyncCallback::new(|x: i32| x * 2);
let callback2 = callback1;
let callback2 = callback1.clone();
assert!(callback1.matches(&callback2));
}

View File

@@ -303,13 +303,11 @@ where
let eff = reactive_graph::effect::Effect::new_isomorphic({
move |_| {
tasks.track();
if let Some(tasks) = tasks.try_read() {
if tasks.is_empty() {
if let Some(tx) = tasks_tx.take() {
// If the receiver has dropped, it means the ScopedFuture has already
// dropped, so it doesn't matter if we manage to send this.
_ = tx.send(());
}
if tasks.read().is_empty() {
if let Some(tx) = tasks_tx.take() {
// If the receiver has dropped, it means the ScopedFuture has already
// dropped, so it doesn't matter if we manage to send this.
_ = tx.send(());
}
}
}

View File

@@ -10,7 +10,7 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
config = { version = "0.15.8", default-features = false, features = [
config = { version = "0.14.1", default-features = false, features = [
"toml",
"convert-case",
] }
@@ -20,7 +20,7 @@ thiserror = "2.0"
typed-builder = "0.20.0"
[dev-dependencies]
tokio = { version = "1.43", features = ["rt", "macros"] }
tokio = { version = "1.41", features = ["rt", "macros"] }
tempfile = "3.14"
temp-env = { version = "0.3.6", features = ["async_closure"] }
@@ -28,4 +28,4 @@ temp-env = { version = "0.3.6", features = ["async_closure"] }
rustdoc-args = ["--generate-link-to-definition"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(leptos_debuginfo)'] }
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(leptos_debuginfo)'] }

View File

@@ -15,7 +15,7 @@ or_poisoned = { workspace = true }
js-sys = "0.3.74"
send_wrapper = "0.6.0"
tracing = { version = "0.1.41", optional = true }
wasm-bindgen = { workspace = true }
wasm-bindgen = "0.2.97"
serde_json = { version = "1.0", optional = true }
serde = { version = "1.0", optional = true }
@@ -39,4 +39,4 @@ rustdoc-args = ["--generate-link-to-definition"]
denylist = ["tracing"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(leptos_debuginfo)'] }
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(leptos_debuginfo)'] }

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_macro"
version = { workspace = true }
version = "0.7.5"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
@@ -16,7 +16,7 @@ proc-macro = true
attribute-derive = { version = "0.10.3", features = ["syn-full"] }
cfg-if = "1.0"
html-escape = "0.2.13"
itertools = { workspace = true }
itertools = "0.13.0"
prettyplease = "0.2.25"
proc-macro-error2 = { version = "2.0", default-features = false }
proc-macro2 = "1.0"
@@ -25,7 +25,7 @@ syn = { version = "2.0", features = ["full"] }
rstml = "0.12.0"
leptos_hot_reload = { workspace = true }
server_fn_macro = { workspace = true }
convert_case = "0.7"
convert_case = "0.6.0"
uuid = { version = "1.11", features = ["v4"] }
tracing = { version = "0.1.41", optional = true }
@@ -34,7 +34,7 @@ log = "0.4.22"
typed-builder = "0.20.0"
trybuild = "1.0"
leptos = { path = "../leptos" }
leptos_router = { path = "../router", features = ["ssr"] }
leptos_router = { path = "../router", features= ["ssr"] }
server_fn = { path = "../server_fn", features = ["cbor"] }
insta = "1.41"
serde = "1.0"
@@ -55,30 +55,30 @@ generic = ["server_fn_macro/generic"]
[package.metadata.cargo-all-features]
denylist = ["nightly", "tracing", "trace-component-props"]
skip_feature_sets = [
[
"csr",
"hydrate",
],
[
"hydrate",
"csr",
],
[
"hydrate",
"ssr",
],
[
"actix",
"axum",
],
[
"actix",
"generic",
],
[
"generic",
"axum",
],
[
"csr",
"hydrate",
],
[
"hydrate",
"csr",
],
[
"hydrate",
"ssr",
],
[
"actix",
"axum",
],
[
"actix",
"generic",
],
[
"generic",
"axum",
],
]
[package.metadata.docs.rs]
@@ -86,6 +86,6 @@ rustdoc-args = ["--generate-link-to-definition"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(leptos_debuginfo)',
'cfg(erase_components)',
'cfg(leptos_debuginfo)',
'cfg(erase_components)',
] }

View File

@@ -11,13 +11,13 @@ dependencies = [
[tasks.test-leptos_macro-example]
description = "Tests the leptos_macro/example to check if macro handles doc comments correctly"
command = "cargo"
args = ["+nightly-2025-03-05", "test", "--doc"]
args = ["+nightly-2024-08-01", "test", "--doc"]
cwd = "example"
install_crate = false
[tasks.doc-leptos_macro-example]
description = "Docs the leptos_macro/example to check if macro handles doc comments correctly"
command = "cargo"
args = ["+nightly-2025-03-05", "doc"]
args = ["+nightly-2024-08-01", "doc"]
cwd = "example"
install_crate = false

View File

@@ -144,6 +144,8 @@ impl ToTokens for Model {
let (impl_generics, generics, where_clause) =
body.sig.generics.split_for_impl();
let lifetimes = body.sig.generics.lifetimes();
let props_name = format_ident!("{name}Props");
let props_builder_name = format_ident!("{name}PropsBuilder");
let props_serialized_name = format_ident!("{name}PropsSerialized");
@@ -568,7 +570,7 @@ impl ToTokens for Model {
#tracing_instrument_attr
#vis fn #name #impl_generics (
#props_arg
) #ret
) #ret #(+ #lifetimes)*
#where_clause
{
#body

View File

@@ -677,21 +677,17 @@ fn component_macro(
#[allow(non_snake_case, dead_code, clippy::too_many_arguments, clippy::needless_lifetimes)]
#unexpanded
}
} else {
match dummy {
Ok(mut dummy) => {
dummy.sig.ident = unmodified_fn_name_from_fn_name(&dummy.sig.ident);
quote! {
#[doc(hidden)]
#[allow(non_snake_case, dead_code, clippy::too_many_arguments, clippy::needless_lifetimes)]
#dummy
}
}
Err(e) => {
proc_macro_error2::abort!(e.span(), e);
}
} else if let Ok(mut dummy) = dummy {
dummy.sig.ident = unmodified_fn_name_from_fn_name(&dummy.sig.ident);
quote! {
#[doc(hidden)]
#[allow(non_snake_case, dead_code, clippy::too_many_arguments, clippy::needless_lifetimes)]
#dummy
}
}.into()
} else {
quote! {}
}
.into()
}
/// Annotates a struct so that it can be used with your Component as a `slot`.

View File

@@ -154,12 +154,7 @@ fn is_inert_element(orig_node: &Node<impl CustomNode>) -> bool {
Some(value) => {
matches!(&value.value, KVAttributeValue::Expr(expr) if {
if let Expr::Lit(lit) = expr {
let key = attr.key.to_string();
if key.starts_with("style:") || key.starts_with("prop:") || key.starts_with("on:") || key.starts_with("use:") || key.starts_with("bind") {
false
} else {
matches!(&lit.lit, Lit::Str(_))
}
matches!(&lit.lit, Lit::Str(_))
} else {
false
}
@@ -1157,14 +1152,8 @@ pub(crate) fn two_way_binding_to_tokens(
let ident =
format_ident!("{}", name.to_case(UpperCamel), span = node.key.span());
if name == "group" {
quote! {
.bind(leptos::tachys::reactive_graph::bind::#ident, #value)
}
} else {
quote! {
.bind(::leptos::attr::#ident, #value)
}
quote! {
.bind(::leptos::attr::#ident, #value)
}
}
@@ -1185,7 +1174,8 @@ pub(crate) fn event_type_and_handler(
) -> (TokenStream, TokenStream, TokenStream) {
let handler = attribute_value(node, false);
let (event_type, is_custom, options) = parse_event_name(name);
let (event_type, is_custom, is_force_undelegated, is_targeted) =
parse_event_name(name);
let event_name_ident = match &node.key {
NodeName::Punctuated(parts) => {
@@ -1203,17 +1193,11 @@ pub(crate) fn event_type_and_handler(
}
_ => unreachable!(),
};
let capture_ident = match &node.key {
NodeName::Punctuated(parts) => {
parts.iter().find(|part| part.to_string() == "capture")
}
_ => unreachable!(),
};
let on = match &node.key {
NodeName::Punctuated(parts) => &parts[0],
_ => unreachable!(),
};
let on = if options.targeted {
let on = if is_targeted {
Ident::new("on_target", on.span()).to_token_stream()
} else {
on.to_token_stream()
@@ -1226,29 +1210,15 @@ pub(crate) fn event_type_and_handler(
event_type
};
let event_type = quote! {
::leptos::tachys::html::event::#event_type
};
let event_type = if options.captured {
let capture = if let Some(capture) = capture_ident {
quote! { #capture }
} else {
quote! { capture }
};
quote! { ::leptos::tachys::html::event::#capture(#event_type) }
} else {
event_type
};
let event_type = if options.undelegated {
let event_type = if is_force_undelegated {
let undelegated = if let Some(undelegated) = undelegated_ident {
quote! { #undelegated }
} else {
quote! { undelegated }
};
quote! { ::leptos::tachys::html::event::#undelegated(#event_type) }
quote! { ::leptos::tachys::html::event::#undelegated(::leptos::tachys::html::event::#event_type) }
} else {
event_type
quote! { ::leptos::tachys::html::event::#event_type }
};
(on, event_type, handler)
@@ -1454,22 +1424,13 @@ fn is_ambiguous_element(tag: &str) -> bool {
tag == "a" || tag == "script" || tag == "title"
}
fn parse_event(event_name: &str) -> (String, EventNameOptions) {
let undelegated = event_name.contains(":undelegated");
let targeted = event_name.contains(":target");
let captured = event_name.contains(":capture");
fn parse_event(event_name: &str) -> (String, bool, bool) {
let is_undelegated = event_name.contains(":undelegated");
let is_targeted = event_name.contains(":target");
let event_name = event_name
.replace(":undelegated", "")
.replace(":target", "")
.replace(":capture", "");
(
event_name,
EventNameOptions {
undelegated,
targeted,
captured,
},
)
.replace(":target", "");
(event_name, is_undelegated, is_targeted)
}
/// Escapes Rust keywords that are also HTML attribute names
@@ -1661,17 +1622,8 @@ const TYPED_EVENTS: [&str; 126] = [
const CUSTOM_EVENT: &str = "Custom";
#[derive(Debug)]
pub(crate) struct EventNameOptions {
undelegated: bool,
targeted: bool,
captured: bool,
}
pub(crate) fn parse_event_name(
name: &str,
) -> (TokenStream, bool, EventNameOptions) {
let (name, options) = parse_event(name);
pub(crate) fn parse_event_name(name: &str) -> (TokenStream, bool, bool, bool) {
let (name, is_force_undelegated, is_targeted) = parse_event(name);
let (event_type, is_custom) = TYPED_EVENTS
.binary_search(&name.as_str())
@@ -1687,7 +1639,7 @@ pub(crate) fn parse_event_name(
} else {
event_type
};
(event_type, is_custom, options)
(event_type, is_custom, is_force_undelegated, is_targeted)
}
fn convert_to_snake_case(name: String) -> String {

View File

@@ -104,18 +104,3 @@ fn component_nostrip() {
/>
};
}
#[component]
fn WithLifetime<'a>(data: &'a str) -> impl IntoView {
_ = data;
"static lifetime"
}
#[test]
fn returns_static_lifetime() {
#[allow(unused)]
fn can_return_impl_intoview_from_body() -> impl IntoView {
let val = String::from("non_static_lifetime");
WithLifetime(WithLifetimeProps::builder().data(&val).build())
}
}

View File

@@ -26,8 +26,8 @@ send_wrapper = "0.6"
# serialization formats
serde = { version = "1.0" }
js-sys = { version = "0.3.74", optional = true }
wasm-bindgen = { version = "0.2.100", optional = true }
serde_json = { workspace = true }
wasm-bindgen = { version = "0.2.97", optional = true }
serde_json = { version = "1.0" }
[features]
ssr = []
@@ -46,4 +46,4 @@ denylist = ["tracing"]
rustdoc-args = ["--generate-link-to-definition"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(leptos_debuginfo)'] }
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(leptos_debuginfo)'] }

View File

@@ -12,9 +12,7 @@ use reactive_graph::{
guards::{AsyncPlain, ReadGuard},
ArcRwSignal, RwSignal,
},
traits::{
DefinedAt, IsDisposed, ReadUntracked, Track, Update, With, Write,
},
traits::{DefinedAt, IsDisposed, ReadUntracked, Track, Update, Write},
};
use send_wrapper::SendWrapper;
use std::{
@@ -93,34 +91,6 @@ impl<T> ArcLocalResource<T> {
pub fn refetch(&self) {
*self.refetch.write() += 1;
}
/// Synchronously, reactively reads the current value of the resource and applies the function
/// `f` to its value if it is `Some(_)`.
#[track_caller]
pub fn map<U>(&self, f: impl FnOnce(&SendWrapper<T>) -> U) -> Option<U>
where
T: 'static,
{
self.data.try_with(|n| n.as_ref().map(f))?
}
}
impl<T, E> ArcLocalResource<Result<T, E>>
where
T: 'static,
E: Clone + 'static,
{
/// Applies the given function when a resource that returns `Result<T, E>`
/// has resolved and loaded an `Ok(_)`, rather than requiring nested `.map()`
/// calls over the `Option<Result<_, _>>` returned by the resource.
///
/// This is useful when used with features like server functions, in conjunction
/// with `<ErrorBoundary/>` and `<Suspense/>`, when these other components are
/// left to handle the `None` and `Err(_)` states.
#[track_caller]
pub fn and_then<U>(&self, f: impl FnOnce(&T) -> U) -> Option<Result<U, E>> {
self.map(|data| data.as_ref().map(f).map_err(|e| e.clone()))
}
}
impl<T> IntoFuture for ArcLocalResource<T>
@@ -172,6 +142,12 @@ where
fn try_read_untracked(&self) -> Option<Self::Value> {
if let Some(mut notifier) = use_context::<LocalResourceNotifier>() {
notifier.notify();
} else if cfg!(feature = "ssr") {
panic!(
"Reading from a LocalResource outside Suspense in `ssr` mode \
will cause the response to hang, because LocalResources are \
always pending on the server."
);
}
self.data.try_read_untracked()
}
@@ -358,6 +334,12 @@ where
fn try_read_untracked(&self) -> Option<Self::Value> {
if let Some(mut notifier) = use_context::<LocalResourceNotifier>() {
notifier.notify();
} else if cfg!(feature = "ssr") {
panic!(
"Reading from a LocalResource outside Suspense in `ssr` mode \
will cause the response to hang, because LocalResources are \
always pending on the server."
);
}
self.data.try_read_untracked()
}

View File

@@ -168,41 +168,6 @@ where
data
}
/// Synchronously, reactively reads the current value of the resource and applies the function
/// `f` to its value if it is `Some(_)`.
#[track_caller]
pub fn map<U>(&self, f: impl FnOnce(&T) -> U) -> Option<U>
where
T: Send + Sync + 'static,
{
self.try_with(|n| n.as_ref().map(f))?
}
}
impl<T, E, Ser> ArcOnceResource<Result<T, E>, Ser>
where
Ser: Encoder<Result<T, E>> + Decoder<Result<T, E>>,
<Ser as Encoder<Result<T, E>>>::Error: Debug,
<Ser as Decoder<Result<T, E>>>::Error: Debug,
<<Ser as Decoder<Result<T, E>>>::Encoded as FromEncodedStr>::DecodingError:
Debug,
<Ser as Encoder<Result<T, E>>>::Encoded: IntoEncodedString,
<Ser as Decoder<Result<T, E>>>::Encoded: FromEncodedStr,
T: Send + Sync + 'static,
E: Send + Sync + Clone + 'static,
{
/// Applies the given function when a resource that returns `Result<T, E>`
/// has resolved and loaded an `Ok(_)`, rather than requiring nested `.map()`
/// calls over the `Option<Result<_, _>>` returned by the resource.
///
/// This is useful when used with features like server functions, in conjunction
/// with `<ErrorBoundary/>` and `<Suspense/>`, when these other components are
/// left to handle the `None` and `Err(_)` states.
#[track_caller]
pub fn and_then<U>(&self, f: impl FnOnce(&T) -> U) -> Option<Result<U, E>> {
self.map(|data| data.as_ref().map(f).map_err(|e| e.clone()))
}
}
impl<T, Ser> ArcOnceResource<T, Ser> {
@@ -569,37 +534,6 @@ where
defined_at,
}
}
/// Synchronously, reactively reads the current value of the resource and applies the function
/// `f` to its value if it is `Some(_)`.
pub fn map<U>(&self, f: impl FnOnce(&T) -> U) -> Option<U> {
self.try_with(|n| n.as_ref().map(|n| Some(f(n))))?.flatten()
}
}
impl<T, E, Ser> OnceResource<Result<T, E>, Ser>
where
Ser: Encoder<Result<T, E>> + Decoder<Result<T, E>>,
<Ser as Encoder<Result<T, E>>>::Error: Debug,
<Ser as Decoder<Result<T, E>>>::Error: Debug,
<<Ser as Decoder<Result<T, E>>>::Encoded as FromEncodedStr>::DecodingError:
Debug,
<Ser as Encoder<Result<T, E>>>::Encoded: IntoEncodedString,
<Ser as Decoder<Result<T, E>>>::Encoded: FromEncodedStr,
T: Send + Sync + 'static,
E: Send + Sync + Clone + 'static,
{
/// Applies the given function when a resource that returns `Result<T, E>`
/// has resolved and loaded an `Ok(_)`, rather than requiring nested `.map()`
/// calls over the `Option<Result<_, _>>` returned by the resource.
///
/// This is useful when used with features like server functions, in conjunction
/// with `<ErrorBoundary/>` and `<Suspense/>`, when these other components are
/// left to handle the `None` and `Err(_)` states.
#[track_caller]
pub fn and_then<U>(&self, f: impl FnOnce(&T) -> U) -> Option<Result<U, E>> {
self.map(|data| data.as_ref().map(f).map_err(|e| e.clone()))
}
}
impl<T, Ser> OnceResource<T, Ser>

View File

@@ -215,11 +215,16 @@ where
None
}
Ok(encoded) => {
let decoded = Ser::decode(encoded.borrow());
#[cfg(feature = "tracing")]
let decoded = decoded
.inspect_err(|e| tracing::error!("{e:?}"));
decoded.ok()
match Ser::decode(encoded.borrow()) {
#[allow(unused_variables)]
// used in tracing
Err(e) => {
#[cfg(feature = "tracing")]
tracing::error!("{e:?}");
None
}
Ok(value) => Some(value),
}
}
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_meta"
version = "0.7.8"
version = "0.7.5"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
@@ -15,7 +15,7 @@ or_poisoned = { workspace = true }
indexmap = "2.6"
send_wrapper = "0.6.0"
tracing = { version = "0.1.41", optional = true }
wasm-bindgen = { workspace = true }
wasm-bindgen = "0.2.97"
futures = "0.3.31"
[dependencies.web-sys]

View File

@@ -323,13 +323,37 @@ pub(crate) fn register<E, At, Ch>(
where
HtmlElement<E, At, Ch>: RenderHtml,
{
#[allow(unused_mut)] // used for `ssr`
let mut el = Some(el);
#[cfg(feature = "ssr")]
if let Some(cx) = use_context::<ServerMetaContext>() {
let mut buf = String::new();
el.take().unwrap().to_html_with_buf(
&mut buf,
&mut Position::NextChild,
false,
false,
);
_ = cx.elements.send(buf); // fails only if the receiver is already dropped
} else {
let msg = "tried to use a leptos_meta component without \
`ServerMetaContext` provided";
#[cfg(feature = "tracing")]
tracing::warn!("{}", msg);
#[cfg(not(feature = "tracing"))]
eprintln!("{}", msg);
}
RegisteredMetaTag { el }
}
struct RegisteredMetaTag<E, At, Ch> {
// this is `None` if we've already taken it out to render to HTML on the server
// we don't render it in place in RenderHtml, so it's fine
el: HtmlElement<E, At, Ch>,
el: Option<HtmlElement<E, At, Ch>>,
}
struct RegisteredMetaTagState<E, At, Ch>
@@ -367,12 +391,12 @@ where
type State = RegisteredMetaTagState<E, At, Ch>;
fn build(self) -> Self::State {
let state = self.el.build();
let state = self.el.unwrap().build();
RegisteredMetaTagState { state }
}
fn rebuild(self, state: &mut Self::State) {
self.el.rebuild(&mut state.state);
self.el.unwrap().rebuild(&mut state.state);
}
}
@@ -393,7 +417,7 @@ where
Self::Output<NewAttr>: RenderHtml,
{
RegisteredMetaTag {
el: self.el.add_any_attr(attr),
el: self.el.map(|inner| inner.add_any_attr(attr)),
}
}
}
@@ -425,26 +449,6 @@ where
) {
// meta tags are rendered into the buffer stored into the context
// the value has already been taken out, when we're on the server
#[cfg(feature = "ssr")]
if let Some(cx) = use_context::<ServerMetaContext>() {
let mut buf = String::new();
self.el.to_html_with_buf(
&mut buf,
&mut Position::NextChild,
false,
false,
);
_ = cx.elements.send(buf); // fails only if the receiver is already dropped
} else {
let msg = "tried to use a leptos_meta component without \
`ServerMetaContext` provided";
#[cfg(feature = "tracing")]
tracing::warn!("{}", msg);
#[cfg(not(feature = "tracing"))]
eprintln!("{}", msg);
}
}
fn hydrate<const FROM_SERVER: bool>(
@@ -458,7 +462,7 @@ where
MetaContext provided",
)
.cursor;
let state = self.el.hydrate::<FROM_SERVER>(
let state = self.el.unwrap().hydrate::<FROM_SERVER>(
&cursor,
&PositionState::new(Position::NextChild),
);

View File

@@ -13,4 +13,4 @@ serde = "1.0"
thiserror = "2.0"
[dev-dependencies]
serde_json = { workspace = true }
serde_json = "1.0"

View File

@@ -35,7 +35,7 @@ pub trait OrPoisoned {
fn or_poisoned(self) -> Self::Inner;
}
impl<'a, T: ?Sized> OrPoisoned
impl<'a, T> OrPoisoned
for Result<RwLockReadGuard<'a, T>, PoisonError<RwLockReadGuard<'a, T>>>
{
type Inner = RwLockReadGuard<'a, T>;
@@ -45,7 +45,7 @@ impl<'a, T: ?Sized> OrPoisoned
}
}
impl<'a, T: ?Sized> OrPoisoned
impl<'a, T> OrPoisoned
for Result<RwLockWriteGuard<'a, T>, PoisonError<RwLockWriteGuard<'a, T>>>
{
type Inner = RwLockWriteGuard<'a, T>;
@@ -55,7 +55,7 @@ impl<'a, T: ?Sized> OrPoisoned
}
}
impl<'a, T: ?Sized> OrPoisoned for LockResult<MutexGuard<'a, T>> {
impl<'a, T> OrPoisoned for LockResult<MutexGuard<'a, T>> {
type Inner = MutexGuard<'a, T>;
fn or_poisoned(self) -> Self::Inner {

View File

@@ -8,19 +8,19 @@ codegen-units = 1
lto = true
[dependencies]
leptos = { version = "0.7.8", features = ["csr"] }
leptos_meta = { version = "0.7.8" }
leptos_router = { version = "0.7.8" }
leptos = { version = "0.6.13", features = ["csr"] }
leptos_meta = { version = "0.6.13", features = ["csr"] }
leptos_router = { version = "0.6.13", features = ["csr"] }
console_log = "1.0"
log = "0.4.22"
console_error_panic_hook = "0.1.7"
bevy = "0.15.2"
bevy = "0.14.1"
crossbeam-channel = "0.5.13"
[dev-dependencies]
wasm-bindgen = "0.2.100"
wasm-bindgen-test = "0.3.50"
web-sys = "0.3.77"
wasm-bindgen = "0.2.92"
wasm-bindgen-test = "0.3.42"
web-sys = "0.3.69"
[workspace]
# The empty workspace here is to keep rust-analyzer satisfied

View File

@@ -17,7 +17,7 @@ impl DuplexEventsPlugin {
let (bevy_sender, client_receiver) = crossbeam_channel::bounded(50);
// For sending message from the client to bevy
let (client_sender, bevy_receiver) = crossbeam_channel::bounded(50);
DuplexEventsPlugin {
let instance = DuplexEventsPlugin {
client_processor: EventProcessor {
sender: client_sender,
receiver: client_receiver,
@@ -26,7 +26,8 @@ impl DuplexEventsPlugin {
sender: bevy_sender,
receiver: bevy_receiver,
},
}
};
instance
}
/// Get the client event processor

View File

@@ -23,13 +23,14 @@ impl Scene {
/// Create a new instance
pub fn new(canvas_id: String) -> Scene {
let plugin = DuplexEventsPlugin::new();
Scene {
let instance = Scene {
is_setup: false,
canvas_id,
canvas_id: canvas_id,
evt_plugin: plugin.clone(),
shared_state: SharedState::new(),
processor: plugin.get_processor(),
}
};
instance
}
/// Get the shared state
@@ -46,7 +47,7 @@ impl Scene {
/// Setup and attach the bevy instance to the html canvas element
pub fn setup(&mut self) {
if self.is_setup {
if self.is_setup == true {
return;
};
App::new()
@@ -75,37 +76,40 @@ fn setup_scene(
) {
let name = resource.0.lock().unwrap().name.clone();
// circular base
commands.spawn((
Mesh3d(meshes.add(Circle::new(4.0))),
MeshMaterial3d(materials.add(Color::WHITE)),
Transform::from_rotation(Quat::from_rotation_x(
commands.spawn(PbrBundle {
mesh: meshes.add(Circle::new(4.0)),
material: materials.add(Color::WHITE),
transform: Transform::from_rotation(Quat::from_rotation_x(
-std::f32::consts::FRAC_PI_2,
)),
));
..default()
});
// cube
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
MeshMaterial3d(materials.add(Color::srgb_u8(124, 144, 255))),
Transform::from_xyz(0.0, 0.5, 0.0),
PbrBundle {
mesh: meshes.add(Cuboid::new(1.0, 1.0, 1.0)),
material: materials.add(Color::rgb_u8(124, 144, 255)),
transform: Transform::from_xyz(0.0, 0.5, 0.0),
..default()
},
Cube,
));
// light
commands.spawn((
PointLight {
commands.spawn(PointLightBundle {
point_light: PointLight {
shadows_enabled: true,
..default()
},
Transform::from_xyz(4.0, 8.0, 4.0),
));
transform: Transform::from_xyz(4.0, 8.0, 4.0),
..default()
});
// camera
commands.spawn((
Camera3d::default(),
Transform::from_xyz(-2.5, 4.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y),
));
commands.spawn((Text::new(name), TextFont::default()));
commands.spawn(Camera3dBundle {
transform: Transform::from_xyz(-2.5, 4.5, 9.0)
.looking_at(Vec3::ZERO, Vec3::Y),
..default()
});
commands.spawn(TextBundle::from_section(name, TextStyle::default()));
}
/// Move the Cube on event

View File

@@ -1,6 +1,6 @@
mod demos;
mod routes;
use leptos::prelude::*;
use leptos::*;
use routes::RootPage;
pub fn main() {

View File

@@ -2,7 +2,7 @@ use crate::demos::bevydemo1::eventqueue::events::{
ClientInEvents, CounterEvtData,
};
use crate::demos::bevydemo1::scene::Scene;
use leptos::prelude::*;
use leptos::*;
/// 3d view component
#[component]
@@ -10,18 +10,18 @@ pub fn Demo1() -> impl IntoView {
// Setup a Counter
let initial_value: i32 = 0;
let step: i32 = 1;
let (value, set_value) = signal(initial_value);
let (value, set_value) = create_signal(initial_value);
// Setup a bevy 3d scene
let scene = Scene::new("#bevy".to_string());
let sender = scene.get_processor().sender;
let (sender_sig, _set_sender_sig) = signal(sender);
let (scene_sig, _set_scene_sig) = signal(scene);
let (sender_sig, _set_sender_sig) = create_signal(sender);
let (scene_sig, _set_scene_sig) = create_signal(scene);
// We need to add the 3D view onto the canvas post render.
Effect::new(move |_| {
create_effect(move |_| {
request_animation_frame(move || {
scene_sig.get_untracked().setup();
scene_sig.get().setup();
});
});

View File

@@ -1,11 +1,9 @@
pub mod demo1;
use demo1::Demo1;
use leptos::prelude::*;
use leptos_meta::Meta;
use leptos_meta::Title;
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet};
use leptos_router::components::*;
use leptos_router::StaticSegment;
use leptos::*;
use leptos_meta::{provide_meta_context, Meta, Stylesheet, Title};
use leptos_router::*;
#[component]
pub fn RootPage() -> impl IntoView {
provide_meta_context();
@@ -15,12 +13,11 @@ pub fn RootPage() -> impl IntoView {
<Meta name="description" content="Leptonic CSR template"/>
<Meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<Meta name="theme-color" content="#e66956"/>
<Title text="Leptos Bevy3D Example"/>
<Stylesheet href="https://fonts.googleapis.com/css?family=Roboto&display=swap"/>
<MetaTags/>
<Title text="Leptos Bevy3D Example"/>
<Router>
<Routes fallback=move || "Not found.">
<Route path=StaticSegment("") view=Demo1 />
<Routes>
<Route path="" view=|| view! { <Demo1/> }/>
</Routes>
</Router>
}

View File

@@ -1,6 +1,6 @@
[package]
name = "reactive_graph"
version = "0.1.8"
version = "0.1.5"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -28,7 +28,7 @@ send_wrapper = { version = "0.6.0", features = ["futures"] }
web-sys = { version = "0.3.72", features = ["console"] }
[dev-dependencies]
tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] }
tokio = { version = "1.41", features = ["rt-multi-thread", "macros"] }
tokio-test = { version = "0.4.4" }
any_spawner = { workspace = true, features = ["futures-executor", "tokio"] }

View File

@@ -939,8 +939,7 @@ where
#[track_caller]
pub fn dispatch(&self, input: I) -> ActionAbortHandle {
self.inner
.try_get_value()
.map(|inner| inner.dispatch(input))
.try_with_value(|inner| inner.dispatch(input))
.unwrap_or_else(unwrap_signal!(self))
}
}
@@ -955,8 +954,7 @@ where
#[track_caller]
pub fn dispatch_local(&self, input: I) -> ActionAbortHandle {
self.inner
.try_get_value()
.map(|inner| inner.dispatch_local(input))
.try_with_value(|inner| inner.dispatch_local(input))
.unwrap_or_else(unwrap_signal!(self))
}
}

View File

@@ -324,7 +324,7 @@ macro_rules! spawn_derived {
}
while rx.next().await.is_some() {
let update_if_necessary = !owner.paused() && if $should_track {
let update_if_necessary = if $should_track {
any_subscriber
.with_observer(|| any_subscriber.update_if_necessary())
} else {

View File

@@ -3,13 +3,11 @@
#[allow(clippy::module_inception)]
mod effect;
mod effect_function;
mod immediate;
mod inner;
mod render_effect;
pub use effect::*;
pub use effect_function::*;
pub use immediate::*;
pub use render_effect::*;
/// Creates a new render effect, which immediately runs `fun`.

View File

@@ -170,10 +170,9 @@ impl Effect<LocalStorage> {
async move {
while rx.next().await.is_some() {
if !owner.paused()
&& (subscriber.with_observer(|| {
subscriber.update_if_necessary()
}) || first_run)
if subscriber
.with_observer(|| subscriber.update_if_necessary())
|| first_run
{
first_run = false;
subscriber.clear_sources(&subscriber);
@@ -322,10 +321,9 @@ impl Effect<LocalStorage> {
async move {
while rx.next().await.is_some() {
if !owner.paused()
&& (subscriber.with_observer(|| {
subscriber.update_if_necessary()
}) || first_run)
if subscriber
.with_observer(|| subscriber.update_if_necessary())
|| first_run
{
subscriber.clear_sources(&subscriber);
@@ -374,16 +372,46 @@ impl Effect<SyncStorage> {
/// This spawns a task that can be run on any thread. For an effect that will be spawned on
/// the current thread, use [`new`](Effect::new).
pub fn new_sync<T, M>(
fun: impl EffectFunction<T, M> + Send + Sync + 'static,
mut fun: impl EffectFunction<T, M> + Send + Sync + 'static,
) -> Self
where
T: Send + Sync + 'static,
{
if !cfg!(feature = "effects") {
return Self { inner: None };
}
let inner = cfg!(feature = "effects").then(|| {
let (mut rx, owner, inner) = effect_base();
let mut first_run = true;
let value = Arc::new(RwLock::new(None::<T>));
Self::new_isomorphic(fun)
crate::spawn({
let value = Arc::clone(&value);
let subscriber = inner.to_any_subscriber();
async move {
while rx.next().await.is_some() {
if subscriber
.with_observer(|| subscriber.update_if_necessary())
|| first_run
{
first_run = false;
subscriber.clear_sources(&subscriber);
let old_value =
mem::take(&mut *value.write().or_poisoned());
let new_value = owner.with_cleanup(|| {
subscriber.with_observer(|| {
run_in_effect_scope(|| fun.run(old_value))
})
});
*value.write().or_poisoned() = Some(new_value);
}
}
}
});
ArenaItem::new_with_storage(Some(inner))
});
Self { inner }
}
/// Creates a new effect, which runs once on the next “tick”, and then runs again when reactive values
@@ -406,10 +434,9 @@ impl Effect<SyncStorage> {
async move {
while rx.next().await.is_some() {
if !owner.paused()
&& (subscriber
.with_observer(|| subscriber.update_if_necessary())
|| first_run)
if subscriber
.with_observer(|| subscriber.update_if_necessary())
|| first_run
{
first_run = false;
subscriber.clear_sources(&subscriber);
@@ -460,10 +487,9 @@ impl Effect<SyncStorage> {
async move {
while rx.next().await.is_some() {
if !owner.paused()
&& (subscriber.with_observer(|| {
subscriber.update_if_necessary()
}) || first_run)
if subscriber
.with_observer(|| subscriber.update_if_necessary())
|| first_run
{
subscriber.clear_sources(&subscriber);

View File

@@ -1,379 +0,0 @@
use crate::{
graph::{AnySubscriber, ReactiveNode, ToAnySubscriber},
owner::on_cleanup,
traits::{DefinedAt, Dispose},
};
use or_poisoned::OrPoisoned;
use std::{
panic::Location,
sync::{Arc, Mutex, RwLock},
};
/// Effects run a certain chunk of code whenever the signals they depend on change.
///
/// The effect runs on creation and again as soon as any tracked signal changes.
///
/// NOTE: you probably want use [`Effect`](super::Effect) instead.
/// This is for the few cases where it's important to execute effects immediately and in order.
///
/// [ImmediateEffect]s stop running when dropped.
///
/// NOTE: since effects are executed immediately, they might recurse.
/// Under recursion or parallelism only the last run to start is tracked.
///
/// ## Example
///
/// ```
/// # use reactive_graph::computed::*;
/// # use reactive_graph::signal::*; let owner = reactive_graph::owner::Owner::new(); owner.set();
/// # use reactive_graph::prelude::*;
/// # use reactive_graph::effect::ImmediateEffect;
/// # use reactive_graph::owner::ArenaItem;
/// # let owner = reactive_graph::owner::Owner::new(); owner.set();
/// let a = RwSignal::new(0);
/// let b = RwSignal::new(0);
///
/// // ✅ use effects to interact between reactive state and the outside world
/// let _drop_guard = ImmediateEffect::new(move || {
/// // on the next “tick” prints "Value: 0" and subscribes to `a`
/// println!("Value: {}", a.get());
/// });
///
/// // The effect runs immediately and subscribes to `a`, in the process it prints "Value: 0"
/// # assert_eq!(a.get(), 0);
/// a.set(1);
/// # assert_eq!(a.get(), 1);
/// // ✅ because it's subscribed to `a`, the effect reruns and prints "Value: 1"
/// ```
/// ## Notes
///
/// 1. **Scheduling**: Effects run immediately, as soon as any tracked signal changes.
/// 2. By default, effects do not run unless the `effects` feature is enabled. If you are using
/// this with a web framework, this generally means that effects **do not run on the server**.
/// and you can call browser-specific APIs within the effect function without causing issues.
/// If you need an effect to run on the server, use [`ImmediateEffect::new_isomorphic`].
#[derive(Debug, Clone)]
pub struct ImmediateEffect {
inner: StoredEffect,
}
type StoredEffect = Option<Arc<RwLock<inner::EffectInner>>>;
impl Dispose for ImmediateEffect {
fn dispose(self) {}
}
impl ImmediateEffect {
/// Creates a new effect which runs immediately, then again as soon as any tracked signal changes.
///
/// NOTE: this requires a `Fn` function because it might recurse.
/// Use [Self::new_mut] to pass a `FnMut` function, it'll panic on recursion.
#[track_caller]
#[must_use]
pub fn new(fun: impl Fn() + Send + Sync + 'static) -> Self {
if !cfg!(feature = "effects") {
return Self { inner: None };
}
let inner = inner::EffectInner::new(fun);
inner.update_if_necessary();
Self { inner: Some(inner) }
}
/// Creates a new effect which runs immediately, then again as soon as any tracked signal changes.
///
/// # Panics
/// Panics on recursion or if triggered in parallel. Also see [Self::new]
#[track_caller]
#[must_use]
pub fn new_mut(fun: impl FnMut() + Send + Sync + 'static) -> Self {
const MSG: &str = "The effect recursed or its function panicked.";
let fun = Mutex::new(fun);
Self::new(move || fun.try_lock().expect(MSG)())
}
/// Creates a new effect which runs immediately, then again as soon as any tracked signal changes.
///
/// NOTE: this requires a `Fn` function because it might recurse.
/// NOTE: this effect is automatically cleaned up when the current owner is cleared or disposed.
#[track_caller]
pub fn new_scoped(fun: impl Fn() + Send + Sync + 'static) {
let effect = Self::new(fun);
on_cleanup(move || effect.dispose());
}
/// Creates a new effect which runs immediately, then again as soon as any tracked signal changes.
///
/// This will run whether the `effects` feature is enabled or not.
#[track_caller]
#[must_use]
pub fn new_isomorphic(fun: impl Fn() + Send + Sync + 'static) -> Self {
let inner = inner::EffectInner::new(fun);
inner.update_if_necessary();
Self { inner: Some(inner) }
}
}
impl ToAnySubscriber for ImmediateEffect {
fn to_any_subscriber(&self) -> AnySubscriber {
const MSG: &str = "tried to set effect that has been stopped";
self.inner.as_ref().expect(MSG).to_any_subscriber()
}
}
impl DefinedAt for ImmediateEffect {
fn defined_at(&self) -> Option<&'static Location<'static>> {
self.inner.as_ref()?.read().or_poisoned().defined_at()
}
}
mod inner {
use crate::{
graph::{
AnySource, AnySubscriber, ReactiveNode, ReactiveNodeState,
SourceSet, Subscriber, ToAnySubscriber, WithObserver,
},
log_warning,
owner::Owner,
traits::DefinedAt,
};
use or_poisoned::OrPoisoned;
use std::{
panic::Location,
sync::{Arc, RwLock, Weak},
thread::{self, ThreadId},
};
/// Handles subscription logic for effects.
///
/// To handle parallelism and recursion we assign ordered (1..) ids to each run.
/// We only keep the sources tracked by the run with the highest id (the last one).
///
/// We do this by:
/// - Clearing the sources before every run, so the last one clears anything before it.
/// - We stop tracking sources after the last run has completed.
/// (A parent run will start before and end after a recursive child run.)
/// - To handle parallelism with the last run, we only allow sources to be added by its thread.
pub(super) struct EffectInner {
#[cfg(any(debug_assertions, leptos_debuginfo))]
defined_at: &'static Location<'static>,
owner: Owner,
state: ReactiveNodeState,
/// The number of effect runs in this 'batch'.
/// Cleared when no runs are *ongoing* anymore.
/// Used to assign ordered ids to each run, and to know when we can clear these values.
run_count_start: usize,
/// The number of effect runs that have completed in the current 'batch'.
/// Cleared when no runs are *ongoing* anymore.
/// Used to know when we can clear these values.
run_done_count: usize,
/// Given ordered ids (1..), the run with the highest id that has completed in this 'batch'.
/// Cleared when no runs are *ongoing* anymore.
/// Used to know whether the current run is the latest one.
run_done_max: usize,
/// The [ThreadId] of the run with the highest id.
/// Used to prevent over-subscribing during parallel execution with the last run.
///
/// ```text
/// Thread 1:
/// -------------------------
/// --- --- =======
///
/// Thread 2:
/// -------------------------
/// -----------
/// ```
///
/// In the parallel example above, we can see why we need this.
/// The last run is marked using `=`, but another run in the other thread might
/// also be gathering sources. So we only allow the run from the correct [ThreadId] to push sources.
last_run_thread_id: ThreadId,
fun: Arc<dyn Fn() + Send + Sync>,
sources: SourceSet,
any_subscriber: AnySubscriber,
}
impl EffectInner {
#[track_caller]
pub fn new(
fun: impl Fn() + Send + Sync + 'static,
) -> Arc<RwLock<EffectInner>> {
let owner = Owner::new();
Arc::new_cyclic(|weak| {
let any_subscriber = AnySubscriber(
weak.as_ptr() as usize,
Weak::clone(weak) as Weak<dyn Subscriber + Send + Sync>,
);
RwLock::new(EffectInner {
#[cfg(any(debug_assertions, leptos_debuginfo))]
defined_at: Location::caller(),
owner,
state: ReactiveNodeState::Dirty,
run_count_start: 0,
run_done_count: 0,
run_done_max: 0,
last_run_thread_id: thread::current().id(),
fun: Arc::new(fun),
sources: SourceSet::new(),
any_subscriber,
})
})
}
}
impl ToAnySubscriber for Arc<RwLock<EffectInner>> {
fn to_any_subscriber(&self) -> AnySubscriber {
AnySubscriber(
Arc::as_ptr(self) as usize,
Arc::downgrade(self) as Weak<dyn Subscriber + Send + Sync>,
)
}
}
impl ReactiveNode for RwLock<EffectInner> {
fn mark_subscribers_check(&self) {}
fn update_if_necessary(&self) -> bool {
let state = {
let guard = self.read().or_poisoned();
if guard.owner.paused() {
return false;
}
guard.state
};
let needs_update = match state {
ReactiveNodeState::Clean => false,
ReactiveNodeState::Check => {
let sources = self.read().or_poisoned().sources.clone();
sources
.into_iter()
.any(|source| source.update_if_necessary())
}
ReactiveNodeState::Dirty => true,
};
if needs_update {
let mut guard = self.write().or_poisoned();
let owner = guard.owner.clone();
let any_subscriber = guard.any_subscriber.clone();
let fun = guard.fun.clone();
// New run has started.
guard.run_count_start += 1;
// We get a value for this run, the highest value will be what we keep the sources from.
let recursion_count = guard.run_count_start;
// We clear the sources before running the effect.
// Note that this is tied to the ordering of the initial write lock acquisition
// to ensure the last run is also the last to clear them.
guard.sources.clear_sources(&any_subscriber);
// Only this thread will be able to subscribe.
guard.last_run_thread_id = thread::current().id();
if recursion_count > 2 {
warn_excessive_recursion(&guard);
}
drop(guard);
// We execute the effect.
// Note that *this could happen in parallel across threads*.
owner.with_cleanup(|| any_subscriber.with_observer(|| fun()));
let mut guard = self.write().or_poisoned();
// This run has completed.
guard.run_done_count += 1;
// We update the done count.
// Sources will only be added if recursion_done_max < recursion_count_start.
// (Meaning the last run is not done yet.)
guard.run_done_max =
Ord::max(recursion_count, guard.run_done_max);
// The same amount of runs has started and completed,
// so we can clear everything up for next time.
if guard.run_count_start == guard.run_done_count {
guard.run_count_start = 0;
guard.run_done_count = 0;
guard.run_done_max = 0;
// Can be left unchanged, it'll be set again next time.
// guard.last_run_thread_id = thread::current().id();
}
guard.state = ReactiveNodeState::Clean;
}
needs_update
}
fn mark_check(&self) {
self.write().or_poisoned().state = ReactiveNodeState::Check;
self.update_if_necessary();
}
fn mark_dirty(&self) {
self.write().or_poisoned().state = ReactiveNodeState::Dirty;
self.update_if_necessary();
}
}
impl Subscriber for RwLock<EffectInner> {
fn add_source(&self, source: AnySource) {
let mut guard = self.write().or_poisoned();
if guard.run_done_max < guard.run_count_start
&& guard.last_run_thread_id == thread::current().id()
{
guard.sources.insert(source);
}
}
fn clear_sources(&self, subscriber: &AnySubscriber) {
self.write().or_poisoned().sources.clear_sources(subscriber);
}
}
impl DefinedAt for EffectInner {
fn defined_at(&self) -> Option<&'static Location<'static>> {
#[cfg(any(debug_assertions, leptos_debuginfo))]
{
Some(self.defined_at)
}
#[cfg(not(any(debug_assertions, leptos_debuginfo)))]
{
None
}
}
}
impl std::fmt::Debug for EffectInner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EffectInner")
.field("owner", &self.owner)
.field("state", &self.state)
.field("sources", &self.sources)
.field("any_subscriber", &self.any_subscriber)
.finish()
}
}
fn warn_excessive_recursion(effect: &EffectInner) {
const MSG: &str = "ImmediateEffect recursed more than once.";
match effect.defined_at() {
Some(defined_at) => {
log_warning(format_args!("{MSG} Defined at: {}", defined_at));
}
None => {
log_warning(format_args!("{MSG}"));
}
}
}
}

View File

@@ -30,19 +30,21 @@ impl ReactiveNode for RwLock<EffectInner> {
fn update_if_necessary(&self) -> bool {
let mut guard = self.write().or_poisoned();
let (is_dirty, sources) =
(guard.dirty, (!guard.dirty).then(|| guard.sources.clone()));
if guard.dirty {
if is_dirty {
guard.dirty = false;
return true;
}
let sources = guard.sources.clone();
drop(guard);
sources
.into_iter()
.any(|source| source.update_if_necessary())
for source in sources.into_iter().flatten() {
if source.update_if_necessary() {
return true;
}
}
false
}
fn mark_check(&self) {

View File

@@ -91,11 +91,9 @@ where
async move {
while rx.next().await.is_some() {
if !owner.paused()
&& subscriber.with_observer(|| {
subscriber.update_if_necessary()
})
{
if subscriber.with_observer(|| {
subscriber.update_if_necessary()
}) {
subscriber.clear_sources(&subscriber);
let old_value = mem::take(
@@ -161,10 +159,8 @@ where
async move {
while rx.next().await.is_some() {
if !owner.paused()
&& subscriber.with_observer(|| {
subscriber.update_if_necessary()
})
if subscriber
.with_observer(|| subscriber.update_if_necessary())
{
subscriber.clear_sources(&subscriber);

View File

@@ -48,10 +48,10 @@
//!
//! ## Design Principles and Assumptions
//! - **Effects are expensive.** The library is built on the assumption that the side effects
//! (making a network request, rendering something to the DOM, writing to disk) are orders of
//! magnitude more expensive than propagating signal updates. As a result, the algorithm is
//! designed to avoid re-running side effects unnecessarily, and is willing to sacrifice a small
//! amount of raw update speed to that goal.
//! (making a network request, rendering something to the DOM, writing to disk) are orders of
//! magnitude more expensive than propagating signal updates. As a result, the algorithm is
//! designed to avoid re-running side effects unnecessarily, and is willing to sacrifice a small
//! amount of raw update speed to that goal.
//! - **Automatic dependency tracking.** Dependencies are not specified as a compile-time list, but
//! tracked at runtime. This in turn enables **dynamic dependency tracking**: subscribers
//! unsubscribe from their sources between runs, which means that a subscriber that contains a

View File

@@ -60,38 +60,6 @@ pub struct Owner {
pub(crate) shared_context: Option<Arc<dyn SharedContext + Send + Sync>>,
}
impl Owner {
fn downgrade(&self) -> WeakOwner {
WeakOwner {
inner: Arc::downgrade(&self.inner),
#[cfg(feature = "hydration")]
shared_context: self.shared_context.as_ref().map(Arc::downgrade),
}
}
}
#[derive(Clone)]
struct WeakOwner {
inner: Weak<RwLock<OwnerInner>>,
#[cfg(feature = "hydration")]
shared_context: Option<Weak<dyn SharedContext + Send + Sync>>,
}
impl WeakOwner {
fn upgrade(&self) -> Option<Owner> {
self.inner.upgrade().map(|inner| {
#[cfg(feature = "hydration")]
let shared_context =
self.shared_context.as_ref().and_then(|sc| sc.upgrade());
Owner {
inner,
#[cfg(feature = "hydration")]
shared_context,
}
})
}
}
impl PartialEq for Owner {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.inner, &other.inner)
@@ -99,7 +67,7 @@ impl PartialEq for Owner {
}
thread_local! {
static OWNER: RefCell<Option<WeakOwner>> = Default::default();
static OWNER: RefCell<Option<Owner>> = Default::default();
}
impl Owner {
@@ -139,16 +107,12 @@ impl Owner {
/// Creates a new `Owner` and registers it as a child of the current `Owner`, if there is one.
pub fn new() -> Self {
#[cfg(not(feature = "hydration"))]
let parent = OWNER.with(|o| {
o.borrow()
.as_ref()
.and_then(|o| o.upgrade())
.map(|o| Arc::downgrade(&o.inner))
});
let parent = OWNER
.with(|o| o.borrow().as_ref().map(|o| Arc::downgrade(&o.inner)));
#[cfg(feature = "hydration")]
let (parent, shared_context) = OWNER
.with(|o| {
o.borrow().as_ref().and_then(|o| o.upgrade()).map(|o| {
o.borrow().as_ref().map(|o| {
(Some(Arc::downgrade(&o.inner)), o.shared_context.clone())
})
})
@@ -166,7 +130,6 @@ impl Owner {
.and_then(|parent| parent.upgrade())
.map(|parent| parent.read().or_poisoned().arena.clone())
.unwrap_or_default(),
paused: false,
})),
#[cfg(feature = "hydration")]
shared_context,
@@ -200,7 +163,6 @@ impl Owner {
children: Default::default(),
#[cfg(feature = "sandboxed-arenas")]
arena: Default::default(),
paused: false,
})),
#[cfg(feature = "hydration")]
shared_context,
@@ -212,10 +174,8 @@ impl Owner {
/// Creates a new `Owner` that is the child of the current `Owner`, if any.
pub fn child(&self) -> Self {
let parent = Some(Arc::downgrade(&self.inner));
let mut inner = self.inner.write().or_poisoned();
#[cfg(feature = "sandboxed-arenas")]
let arena = inner.arena.clone();
let paused = inner.paused;
let arena = self.inner.read().or_poisoned().arena.clone();
let child = Self {
inner: Arc::new(RwLock::new(OwnerInner {
parent,
@@ -225,18 +185,21 @@ impl Owner {
children: Default::default(),
#[cfg(feature = "sandboxed-arenas")]
arena,
paused,
})),
#[cfg(feature = "hydration")]
shared_context: self.shared_context.clone(),
};
inner.children.push(Arc::downgrade(&child.inner));
self.inner
.write()
.or_poisoned()
.children
.push(Arc::downgrade(&child.inner));
child
}
/// Sets this as the current `Owner`.
pub fn set(&self) {
OWNER.with_borrow_mut(|owner| *owner = Some(self.downgrade()));
OWNER.with_borrow_mut(|owner| *owner = Some(self.clone()));
#[cfg(feature = "sandboxed-arenas")]
Arena::set(&self.inner.read().or_poisoned().arena);
}
@@ -245,7 +208,7 @@ impl Owner {
pub fn with<T>(&self, fun: impl FnOnce() -> T) -> T {
let prev = {
OWNER.with(|o| {
Option::replace(&mut *o.borrow_mut(), self.downgrade())
mem::replace(&mut *o.borrow_mut(), Some(self.clone()))
})
};
#[cfg(feature = "sandboxed-arenas")]
@@ -293,7 +256,7 @@ impl Owner {
/// Returns the current `Owner`, if any.
pub fn current() -> Option<Owner> {
OWNER.with(|o| o.borrow().as_ref().and_then(|n| n.upgrade()))
OWNER.with(|o| o.borrow().clone())
}
/// Returns the [`SharedContext`] associated with this owner, if any.
@@ -307,7 +270,7 @@ impl Owner {
/// Removes this from its state as the thread-local owner and drops it.
pub fn unset(self) {
OWNER.with_borrow_mut(|owner| {
if owner.as_ref().and_then(|n| n.upgrade()) == Some(self) {
if owner.as_ref() == Some(&self) {
mem::take(owner);
}
})
@@ -320,7 +283,6 @@ impl Owner {
OWNER.with(|o| {
o.borrow()
.as_ref()
.and_then(|o| o.upgrade())
.and_then(|current| current.shared_context.clone())
})
}
@@ -334,7 +296,6 @@ impl Owner {
let sc = OWNER.with_borrow(|o| {
o.as_ref()
.and_then(|o| o.upgrade())
.and_then(|current| current.shared_context.clone())
});
match sc {
@@ -360,7 +321,6 @@ impl Owner {
let sc = OWNER.with_borrow(|o| {
o.as_ref()
.and_then(|o| o.upgrade())
.and_then(|current| current.shared_context.clone())
});
match sc {
@@ -377,47 +337,6 @@ impl Owner {
inner(Box::new(fun))
}
/// Pauses the execution of side effects for this owner, and any of its descendants.
///
/// If this owner is the owner for an [`Effect`](crate::effect::Effect) or [`RenderEffect`](crate::effect::RenderEffect), this effect will not run until [`Owner::resume`] is called. All children of this effects are also paused.
///
/// Any notifications will be ignored; effects that are notified will paused will not run when
/// resumed, until they are notified again by a source after being resumed.
pub fn pause(&self) {
let mut stack = Vec::with_capacity(16);
stack.push(Arc::downgrade(&self.inner));
while let Some(curr) = stack.pop() {
if let Some(curr) = curr.upgrade() {
let mut curr = curr.write().or_poisoned();
curr.paused = true;
stack.extend(curr.children.iter().map(Weak::clone));
}
}
}
/// Whether this owner has been paused by [`Owner::pause`].
pub fn paused(&self) -> bool {
self.inner.read().or_poisoned().paused
}
/// Resumes side effects that have been paused by [`Owner::pause`].
///
/// All children will also be resumed.
///
/// This will *not* cause side effects that were notified while paused to run, until they are
/// notified again by a source after being resumed.
pub fn resume(&self) {
let mut stack = Vec::with_capacity(16);
stack.push(Arc::downgrade(&self.inner));
while let Some(curr) = stack.pop() {
if let Some(curr) = curr.upgrade() {
let mut curr = curr.write().or_poisoned();
curr.paused = false;
stack.extend(curr.children.iter().map(Weak::clone));
}
}
}
}
#[doc(hidden)]
@@ -444,7 +363,6 @@ pub(crate) struct OwnerInner {
pub children: Vec<Weak<RwLock<OwnerInner>>>,
#[cfg(feature = "sandboxed-arenas")]
arena: Arc<RwLock<ArenaMap>>,
paused: bool,
}
impl Debug for OwnerInner {

View File

@@ -48,30 +48,20 @@ impl Arena {
fun(&MAP.get_or_init(Default::default).read().or_poisoned())
}
#[cfg(feature = "sandboxed-arenas")]
{
Arena::try_with(fun).unwrap_or_else(|| {
panic!(
"at {}, the `sandboxed-arenas` feature is active, but no \
Arena is active",
std::panic::Location::caller()
)
})
}
}
#[track_caller]
pub fn try_with<U>(fun: impl FnOnce(&ArenaMap) -> U) -> Option<U> {
#[cfg(not(feature = "sandboxed-arenas"))]
{
Some(fun(&MAP.get_or_init(Default::default).read().or_poisoned()))
}
#[cfg(feature = "sandboxed-arenas")]
{
MAP.with_borrow(|arena| {
arena
fun(&arena
.as_ref()
.and_then(Weak::upgrade)
.map(|n| fun(&n.read().or_poisoned()))
.unwrap_or_else(|| {
panic!(
"at {}, the `sandboxed-arenas` feature is active, \
but no Arena is active",
std::panic::Location::caller()
)
})
.read()
.or_poisoned())
})
}
}
@@ -84,32 +74,20 @@ impl Arena {
}
#[cfg(feature = "sandboxed-arenas")]
{
Arena::try_with_mut(fun).unwrap_or_else(|| {
panic!(
"at {}, the `sandboxed-arenas` feature is active, but no \
Arena is active",
std::panic::Location::caller()
)
})
}
}
#[track_caller]
pub fn try_with_mut<U>(fun: impl FnOnce(&mut ArenaMap) -> U) -> Option<U> {
#[cfg(not(feature = "sandboxed-arenas"))]
{
Some(fun(&mut MAP
.get_or_init(Default::default)
.write()
.or_poisoned()))
}
#[cfg(feature = "sandboxed-arenas")]
{
let caller = std::panic::Location::caller();
MAP.with_borrow(|arena| {
arena
fun(&mut arena
.as_ref()
.and_then(Weak::upgrade)
.map(|n| fun(&mut n.write().or_poisoned()))
.unwrap_or_else(|| {
panic!(
"at {}, the `sandboxed-arenas` feature is active, \
but no Arena is active",
caller
)
})
.write()
.or_poisoned())
})
}
}
@@ -148,7 +126,6 @@ pub mod sandboxed {
/// called.
///
/// [item]:[crate::owner::ArenaItem]
#[track_caller]
pub fn new(inner: T) -> Self {
let arena = MAP.with_borrow(|n| n.as_ref().and_then(Weak::upgrade));
Self { arena, inner }

View File

@@ -53,7 +53,7 @@ where
})
};
OWNER.with(|o| {
if let Some(owner) = o.borrow().as_ref().and_then(|o| o.upgrade()) {
if let Some(owner) = &*o.borrow() {
owner.register(node);
}
});

View File

@@ -76,26 +76,24 @@ where
}
fn try_with<U>(node: NodeId, fun: impl FnOnce(&T) -> U) -> Option<U> {
Arena::try_with(|arena| {
Arena::with(|arena| {
let m = arena.get(node);
m.and_then(|n| n.downcast_ref::<T>()).map(fun)
})
.flatten()
}
fn try_with_mut<U>(
node: NodeId,
fun: impl FnOnce(&mut T) -> U,
) -> Option<U> {
Arena::try_with_mut(|arena| {
Arena::with_mut(|arena| {
let m = arena.get_mut(node);
m.and_then(|n| n.downcast_mut::<T>()).map(fun)
})
.flatten()
}
fn try_set(node: NodeId, value: T) -> Option<T> {
Arena::try_with_mut(|arena| {
Arena::with_mut(|arena| {
let m = arena.get_mut(node);
match m.and_then(|n| n.downcast_mut::<T>()) {
Some(inner) => {
@@ -105,7 +103,6 @@ where
None => Some(value),
}
})
.flatten()
}
fn take(node: NodeId) -> Option<T> {

View File

@@ -55,7 +55,7 @@ impl<T: AsSubscriberSet + DefinedAt> ReactiveNode for T {
fn mark_subscribers_check(&self) {
if let Some(inner) = self.as_subscriber_set() {
let subs = inner.borrow().read().unwrap().clone();
let subs = inner.borrow().write().unwrap().take();
for sub in subs {
sub.mark_dirty();
}

View File

@@ -37,9 +37,9 @@ impl AsyncTransition {
let (tx, rx) = mpsc::channel();
let global_transition = global_transition();
let inner = TransitionInner { tx };
let prev = Option::replace(
let prev = std::mem::replace(
&mut *global_transition.write().or_poisoned(),
inner.clone(),
Some(inner.clone()),
);
let value = action().await;
_ = std::mem::replace(

View File

@@ -196,62 +196,3 @@ async fn recursive_effect_runs_recursively() {
})
.await;
}
#[cfg(feature = "effects")]
#[tokio::test]
async fn paused_effect_pauses() {
use imports::*;
use reactive_graph::owner::StoredValue;
_ = Executor::init_tokio();
let owner = Owner::new();
owner.set();
task::LocalSet::new()
.run_until(async {
let a = RwSignal::new(-1);
// simulate an arbitrary side effect
let runs = StoredValue::new(0);
let owner = StoredValue::new(None);
Effect::new({
move || {
*owner.write_value() = Owner::current();
let _ = a.get();
*runs.write_value() += 1;
}
});
Executor::tick().await;
assert_eq!(runs.get_value(), 1);
println!("setting to 1");
a.set(1);
Executor::tick().await;
assert_eq!(runs.get_value(), 2);
println!("pausing");
owner.get_value().unwrap().pause();
println!("setting to 2");
a.set(2);
Executor::tick().await;
assert_eq!(runs.get_value(), 2);
println!("resuming");
owner.get_value().unwrap().resume();
println!("setting to 3");
a.set(3);
Executor::tick().await;
println!("checking value");
assert_eq!(runs.get_value(), 3);
})
.await
}

View File

@@ -1,227 +0,0 @@
#[cfg(feature = "effects")]
pub mod imports {
pub use any_spawner::Executor;
pub use reactive_graph::{
effect::ImmediateEffect, owner::Owner, prelude::*, signal::RwSignal,
};
pub use std::sync::{Arc, RwLock};
pub use tokio::task;
}
#[cfg(feature = "effects")]
#[test]
fn effect_runs() {
use imports::*;
let owner = Owner::new();
owner.set();
let a = RwSignal::new(-1);
// simulate an arbitrary side effect
let b = Arc::new(RwLock::new(String::new()));
let _guard = ImmediateEffect::new({
let b = b.clone();
move || {
let formatted = format!("Value is {}", a.get());
*b.write().unwrap() = formatted;
}
});
assert_eq!(b.read().unwrap().as_str(), "Value is -1");
println!("setting to 1");
a.set(1);
assert_eq!(b.read().unwrap().as_str(), "Value is 1");
}
#[cfg(feature = "effects")]
#[test]
fn dynamic_dependencies() {
use imports::*;
let owner = Owner::new();
owner.set();
let first = RwSignal::new("Greg");
let last = RwSignal::new("Johnston");
let use_last = RwSignal::new(true);
let combined_count = Arc::new(RwLock::new(0));
let _guard = ImmediateEffect::new({
let combined_count = Arc::clone(&combined_count);
move || {
*combined_count.write().unwrap() += 1;
if use_last.get() {
println!("{} {}", first.get(), last.get());
} else {
println!("{}", first.get());
}
}
});
assert_eq!(*combined_count.read().unwrap(), 1);
println!("\nsetting `first` to Bob");
first.set("Bob");
assert_eq!(*combined_count.read().unwrap(), 2);
println!("\nsetting `last` to Bob");
last.set("Thompson");
assert_eq!(*combined_count.read().unwrap(), 3);
println!("\nsetting `use_last` to false");
use_last.set(false);
assert_eq!(*combined_count.read().unwrap(), 4);
println!("\nsetting `last` to Jones");
last.set("Jones");
assert_eq!(*combined_count.read().unwrap(), 4);
println!("\nsetting `last` to Jones");
last.set("Smith");
assert_eq!(*combined_count.read().unwrap(), 4);
println!("\nsetting `last` to Stevens");
last.set("Stevens");
assert_eq!(*combined_count.read().unwrap(), 4);
println!("\nsetting `use_last` to true");
use_last.set(true);
assert_eq!(*combined_count.read().unwrap(), 5);
}
#[cfg(feature = "effects")]
#[test]
fn recursive_effect_runs_recursively() {
use imports::*;
let owner = Owner::new();
owner.set();
let s = RwSignal::new(0);
let logged_values = Arc::new(RwLock::new(Vec::new()));
let _guard = ImmediateEffect::new({
let logged_values = Arc::clone(&logged_values);
move || {
let a = s.get();
println!("a = {a}");
logged_values.write().unwrap().push(a);
if a == 0 {
return;
}
s.set(0);
}
});
s.set(1);
s.set(2);
s.set(3);
assert_eq!(0, s.get_untracked());
assert_eq!(&*logged_values.read().unwrap(), &[0, 1, 0, 2, 0, 3, 0]);
}
#[cfg(feature = "effects")]
#[test]
fn paused_effect_pauses() {
use imports::*;
use reactive_graph::owner::StoredValue;
let owner = Owner::new();
owner.set();
let a = RwSignal::new(-1);
// simulate an arbitrary side effect
let runs = StoredValue::new(0);
let owner = StoredValue::new(None);
let _guard = ImmediateEffect::new({
move || {
*owner.write_value() = Owner::current();
let _ = a.get();
*runs.write_value() += 1;
}
});
assert_eq!(runs.get_value(), 1);
println!("setting to 1");
a.set(1);
assert_eq!(runs.get_value(), 2);
println!("pausing");
owner.get_value().unwrap().pause();
println!("setting to 2");
a.set(2);
assert_eq!(runs.get_value(), 2);
println!("resuming");
owner.get_value().unwrap().resume();
println!("setting to 3");
a.set(3);
println!("checking value");
assert_eq!(runs.get_value(), 3);
}
#[cfg(feature = "effects")]
#[test]
#[ignore = "Parallel signal access can panic."]
fn threaded_chaos_effect() {
use imports::*;
use reactive_graph::owner::StoredValue;
const SIGNAL_COUNT: usize = 5;
const THREAD_COUNT: usize = 10;
let owner = Owner::new();
owner.set();
let signals = vec![RwSignal::new(0); SIGNAL_COUNT];
let runs = StoredValue::new(0);
let _guard = ImmediateEffect::new({
let signals = signals.clone();
move || {
*runs.write_value() += 1;
let mut values = vec![];
for s in &signals {
let v = s.get();
values.push(v);
if v != 0 {
s.set(v - 1);
}
}
println!("{values:?}");
}
});
std::thread::scope(|s| {
for _ in 0..THREAD_COUNT {
let signals = signals.clone();
s.spawn(move || {
for s in &signals {
s.set(1);
}
});
}
});
assert_eq!(runs.get_value(), 1 + THREAD_COUNT * SIGNAL_COUNT);
let values: Vec<_> = signals.iter().map(|s| s.get_untracked()).collect();
println!("FINAL: {values:?}");
}

View File

@@ -1,6 +1,6 @@
[package]
name = "reactive_stores"
version = "0.1.8"
version = "0.1.5"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -11,7 +11,7 @@ edition.workspace = true
[dependencies]
guardian = "1.2"
itertools = { workspace = true }
itertools = "0.13.0"
or_poisoned = { workspace = true }
paste = "1.0"
reactive_graph = { workspace = true }
@@ -19,7 +19,7 @@ rustc-hash = "2.0"
reactive_stores_macro = { workspace = true }
[dev-dependencies]
tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] }
tokio = { version = "1.41", features = ["rt-multi-thread", "macros"] }
tokio-test = { version = "0.4.4" }
any_spawner = { workspace = true, features = ["futures-executor", "tokio"] }
reactive_graph = { workspace = true, features = ["effects"] }

View File

@@ -38,20 +38,6 @@ where
track_field: Arc<dyn Fn() + Send + Sync>,
}
impl<T> Debug for ArcField<T>
where
T: 'static,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut f = f.debug_struct("ArcField");
#[cfg(any(debug_assertions, leptos_debuginfo))]
let f = f.field("defined_at", &self.defined_at);
f.field("path", &self.path)
.field("trigger", &self.trigger)
.finish()
}
}
pub struct StoreFieldReader<T>(Box<dyn Deref<Target = T>>);
impl<T> StoreFieldReader<T> {

View File

@@ -10,6 +10,7 @@ use reactive_graph::{
DefinedAt, IsDisposed, Notify, ReadUntracked, Track, UntrackableGuard,
Write,
},
unwrap_signal,
};
use std::{
fmt::Debug,
@@ -31,19 +32,6 @@ where
inner: ArenaItem<ArcField<T>, S>,
}
impl<T, S> Debug for Field<T, S>
where
T: 'static,
S: Debug,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut f = f.debug_struct("Field");
#[cfg(any(debug_assertions, leptos_debuginfo))]
let f = f.field("defined_at", &self.defined_at);
f.field("inner", &self.inner).finish()
}
}
impl<T, S> StoreField for Field<T, S>
where
S: Storage<ArcField<T>>,
@@ -56,14 +44,14 @@ where
self.inner
.try_get_value()
.map(|inner| inner.get_trigger(path))
.unwrap_or_default()
.unwrap_or_else(unwrap_signal!(self))
}
fn path(&self) -> impl IntoIterator<Item = StorePathSegment> {
self.inner
.try_get_value()
.map(|inner| inner.path().into_iter().collect::<Vec<_>>())
.unwrap_or_default()
.unwrap_or_else(unwrap_signal!(self))
}
fn reader(&self) -> Option<Self::Reader> {
@@ -94,21 +82,6 @@ where
}
}
impl<T, S> From<ArcField<T>> for Field<T, S>
where
T: 'static,
S: Storage<ArcField<T>>,
{
#[track_caller]
fn from(value: ArcField<T>) -> Self {
Field {
#[cfg(any(debug_assertions, leptos_debuginfo))]
defined_at: Location::caller(),
inner: ArenaItem::new_with_storage(value),
}
}
}
impl<T, S> From<ArcStore<T>> for Field<T, S>
where
T: Send + Sync + 'static,

View File

@@ -148,8 +148,11 @@ where
{
fn latest_keys(&self) -> Vec<K> {
self.reader()
.map(|r| r.deref().into_iter().map(|n| (self.key_fn)(n)).collect())
.unwrap_or_default()
.expect("trying to update keys")
.deref()
.into_iter()
.map(|n| (self.key_fn)(n))
.collect()
}
}
@@ -480,7 +483,8 @@ where
|| self.inner.latest_keys(),
)
.flatten()
.map(|(_, idx)| idx)?;
.map(|(_, idx)| idx)
.expect("reading from a keyed field that has not yet been created");
Some(WriteGuard::new(
trigger.children,
@@ -650,15 +654,13 @@ where
self.track_field();
// get the current length of the field by accessing slice
let reader = self.reader();
let reader = self
.reader()
.expect("creating iterator from unavailable store field");
let keys = reader
.map(|r| {
r.into_iter()
.map(|item| (self.key_fn)(item))
.collect::<VecDeque<_>>()
})
.unwrap_or_default();
.into_iter()
.map(|item| (self.key_fn)(item))
.collect::<VecDeque<_>>();
// return the iterator
StoreFieldKeyedIter { inner: self, keys }

View File

@@ -1,5 +1,5 @@
use crate::{StoreField, Subfield};
use reactive_graph::traits::{FlattenOptionRefOption, Read, ReadUntracked};
use reactive_graph::traits::{Read, ReadUntracked};
use std::ops::Deref;
/// Extends optional store fields, with the ability to unwrap or map over them.
@@ -13,13 +13,6 @@ where
/// Provides access to the inner value, as a subfield, unwrapping the outer value.
fn unwrap(self) -> Subfield<Self, Option<Self::Output>, Self::Output>;
/// Inverts a subfield of an `Option` to an `Option` of a subfield.
fn invert(
self,
) -> Option<Subfield<Self, Option<Self::Output>, Self::Output>> {
self.map(|f| f)
}
/// Reactively maps over the field.
///
/// This returns `None` if the subfield is currently `None`,
@@ -63,7 +56,7 @@ where
self,
map_fn: impl FnOnce(Subfield<S, Option<T>, T>) -> U,
) -> Option<U> {
if self.try_read().as_deref().flatten().is_some() {
if self.read().is_some() {
Some(map_fn(self.unwrap()))
} else {
None
@@ -74,7 +67,7 @@ where
self,
map_fn: impl FnOnce(Subfield<S, Option<T>, T>) -> U,
) -> Option<U> {
if self.try_read_untracked().as_deref().flatten().is_some() {
if self.read_untracked().is_some() {
Some(map_fn(self.unwrap()))
} else {
None
@@ -84,12 +77,11 @@ where
#[cfg(test)]
mod tests {
use crate::{self as reactive_stores, Patch as _, Store};
use crate::{self as reactive_stores, Store};
use reactive_graph::{
effect::Effect,
traits::{Get, Read, ReadUntracked, Set, Write},
};
use reactive_stores_macro::Patch;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
@@ -245,115 +237,4 @@ mod tests {
assert_eq!(parent_count.load(Ordering::Relaxed), 3);
assert_eq!(inner_count.load(Ordering::Relaxed), 3);
}
#[tokio::test]
async fn patch() {
use crate::OptionStoreExt;
#[derive(Debug, Clone, Store, Patch)]
struct Outer {
inner: Option<Inner>,
}
#[derive(Debug, Clone, Store, Patch)]
struct Inner {
first: String,
second: String,
}
let store = Store::new(Outer {
inner: Some(Inner {
first: "A".to_owned(),
second: "B".to_owned(),
}),
});
_ = any_spawner::Executor::init_tokio();
let parent_count = Arc::new(AtomicUsize::new(0));
let inner_first_count = Arc::new(AtomicUsize::new(0));
let inner_second_count = Arc::new(AtomicUsize::new(0));
Effect::new_sync({
let parent_count = Arc::clone(&parent_count);
move |prev: Option<()>| {
if prev.is_none() {
println!("parent: first run");
} else {
println!("parent: next run");
}
println!(" value = {:?}", store.inner().get());
parent_count.fetch_add(1, Ordering::Relaxed);
}
});
Effect::new_sync({
let inner_first_count = Arc::clone(&inner_first_count);
move |prev: Option<()>| {
if prev.is_none() {
println!("inner_first: first run");
} else {
println!("inner_first: next run");
}
println!(
" value = {:?}",
store.inner().map(|inner| inner.first().get())
);
inner_first_count.fetch_add(1, Ordering::Relaxed);
}
});
Effect::new_sync({
let inner_second_count = Arc::clone(&inner_second_count);
move |prev: Option<()>| {
if prev.is_none() {
println!("inner_second: first run");
} else {
println!("inner_second: next run");
}
println!(
" value = {:?}",
store.inner().map(|inner| inner.second().get())
);
inner_second_count.fetch_add(1, Ordering::Relaxed);
}
});
tick().await;
assert_eq!(parent_count.load(Ordering::Relaxed), 1);
assert_eq!(inner_first_count.load(Ordering::Relaxed), 1);
assert_eq!(inner_second_count.load(Ordering::Relaxed), 1);
store.patch(Outer {
inner: Some(Inner {
first: "A".to_string(),
second: "C".to_string(),
}),
});
tick().await;
assert_eq!(parent_count.load(Ordering::Relaxed), 1);
assert_eq!(inner_first_count.load(Ordering::Relaxed), 1);
assert_eq!(inner_second_count.load(Ordering::Relaxed), 2);
store.patch(Outer { inner: None });
tick().await;
assert_eq!(parent_count.load(Ordering::Relaxed), 2);
assert_eq!(inner_first_count.load(Ordering::Relaxed), 2);
assert_eq!(inner_second_count.load(Ordering::Relaxed), 3);
store.patch(Outer {
inner: Some(Inner {
first: "A".to_string(),
second: "B".to_string(),
}),
});
tick().await;
assert_eq!(parent_count.load(Ordering::Relaxed), 3);
assert_eq!(inner_first_count.load(Ordering::Relaxed), 3);
assert_eq!(inner_second_count.load(Ordering::Relaxed), 4);
}
}

View File

@@ -114,35 +114,6 @@ patch_primitives! {
NonZeroUsize
}
impl<T> PatchField for Option<T>
where
T: PatchField,
{
fn patch_field(
&mut self,
new: Self,
path: &StorePath,
notify: &mut dyn FnMut(&StorePath),
) {
match (self, new) {
(None, None) => {}
(old @ Some(_), None) => {
old.take();
notify(path);
}
(old @ None, new @ Some(_)) => {
*old = new;
notify(path);
}
(Some(old), Some(new)) => {
let mut new_path = path.to_owned();
new_path.push(0);
old.patch_field(new, &new_path, notify);
}
}
}
}
impl<T> PatchField for Vec<T>
where
T: PatchField,

View File

@@ -9,7 +9,8 @@ use reactive_graph::{
guards::{Plain, UntrackedWriteGuard, WriteGuard},
ArcTrigger,
},
traits::{Track, UntrackableGuard},
traits::{DefinedAt, Track, UntrackableGuard},
unwrap_signal,
};
use std::{iter, ops::Deref, sync::Arc};
@@ -104,7 +105,7 @@ where
self.inner
.try_get_value()
.map(|n| n.get_trigger(path))
.unwrap_or_default()
.unwrap_or_else(unwrap_signal!(self))
}
#[track_caller]
@@ -112,7 +113,7 @@ where
self.inner
.try_get_value()
.map(|n| n.path().into_iter().collect::<Vec<_>>())
.unwrap_or_default()
.unwrap_or_else(unwrap_signal!(self))
}
#[track_caller]

View File

@@ -9,10 +9,9 @@ use reactive_graph::{
ArcTrigger,
},
traits::{
DefinedAt, Get as _, IsDisposed, Notify, ReadUntracked, Track,
UntrackableGuard, Write,
DefinedAt, IsDisposed, Notify, ReadUntracked, Track, UntrackableGuard,
Write,
},
wrappers::read::Signal,
};
use std::{iter, marker::PhantomData, ops::DerefMut, panic::Location};
@@ -100,9 +99,6 @@ where
let mut full_path = self.path().into_iter().collect::<StorePath>();
full_path.pop();
// build a list of triggers, starting with the full path to this node and ending with the root
// this will mean that the root is the final item, and this path is first
let mut triggers = Vec::with_capacity(full_path.len());
triggers.push(trigger.this.clone());
loop {
@@ -113,17 +109,6 @@ where
}
full_path.pop();
}
// when the WriteGuard is dropped, each trigger will be notified, in order
// reversing the list will cause the triggers to be notified starting from the root,
// then to each child down to this one
//
// notifying from the root down is important for things like OptionStoreExt::map()/unwrap(),
// where it's really important that any effects that subscribe to .is_some() run before effects
// that subscribe to the inner value, so that the inner effect can be canceled if the outer switches to `None`
// (see https://github.com/leptos-rs/leptos/issues/3704)
triggers.reverse();
let guard = WriteGuard::new(triggers, parent);
Some(MappedMut::new(guard, self.read, self.write))
@@ -238,14 +223,3 @@ where
})
}
}
impl<Inner, Prev, T> From<Subfield<Inner, Prev, T>> for Signal<T>
where
Inner: StoreField<Value = Prev> + Track + Send + Sync + 'static,
Prev: 'static,
T: Send + Sync + Clone + 'static,
{
fn from(subfield: Subfield<Inner, Prev, T>) -> Self {
Signal::derive(move || subfield.get())
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "reactive_stores_macro"
version = "0.1.8"
version = "0.1.5"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -13,7 +13,7 @@ edition.workspace = true
proc-macro = true
[dependencies]
convert_case = "0.7"
convert_case = "0.6"
proc-macro-error2 = "2.0"
proc-macro2 = "1.0"
quote = "1.0"

View File

@@ -79,7 +79,7 @@ impl Parse for Model {
#[derive(Clone)]
enum SubfieldMode {
Keyed(ExprClosure, Box<Type>),
Keyed(ExprClosure, Type),
Skip,
}
@@ -87,15 +87,15 @@ impl Parse for SubfieldMode {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let mode: Ident = input.parse()?;
if mode == "key" {
let _col: Token![:] = input.parse()?;
let _col: Token!(:) = input.parse()?;
let ty: Type = input.parse()?;
let _eq: Token![=] = input.parse()?;
let closure: ExprClosure = input.parse()?;
Ok(SubfieldMode::Keyed(closure, Box::new(ty)))
let _eq: Token!(=) = input.parse()?;
let ident: ExprClosure = input.parse()?;
Ok(SubfieldMode::Keyed(ident, ty))
} else if mode == "skip" {
Ok(SubfieldMode::Skip)
} else {
Err(input.error("expected `key: <Type> = <closure>`"))
Err(input.error("expected `key = <ident>: <Type>`"))
}
}
}
@@ -604,9 +604,9 @@ impl ToTokens for PatchModel {
let Field {
attrs, ident, ..
} = &field;
let locator = match &ident {
Some(ident) => Either::Left(ident),
None => Either::Right(Index::from(idx)),
let field_name = match &ident {
Some(ident) => quote! { #ident },
None => quote! { #idx },
};
let closure = attrs
.iter()
@@ -639,9 +639,9 @@ impl ToTokens for PatchModel {
let params = closure.inputs;
let body = closure.body;
quote! {
if new.#locator != self.#locator {
if new.#field_name != self.#field_name {
_ = {
let (#params) = (&mut self.#locator, new.#locator);
let (#params) = (&mut self.#field_name, new.#field_name);
#body
};
notify(&new_path);
@@ -651,8 +651,8 @@ impl ToTokens for PatchModel {
} else {
quote! {
#library_path::PatchField::patch_field(
&mut self.#locator,
new.#locator,
&mut self.#field_name,
new.#field_name,
&new_path,
notify
);
@@ -684,17 +684,3 @@ impl ToTokens for PatchModel {
});
}
}
enum Either<A, B> {
Left(A),
Right(B),
}
impl<A: ToTokens, B: ToTokens> ToTokens for Either<A, B> {
fn to_tokens(&self, tokens: &mut TokenStream) {
match self {
Either::Left(a) => a.to_tokens(tokens),
Either::Right(b) => b.to_tokens(tokens),
}
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_router"
version = "0.7.8"
version = "0.7.5"
authors = ["Greg Johnston", "Ben Wishovich"]
license = "MIT"
readme = "../README.md"
@@ -20,7 +20,7 @@ tachys = { workspace = true, features = ["reactive_graph"] }
futures = "0.3.31"
url = "2.5"
js-sys = { version = "0.3.74" }
wasm-bindgen = { workspace = true }
wasm-bindgen = { version = "0.2.97" }
tracing = { version = "0.1.41", optional = true }
once_cell = "1.20"
send_wrapper = "0.6.0"

View File

@@ -91,9 +91,7 @@ impl Url {
path.push_str(&self.search);
}
if !self.hash.is_empty() {
if !self.hash.starts_with('#') {
path.push('#');
}
path.push('#');
path.push_str(&self.hash);
}
path
@@ -125,20 +123,14 @@ impl Url {
#[cfg(not(feature = "ssr"))]
{
match js_sys::decode_uri_component(s) {
Ok(v) => v.into(),
Err(_) => s.into(),
}
js_sys::decode_uri_component(s).unwrap().into()
}
}
pub fn unescape_minimal(s: &str) -> String {
#[cfg(not(feature = "ssr"))]
{
match js_sys::decode_uri(s) {
Ok(v) => v.into(),
Err(_) => s.into(),
}
js_sys::decode_uri(s).unwrap().into()
}
#[cfg(feature = "ssr")]

View File

@@ -58,7 +58,7 @@ impl PossibleRouteMatch for ParamSegment {
}
}
if matched_len == 0 || (matched_len == 1 && path.starts_with('/')) {
if matched_len == 0 {
return None;
}
@@ -318,28 +318,6 @@ mod tests {
assert!(params.is_empty());
}
#[test]
fn static_before_param() {
let path = "/foo/bar";
let def = (StaticSegment("foo"), ParamSegment("b"));
let matched = def.test(path).expect("couldn't match route");
assert_eq!(matched.matched(), "/foo/bar");
assert_eq!(matched.remaining(), "");
let params = matched.params();
assert_eq!(params[0], ("b".into(), "bar".into()));
}
#[test]
fn static_before_optional_param() {
let path = "/foo/bar";
let def = (StaticSegment("foo"), OptionalParamSegment("b"));
let matched = def.test(path).expect("couldn't match route");
assert_eq!(matched.matched(), "/foo/bar");
assert_eq!(matched.remaining(), "");
let params = matched.params();
assert_eq!(params[0], ("b".into(), "bar".into()));
}
#[test]
fn multiple_optional_params_match_first() {
let path = "/foo/bar";

View File

@@ -25,10 +25,7 @@ macro_rules! tuples {
let mut p = Vec::new();
let mut m = String::new();
if $first::OPTIONAL {
nth_field += 1;
}
if !$first::OPTIONAL || nth_field <= include_optionals {
if !$first::OPTIONAL || nth_field < include_optionals {
match $first.test(r) {
None => {
return None;
@@ -46,7 +43,7 @@ macro_rules! tuples {
if $ty::OPTIONAL {
nth_field += 1;
}
if !$ty::OPTIONAL || nth_field <= include_optionals {
if !$ty::OPTIONAL || nth_field < include_optionals {
let PartialPathMatch {
remaining,
matched,

View File

@@ -458,7 +458,7 @@ where
}
}
type OutletViewFn = Box<dyn FnMut() -> Suspend<AnyView> + Send>;
type OutletViewFn = Box<dyn Fn() -> Suspend<AnyView> + Send>;
pub(crate) struct RouteContext {
id: RouteMatchId,
@@ -752,8 +752,8 @@ where
// assign a new owner, so that contexts and signals owned by the previous route
// in this outlet can be dropped
let mut old_owner =
Some(mem::replace(&mut current.owner, parent.child()));
let old_owner =
mem::replace(&mut current.owner, parent.child());
let owner = current.owner.clone();
let (full_tx, full_rx) = oneshot::channel();
let full_tx = Mutex::new(Some(full_tx));
@@ -780,7 +780,6 @@ where
let view = view.clone();
let full_tx =
full_tx.lock().or_poisoned().take();
let old_owner = old_owner.take();
Suspend::new(Box::pin(async move {
let view = SendWrapper::new(
owner.with(|| {
@@ -796,10 +795,6 @@ where
}),
);
let view = view.await;
if let Some(old_owner) = old_owner {
old_owner.cleanup();
}
if let Some(tx) = full_tx {
_ = tx.send(());
}
@@ -808,7 +803,7 @@ where
})
}))
});
drop(old_owner);
drop(old_params);
drop(old_url);
drop(old_matched);
@@ -892,7 +887,7 @@ where
trigger, view_fn, ..
} = ctx;
trigger.track();
let mut view_fn = view_fn.lock().or_poisoned();
let view_fn = view_fn.lock().or_poisoned();
view_fn()
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_router_macro"
version = "0.7.8"
version = "0.7.5"
authors = ["Greg Johnston", "Ben Wishovich"]
license = "MIT"
readme = "../README.md"

View File

@@ -1,45 +0,0 @@
#!/usr/bin/env bash
set -e
exit_error() {
echo "ERROR: $1" >&2
exit 1
}
current_dir=$(pwd)
if [[ "$current_dir" != */leptos ]]; then
exit_error "Current directory does not end with leptos"
fi
# Check if a date is provided as an argument
if [ $# -eq 1 ]; then
NEW_DATE=$1
echo -n "Will use the provided date: "
else
# Use current date if no date is provided
NEW_DATE=$(date +"%Y-%m-%d")
echo -n "Will use the current date: "
fi
echo "$NEW_DATE"
# Detect if it is darwin sed
if sed --version 2>/dev/null | grep -q GNU; then
SED_COMMAND="sed -i"
else
SED_COMMAND='sed -i ""'
fi
MATCH="nightly-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]"
REPLACE="nightly-$NEW_DATE"
# Find all occurrences of the pattern
git grep -l "$MATCH" | while read -r file; do
# Replace the pattern in each file
$SED_COMMAND "s/$MATCH/$REPLACE/g" "$file"
echo "Updated $file"
done
echo "All occurrences of 'nightly-XXXX-XX-XX' have been replaced with 'nightly-$NEW_DATE'"

4
server_fn/Cargo.lock generated
View File

@@ -198,7 +198,7 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "ahash"
version = "0.7.8"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a824f2aa7e75a0c98c5a504fceb80649e9c35265d44525b5f94de4771a395cd"
dependencies = [
@@ -880,7 +880,7 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
"ahash 0.7.7",
]
[[package]]

View File

@@ -42,7 +42,7 @@ multer = { version = "3.1", optional = true }
## output encodings
# serde
serde_json = { workspace = true }
serde_json = "1.0"
serde-lite = { version = "0.5.0", features = ["derive"], optional = true }
futures = "0.3.31"
http = { version = "1.1" }
@@ -57,8 +57,8 @@ rmp-serde = { version = "1.3.0", optional = true }
# client
gloo-net = { version = "0.6.0", optional = true }
js-sys = { version = "0.3.74", optional = true }
wasm-bindgen = { version = "0.2.100", optional = true }
wasm-bindgen-futures = { version = "0.4.50", optional = true }
wasm-bindgen = { version = "0.2.97", optional = true }
wasm-bindgen-futures = { version = "0.4.47", optional = true }
wasm-streams = { version = "0.4.2", optional = true }
web-sys = { version = "0.3.72", optional = true, features = [
"console",

View File

@@ -28,7 +28,7 @@ use syn::__private::ToTokens;
///
/// You can any combination of the following named arguments:
/// - `name`: sets the identifier for the server functions type, which is a struct created
/// to hold the arguments (defaults to the function identifier in PascalCase)
/// to hold the arguments (defaults to the function identifier in PascalCase)
/// - `prefix`: a prefix at which the server function handler will be mounted (defaults to `/api`)
/// - `endpoint`: specifies the exact path at which the server function handler will be mounted,
/// relative to the prefix (defaults to the function name followed by unique hash)

View File

@@ -1,6 +1,6 @@
[package]
name = "tachys"
version = "0.1.8"
version = "0.1.5"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -24,7 +24,7 @@ async-trait = "0.1.81"
dyn-clone = "1.0.17"
once_cell = "1.20"
paste = "1.0"
wasm-bindgen = { workspace = true }
wasm-bindgen = "0.2.97"
html-escape = "0.2.13"
js-sys = "0.3.74"
web-sys = { version = "0.3.72", features = [
@@ -154,7 +154,7 @@ indexmap = "2.6"
rustc-hash = "2.0"
futures = "0.3.31"
parking_lot = "0.12.3"
itertools = { workspace = true }
itertools = "0.13.0"
send_wrapper = "0.6.0"
linear-map = "1.2"
sledgehammer_bindgen = { version = "0.6.0", features = [
@@ -165,7 +165,7 @@ tracing = { version = "0.1.41", optional = true }
[dev-dependencies]
tokio-test = "0.4.4"
tokio = { version = "1.43", features = ["rt", "macros"] }
tokio = { version = "1.41", features = ["rt", "macros"] }
[features]
default = ["testing"]

View File

@@ -283,7 +283,7 @@ html_elements! {
/// The `<em>` HTML element marks text that has stress emphasis. The `<em>` element can be nested, with each level of nesting indicating a greater degree of emphasis.
em HtmlElement [] true,
/// The `<fieldset>` HTML element is used to group several controls as well as labels (label) within a web form.
fieldset HtmlFieldSetElement [disabled, form, name] true,
fieldset HtmlFieldSetElement [] true,
/// The `<figcaption>` HTML element represents a caption or legend describing the rest of the contents of its parent figure element.
figcaption HtmlElement [] true,
/// The `<figure>` HTML element represents self-contained content, potentially with an optional caption, which is specified using the figcaption element. The figure, its caption, and its contents are referenced as a single unit.

View File

@@ -167,9 +167,6 @@ where
el: &crate::renderer::types::Element,
cb: Box<dyn FnMut(crate::renderer::types::Event)>,
name: Cow<'static, str>,
// TODO investigate: does passing this as an option
// (rather than, say, having a const DELEGATED: bool)
// add to binary size?
delegation_key: Option<Cow<'static, str>>,
) -> RemoveEventHandler<crate::renderer::types::Element> {
match delegation_key {
@@ -204,39 +201,6 @@ where
.then(|| self.event.event_delegation_key()),
)
}
/// Attaches the event listener to the element as a listener that is triggered during the capture phase,
/// meaning it will fire before any event listeners further down in the DOM.
pub fn attach_capture(
self,
el: &crate::renderer::types::Element,
) -> RemoveEventHandler<crate::renderer::types::Element> {
fn attach_inner(
el: &crate::renderer::types::Element,
cb: Box<dyn FnMut(crate::renderer::types::Event)>,
name: Cow<'static, str>,
) -> RemoveEventHandler<crate::renderer::types::Element> {
Rndr::add_event_listener_use_capture(el, &name, cb)
}
let mut cb = self.cb.expect("callback removed before attaching").take();
#[cfg(feature = "tracing")]
let span = tracing::Span::current();
let cb = Box::new(move |ev: crate::renderer::types::Event| {
#[cfg(all(debug_assertions, feature = "reactive_graph"))]
let _rx_guard =
reactive_graph::diagnostics::SpecialNonReactiveZone::enter();
#[cfg(feature = "tracing")]
let _tracing_guard = span.enter();
let ev = E::EventType::from(ev);
cb.invoke(ev);
}) as Box<dyn FnMut(crate::renderer::types::Event)>;
attach_inner(el, cb, self.event.name())
}
}
impl<E, F> Debug for On<E, F>
@@ -286,21 +250,13 @@ where
self,
el: &crate::renderer::types::Element,
) -> Self::State {
let cleanup = if E::CAPTURE {
self.attach_capture(el)
} else {
self.attach(el)
};
let cleanup = self.attach(el);
(el.clone(), Some(cleanup))
}
#[inline(always)]
fn build(self, el: &crate::renderer::types::Element) -> Self::State {
let cleanup = if E::CAPTURE {
self.attach_capture(el)
} else {
self.attach(el)
};
let cleanup = self.attach(el);
(el.clone(), Some(cleanup))
}
@@ -310,11 +266,7 @@ where
if let Some(prev) = prev_cleanup.take() {
(prev.into_inner())(el);
}
*prev_cleanup = Some(if E::CAPTURE {
self.attach_capture(el)
} else {
self.attach(el)
});
*prev_cleanup = Some(self.attach(el));
}
fn into_cloneable(self) -> Self::Cloneable {
@@ -382,13 +334,10 @@ pub trait EventDescriptor: Clone {
/// Indicates if this event bubbles. For example, `click` bubbles,
/// but `focus` does not.
///
/// If this is true, then the event will be delegated globally if the `delegation`
/// feature is enabled. Otherwise, event listeners will be directly attached to the element.
/// If this is true, then the event will be delegated globally,
/// otherwise, event listeners will be directly attached to the element.
const BUBBLES: bool;
/// Indicates if this event should be handled during the capture phase.
const CAPTURE: bool = false;
/// The name of the event, such as `click` or `mouseover`.
fn name(&self) -> Cow<'static, str>;
@@ -403,32 +352,6 @@ pub trait EventDescriptor: Clone {
}
}
/// A wrapper that tells the framework to handle an event during the capture phase.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capture<E> {
inner: E,
}
/// Wraps an event to indicate that it should be handled during the capture phase.
pub fn capture<E>(event: E) -> Capture<E> {
Capture { inner: event }
}
impl<E: EventDescriptor> EventDescriptor for Capture<E> {
type EventType = E::EventType;
const CAPTURE: bool = true;
const BUBBLES: bool = E::BUBBLES;
fn name(&self) -> Cow<'static, str> {
self.inner.name()
}
fn event_delegation_key(&self) -> Cow<'static, str> {
self.inner.event_delegation_key()
}
}
/// A custom event.
#[derive(Debug)]
pub struct Custom<E: FromWasmAbi = web_sys::Event> {

View File

@@ -1,7 +1,6 @@
use crate::html::{element::ElementType, node_ref::NodeRefContainer};
use reactive_graph::{
effect::Effect,
graph::untrack,
signal::{
guards::{Derefable, ReadGuard},
RwSignal,
@@ -47,10 +46,7 @@ where
Effect::new(move |_| {
if let Some(node_ref) = self.get() {
let f = f.take().unwrap();
untrack(move || {
f(node_ref);
});
f.take().unwrap()(node_ref);
}
});
}

View File

@@ -27,7 +27,7 @@ use reactive_graph::{
use std::{
cell::RefCell,
fmt::Debug,
future::Future,
future::{Future, IntoFuture},
mem,
pin::Pin,
rc::Rc,
@@ -115,11 +115,15 @@ impl ToAnySubscriber for SuspendSubscriber {
impl<T> Suspend<T> {
/// Creates a new suspended view.
pub fn new(fut: impl Future<Output = T> + Send + 'static) -> Self {
pub fn new<Fut>(fut: Fut) -> Self
where
Fut: IntoFuture<Output = T>,
Fut::IntoFuture: Send + 'static,
{
let subscriber = SuspendSubscriber::new();
let any_subscriber = subscriber.to_any_subscriber();
let inner =
any_subscriber.with_observer(|| Box::pin(ScopedFuture::new(fut)));
let inner = any_subscriber
.with_observer(|| Box::pin(ScopedFuture::new(fut.into_future())));
Self { subscriber, inner }
}
}

View File

@@ -13,7 +13,7 @@ use once_cell::unsync::Lazy;
use rustc_hash::FxHashSet;
use std::{any::TypeId, borrow::Cow, cell::RefCell};
use wasm_bindgen::{intern, prelude::Closure, JsCast, JsValue};
use web_sys::{AddEventListenerOptions, Comment, HtmlTemplateElement};
use web_sys::{Comment, HtmlTemplateElement};
/// A [`Renderer`](crate::renderer::Renderer) that uses `web-sys` to manipulate DOM elements in the browser.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -245,44 +245,6 @@ impl Dom {
})
}
pub fn add_event_listener_use_capture(
el: &Element,
name: &str,
cb: Box<dyn FnMut(Event)>,
) -> RemoveEventHandler<Element> {
let cb = wasm_bindgen::closure::Closure::wrap(cb);
let name = intern(name);
let options = AddEventListenerOptions::new();
options.set_capture(true);
or_debug!(
el.add_event_listener_with_callback_and_add_event_listener_options(
name,
cb.as_ref().unchecked_ref(),
&options
),
el,
"addEventListenerUseCapture"
);
// return the remover
RemoveEventHandler::new({
let name = name.to_owned();
// safe to construct this here, because it will only run in the browser
// so it will always be accessed or dropped from the main thread
let cb = send_wrapper::SendWrapper::new(cb);
move |el: &Element| {
or_debug!(
el.remove_event_listener_with_callback(
intern(&name),
cb.as_ref().unchecked_ref()
),
el,
"removeEventListener"
)
}
})
}
pub fn event_target<T>(ev: &Event) -> T
where
T: CastFrom<Element>,

View File

@@ -3,9 +3,7 @@ use super::{
Render, RenderHtml,
};
use crate::{
html::attribute::{Attribute, NextAttribute},
hydration::Cursor,
ssr::StreamBuilder,
html::attribute::Attribute, hydration::Cursor, ssr::StreamBuilder,
};
use either_of::*;
use futures::future::join;
@@ -116,150 +114,6 @@ const fn max_usize(vals: &[usize]) -> usize {
max
}
#[cfg(not(erase_components))]
impl<A, B> NextAttribute for Either<A, B>
where
B: NextAttribute,
A: NextAttribute,
{
type Output<NewAttr: Attribute> = Either<
<A as NextAttribute>::Output<NewAttr>,
<B as NextAttribute>::Output<NewAttr>,
>;
fn add_any_attr<NewAttr: Attribute>(
self,
new_attr: NewAttr,
) -> Self::Output<NewAttr> {
match self {
Either::Left(left) => Either::Left(left.add_any_attr(new_attr)),
Either::Right(right) => Either::Right(right.add_any_attr(new_attr)),
}
}
}
#[cfg(erase_components)]
use crate::html::attribute::any_attribute::{AnyAttribute, IntoAnyAttribute};
#[cfg(erase_components)]
impl<A, B> NextAttribute for Either<A, B>
where
B: IntoAnyAttribute,
A: IntoAnyAttribute,
{
type Output<NewAttr: Attribute> = Vec<AnyAttribute>;
fn add_any_attr<NewAttr: Attribute>(
self,
new_attr: NewAttr,
) -> Self::Output<NewAttr> {
vec![
match self {
Either::Left(left) => left.into_any_attr(),
Either::Right(right) => right.into_any_attr(),
},
new_attr.into_any_attr(),
]
}
}
impl<A, B> Attribute for Either<A, B>
where
B: Attribute,
A: Attribute,
{
const MIN_LENGTH: usize = max_usize(&[A::MIN_LENGTH, B::MIN_LENGTH]);
type AsyncOutput = Either<A::AsyncOutput, B::AsyncOutput>;
type State = Either<A::State, B::State>;
type Cloneable = Either<A::Cloneable, B::Cloneable>;
type CloneableOwned = Either<A::CloneableOwned, B::CloneableOwned>;
fn html_len(&self) -> usize {
match self {
Either::Left(left) => left.html_len(),
Either::Right(right) => right.html_len(),
}
}
fn to_html(
self,
buf: &mut String,
class: &mut String,
style: &mut String,
inner_html: &mut String,
) {
match self {
Either::Left(left) => left.to_html(buf, class, style, inner_html),
Either::Right(right) => {
right.to_html(buf, class, style, inner_html)
}
}
}
fn hydrate<const FROM_SERVER: bool>(
self,
el: &crate::renderer::types::Element,
) -> Self::State {
match self {
Either::Left(left) => Either::Left(left.hydrate::<FROM_SERVER>(el)),
Either::Right(right) => {
Either::Right(right.hydrate::<FROM_SERVER>(el))
}
}
}
fn build(self, el: &crate::renderer::types::Element) -> Self::State {
match self {
Either::Left(left) => Either::Left(left.build(el)),
Either::Right(right) => Either::Right(right.build(el)),
}
}
fn rebuild(self, state: &mut Self::State) {
match self {
Either::Left(left) => {
if let Some(state) = state.as_left_mut() {
left.rebuild(state)
}
}
Either::Right(right) => {
if let Some(state) = state.as_right_mut() {
right.rebuild(state)
}
}
}
}
fn into_cloneable(self) -> Self::Cloneable {
match self {
Either::Left(left) => Either::Left(left.into_cloneable()),
Either::Right(right) => Either::Right(right.into_cloneable()),
}
}
fn into_cloneable_owned(self) -> Self::CloneableOwned {
match self {
Either::Left(left) => Either::Left(left.into_cloneable_owned()),
Either::Right(right) => Either::Right(right.into_cloneable_owned()),
}
}
fn dry_resolve(&mut self) {
match self {
Either::Left(left) => left.dry_resolve(),
Either::Right(right) => right.dry_resolve(),
}
}
async fn resolve(self) -> Self::AsyncOutput {
match self {
Either::Left(left) => Either::Left(left.resolve().await),
Either::Right(right) => Either::Right(right.resolve().await),
}
}
}
impl<A, B> RenderHtml for Either<A, B>
where
A: RenderHtml,

View File

@@ -290,7 +290,6 @@ where
child.to_html_with_buf(buf, position, escape, mark_branches);
}
buf.push_str("<!>");
*position = Position::NextChild;
}
fn to_html_async_with_buf<const OUT_OF_ORDER: bool>(
@@ -320,7 +319,6 @@ where
);
}
buf.push_sync("<!>");
*position = Position::NextChild;
}
fn hydrate<const FROM_SERVER: bool>(
@@ -334,7 +332,6 @@ where
.collect();
let marker = cursor.next_placeholder(position);
position.set(Position::NextChild);
VecState { states, marker }
}

View File

@@ -276,7 +276,6 @@ where
rendered_items.push(Some((set_index, item)));
}
let marker = cursor.next_placeholder(position);
position.set(Position::NextChild);
KeyedState {
parent: Some(parent),
marker,

View File

@@ -3,12 +3,7 @@ use super::{
RenderHtml, ToTemplate,
};
use crate::{
html::attribute::{
maybe_next_attr_erasure_macros::{
next_attr_combine, next_attr_output_type,
},
Attribute, AttributeKey, AttributeValue, NextAttribute,
},
html::attribute::{Attribute, AttributeKey, AttributeValue, NextAttribute},
hydration::Cursor,
renderer::{CastFrom, Rndr},
};
@@ -116,13 +111,13 @@ impl<K, const V: &'static str> NextAttribute for StaticAttr<K, V>
where
K: AttributeKey,
{
next_attr_output_type!(Self, NewAttr);
type Output<NewAttr: Attribute> = (Self, NewAttr);
fn add_any_attr<NewAttr: Attribute>(
self,
new_attr: NewAttr,
) -> Self::Output<NewAttr> {
next_attr_combine!(StaticAttr::<K, V> { ty: PhantomData }, new_attr)
(StaticAttr::<K, V> { ty: PhantomData }, new_attr)
}
}

View File

@@ -45,9 +45,7 @@ impl RenderHtml for () {
cursor: &Cursor,
position: &PositionState,
) -> Self::State {
let marker = cursor.next_placeholder(position);
position.set(Position::NextChild);
marker
cursor.next_placeholder(position)
}
async fn resolve(self) -> Self::AsyncOutput {}