Compare commits

...

63 Commits

Author SHA1 Message Date
Greg Johnston
3a8508df6c rc1 2024-11-03 20:19:57 -05:00
Daniëlle Huisman
865c6df483 wasm-bindgen 0.2.95 (#3186) 2024-11-03 20:19:21 -05:00
Greg Johnston
c1d7f0f8d1 fix: exclude excluded server fn paths instead of unregistering them (closes #3150, #3175) (#3176) 2024-11-03 20:02:11 -05:00
zakstucke
8c2dd73b70 chore: expose internals of SerializedDataId and SsrSharedContext to allow creating custom hydration contexts (#3145) 2024-11-03 19:55:59 -05:00
Greg Johnston
d5894555cc fix: allow !Send errors in Actix extract() (#3189) 2024-11-03 19:53:24 -05:00
Enzo Nocera
2ef1723607 Makes the wasm32-wasip1/2 target a first-class citizen for Leptos's Server-Side (#3063)
* feat: WIP wasi integrations crate

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(server_fn): add generic types

This commit adds `From` implementations for the
`Req` and `Res` types using abstraction that are deemed
"platform-agnostic".

Indeed, both the `http` and `bytes` crates contains types
that allows us to represent HTTP Request and Response,
while being capable to target unconventional platforms
(they even have `no-std` support). This allows the
server_fn functions to target new platforms,
for example, the `wasm32-wasip*` targets.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(server_fn): generic types cleanup

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(integrations/wasi): make WASI a first-class citizen of leptos server-side

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* WIP: chore(any_spawner): make the futures::Executor runable

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* fix(server_fn): include `generic` in axum.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(any_spawner): some clippy suggestions

I ran clippy in really annoying mode since I am still
learning Rust and I want to write clean idiomatic code.
I took suggestions that I thought made sense, if any
maintainers think those are *too much*, I can relax
those changes:

* Use `core` instead of `std` to ease migration to `no_std`
  (https://rust-lang.github.io/rust-clippy/master/index.html#/std_instead_of_core)
* Add documentation on exported types and statics
* Bring some types in, with `use`
* Add `#[non_exhaustive]` on types we are not sure we
  won't extend (https://rust-lang.github.io/rust-clippy/master/index.html#exhaustive_enums)
* Add `#[inline]` to help the compiler when doing
  cross-crate compilation and Link-Time optimization
  is not enabled. (https://rust-lang.github.io/rust-clippy/master/index.html#/missing_inline_in_public_items)
* Use generic types instead of anonymous `impl` params
  so callers can use the `::<>` turbofish syntax (https://rust-lang.github.io/rust-clippy/master/index.html#/impl_trait_in_params)

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(leptos_wasi): fine-tune linter and clean-up

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(leptos_wasi): better handling of server fn with form

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: cargo fmt

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: remove custom clippy

Remove clippy crate rules since it
seems to make tests fails in tests.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: use `wasi` crate

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: revert changes to any_spawner

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: simpler crate features + cleanup

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(any_spawner): add local custom executor

This commit adds a single-thread "local"
custom executor, which is useful for environments
like `wasm32` targets.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(leptos_wasi): async runtime

This commit adds a single-threaded
async runtime for `wasm32-wasip*`
targets.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* feat(leptos_wasi): error handling

This commit adds error types for the users
to implement better error handling.

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: migrate integration off-tree

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(ci): fix formatting

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore: remove ref to leptos_wasi in Cargo.toml

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(ci): fix fmt

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(ci): remove explicit into_inter()

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

* chore(ci): make generic mutually exclusive with other options

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>

---------

Signed-off-by: Enzo "raskyld" Nocera <enzo@nocera.eu>
2024-11-02 09:44:50 -07:00
Greg Johnston
13f7387d45 fix: Oco equality check (closes #3178) (#3180) 2024-11-01 14:55:49 -04:00
Greg Johnston
0a13f7c08c chore: reexport unescape (closes #3177) (#3179) 2024-11-01 14:55:37 -04:00
Greg Johnston
7c83904aea Merge pull request #3173 from leptos-rs/store-tweaks
Store tweaks
2024-10-30 20:33:10 -04:00
Greg Johnston
6e13ff9787 feat: impl Into<Field<T>> for Store<T> (closes #3102) 2024-10-28 20:28:09 -04:00
Greg Johnston
234d138f03 chore: remove log 2024-10-28 20:17:23 -04:00
Greg Johnston
97110cd5ac chore: remove Then 2024-10-28 20:17:23 -04:00
Greg Johnston
5acc1b1a5a chore: rename .iter() to .iter_unkeyed() for clarity 2024-10-28 20:16:54 -04:00
Nicolas Cura
f3987246cb docs: remove duplicated "calls" word (#3171) 2024-10-28 20:04:33 -04:00
Greg Johnston
e5149fb348 fix: correctly track inner subfields on Field (closes #3169) (#3170) 2024-10-28 20:04:16 -04:00
Greg Johnston
d67ff03568 chore: fix leptos_dom reexports (closes #3166) (#3168) 2024-10-27 21:12:41 -04:00
Greg Johnston
1dbca3005d Merge pull request #3163 from leptos-rs/undep-mp
chores
2024-10-25 17:47:14 -04:00
Greg Johnston
af61be0c72 fix: correctly reset classes when using Option<T> (#3164) 2024-10-25 17:47:00 -04:00
Johannes Heuel
76facf9539 feat: improve tailwind config to also catch dynamic classes (#3143) 2024-10-25 14:06:19 -04:00
Tommy Yu
0e73d18d7b ci: regression tests for double suspense/double resource fetch (#3103)
* test: first cut of the instrumented suspense_tests

- Based on initial concepts developed for reproducing #2937 and others,
  streamlined and instrumented for e2e testing and refined for inclusion
  as a standalone module to be plugged into some other App.

* First cut of the fixtures and tests

* Actually make it work properly

- Instead of using examples, just feed it the table because examples
  will rerun the whole scenario from scratch, which isn't what we are
  trying to test here.
- Provide a basic example with item listing to show how this will work.

* Use ticketing system to disambiguate CSR calls

- Keep all SSR calls on ticket 0 as a means to disambiguate them from
  CSR calls.  For the mean time the focus of tests isn't on that
  behavior but this may be modified when suitable.

* Update the baseline fixtures

- Given the new understanding, the scenerios all being the baseline
  tests they are now moved into one file.
- Have the checks against all calls at once for better diff output,
  and reword the new scenerios into more idomatic gherkin.
- Streamline the steps and provide additional ones that will help with
  feature definitions.

* Translate the reproduction steps into Gherkin

* Comment out logging to avoid output interference.

* Be able to reset CSR counters everywhere

- Done by providing a button directly on the top level component with
  the navigation footer.  This will be useful for the next test.

* Test showing difference between hydrate and CSR

- Specifically, under hydrated load, resources that shouldn't need
  refetching gets refetched, while CSR does not show this issue.
2024-10-25 14:05:42 -04:00
Greg Johnston
d306a15f86 fix: avoid deadlocking if can't take Memo write lock (closes #3158) (#3160) 2024-10-25 13:57:44 -04:00
Greg Johnston
bf95648dc9 chore: clippy doc comment length 2024-10-25 13:56:02 -04:00
Greg Johnston
00edfc0e0a chore: undeprecate MaybeProp 2024-10-25 13:49:19 -04:00
zakstucke
396327b667 feat: Option<T> read-like traits & deprecate MaybeSignal (#3098) 2024-10-25 13:41:26 -04:00
zakstucke
a437289f81 feat: impl IntoStyle for Option<impl IntoStyle & Oco (#3119) 2024-10-25 13:31:07 -04:00
zakstucke
58e7897db6 fix: From<> impls between ArcLocalResource and LocalResource (#3144) 2024-10-25 13:30:18 -04:00
Greg Johnston
1be1f41fba fix: restore array syntax for setting multiple classes dynamically (closes #3151) (#3152) 2024-10-23 20:10:04 -04:00
Greg Johnston
7b8cd90a6e 0.7.0-rc0 2024-10-21 21:16:20 -04:00
Greg Johnston
d0ef7b904d feat: add OptionalParamSegment (closes #2896) (#3140) 2024-10-21 21:15:14 -04:00
Greg Johnston
7904e0c395 fix: unregister server functions whose paths are in excluded routes (closes #2735) (#3138) 2024-10-21 09:14:36 -04:00
Greg Johnston
7b4c470155 perf: type erasure in router (#3107) 2024-10-20 20:07:14 -04:00
Greg Johnston
98eccc9eb8 perf: make LeptosOptions lighter-weight to clone (closes #3036) (#3136) 2024-10-20 20:05:29 -04:00
Greg Johnston
70d06e2716 feat: Action::clear() to clear action value (closes #2364) (#3135) 2024-10-20 16:29:05 -04:00
PenguinWithATie
67c3bf2478 chore: add tachys::view::fragment::Fragment to prelude (#3125) 2024-10-20 14:15:15 -04:00
Corvus
f3aaae857a feat: allow axum to serve precompressed files (#3133) 2024-10-19 20:47:35 -04:00
Greg Johnston
d727e53dd6 chore(ci): reduce tachys feature set combinations (#3131) 2024-10-19 20:45:49 -04:00
zakstucke
e4543ab5df feat: new nostrip: prop prefix to pass Option<T> directly when prop(optional) (#3105) 2024-10-19 15:41:51 -04:00
Greg Johnston
1ca0f4430c feat: use View Transition API for router animations (#3112) 2024-10-19 15:41:20 -04:00
Joaquim Pedro França Simão
b59fa11853 feat: add two-way data binding support for stores (#3115) 2024-10-19 15:39:45 -04:00
Greg Johnston
e55f08e017 feat: expose use_matched() (closes #3124) (#3126) 2024-10-18 16:12:41 -04:00
zakstucke
fa1939e5b2 chore: From<ArcResource> for ArcResource and AsyncDerived (#3121) 2024-10-18 16:12:11 -04:00
zakstucke
8b2f0eaf44 fix: do not warn when reading resources in effect outside Suspense (#3118) 2024-10-18 15:24:09 -04:00
Chris
b118d69281 fix: remove unused Params attribute params (#3123)
See 1966 for original PR on older version
2024-10-18 15:20:45 -04:00
stefnotch
ee66f6c395 Add support for user-supplied executors (#3091) 2024-10-16 06:24:07 -07:00
Greg Johnston
eba08ad592 fix: don't render empty string as a space in unescaped elements (closes #3120) (#3122) 2024-10-15 18:57:09 -04:00
Greg Johnston
4833b4e287 fix: avoid double-polling synchronously-available Suspend (closes #3113) (#3114) 2024-10-15 08:49:40 -04:00
Greg Johnston
9d1be64e4d chore: publish stores (#3110) 2024-10-14 10:18:38 -04:00
benwis
d6e6cd3be0 v0.7.0gamma3 2024-10-14 05:01:19 -07:00
stefnotch
70476f9277 feat: add support for async-executor from smol-rs (#3090) 2024-10-14 07:57:19 -04:00
zakstucke
d8ddfc26e9 perf: use the Track trait for the Signal wrapper. (#3076) 2024-10-12 20:29:03 -04:00
stefnotch
c8acc3e8bd fix: correctly support local pools for futures-executor (#3089) 2024-10-12 20:13:50 -04:00
zakstucke
547442243b impl IntoClass for Option<impl IntoClass> (#3104) 2024-10-12 05:03:53 -07:00
Greg Johnston
6e58266f54 feat: support set_is_routing/RoutingProgress for nested routes (#3101) 2024-10-11 19:05:33 -04:00
Greg Johnston
f0cd0fb41d feat: condense Router/Routes base prop into one (#3100) 2024-10-11 14:06:11 -04:00
Daniil Polyakov
7585faf57e fix: use full path to Result in Params derive (#3096) 2024-10-10 15:20:38 -04:00
zakstucke
da7f6a34e8 chore: expose AnyView in prelude (#3099) 2024-10-10 15:20:24 -04:00
Greg Johnston
4f7fa41262 fix: don't on WASM server targets unless you actually try to generate static routes (closes #3094) (#3097) 2024-10-10 15:20:04 -04:00
Greg Johnston
4becfa39ca correct version number 2024-10-10 09:13:39 -04:00
Greg Johnston
f8388b122d fix: avoid reentering lock when initializing nested keyed store fields (closes #3086) (#3087) 2024-10-10 08:53:28 -04:00
Greg Johnston
f57a57b92b feat: restore AnimatedShow for 0.7 (#3084) 2024-10-10 08:53:05 -04:00
vsuryamurthy
f0bcbd9cfe remove unused dependencies leptos_axum and leptos_router (#2960)
* remove unused dependencies leptos_axum and leptos_router

* cargo fmt

* Restore http::Uri under default feature

* use axum re-exported headers instead of http directly

---------

Co-authored-by: Greg Johnston <greg.johnston@gmail.com>
2024-10-10 04:29:11 -07:00
Greg Johnston
115477ef1d chore: remove unused code from leptos package (#3085) 2024-10-10 04:23:37 -07:00
Greg Johnston
832b9cb321 chore: pin wasm-bindgen to 0.2.93 to fix example builds (#3088) 2024-10-09 22:56:05 -04:00
153 changed files with 5275 additions and 2018 deletions

View File

@@ -40,36 +40,36 @@ members = [
exclude = ["benchmarks", "examples", "projects"]
[workspace.package]
version = "0.7.0-gamma"
version = "0.7.0-rc1"
edition = "2021"
rust-version = "1.76"
[workspace.dependencies]
throw_error = { path = "./any_error/", version = "0.2.0-gamma" }
throw_error = { path = "./any_error/", version = "0.2.0-rc1" }
any_spawner = { path = "./any_spawner/", version = "0.1.0" }
const_str_slice_concat = { path = "./const_str_slice_concat", version = "0.1.0" }
either_of = { path = "./either_of/", version = "0.1.0" }
hydration_context = { path = "./hydration_context", version = "0.2.0-gamma" }
leptos = { path = "./leptos", version = "0.7.0-gamma" }
leptos_config = { path = "./leptos_config", version = "0.7.0-gamma" }
leptos_dom = { path = "./leptos_dom", version = "0.7.0-gamma" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.0-gamma" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.0-gamma" }
leptos_macro = { path = "./leptos_macro", version = "0.7.0-gamma" }
leptos_router = { path = "./router", version = "0.7.0-gamma" }
leptos_router_macro = { path = "./router_macro", version = "0.7.0-gamma" }
leptos_server = { path = "./leptos_server", version = "0.7.0-gamma" }
leptos_meta = { path = "./meta", version = "0.7.0-gamma" }
next_tuple = { path = "./next_tuple", version = "0.1.0-gamma" }
hydration_context = { path = "./hydration_context", version = "0.2.0-rc1" }
leptos = { path = "./leptos", version = "0.7.0-rc1" }
leptos_config = { path = "./leptos_config", version = "0.7.0-rc1" }
leptos_dom = { path = "./leptos_dom", version = "0.7.0-rc1" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.0-rc1" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.0-rc1" }
leptos_macro = { path = "./leptos_macro", version = "0.7.0-rc1" }
leptos_router = { path = "./router", version = "0.7.0-rc1" }
leptos_router_macro = { path = "./router_macro", version = "0.7.0-rc1" }
leptos_server = { path = "./leptos_server", version = "0.7.0-rc1" }
leptos_meta = { path = "./meta", version = "0.7.0-rc1" }
next_tuple = { path = "./next_tuple", version = "0.1.0-rc1" }
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.0-gamma" }
reactive_stores = { path = "./reactive_stores", version = "0.1.0-gamma" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0-gamma" }
server_fn = { path = "./server_fn", version = "0.7.0-gamma" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.0-gamma" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.0-gamma" }
tachys = { path = "./tachys", version = "0.1.0-gamma" }
reactive_graph = { path = "./reactive_graph", version = "0.1.0-rc1" }
reactive_stores = { path = "./reactive_stores", version = "0.1.0-rc1" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0-rc1" }
server_fn = { path = "./server_fn", version = "0.7.0-rc1" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.0-rc1" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.0-rc1" }
tachys = { path = "./tachys", version = "0.1.0-rc1" }
[profile.release]
codegen-units = 1

View File

@@ -1,6 +1,6 @@
[package]
name = "throw_error"
version = "0.2.0-gamma"
version = "0.2.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"

View File

@@ -9,6 +9,7 @@ description = "Spawn asynchronous tasks in an executor-independent way."
edition.workspace = true
[dependencies]
async-executor = { version = "1.13.1", optional = true }
futures = "0.3.30"
glib = { version = "0.20.0", optional = true }
thiserror = "1.0"
@@ -19,12 +20,14 @@ tracing = { version = "0.1.40", optional = true }
wasm-bindgen-futures = { version = "0.4.42", optional = true }
[features]
async-executor = ["dep:async-executor"]
tracing = ["dep:tracing"]
tokio = ["dep:tokio"]
glib = ["dep:glib"]
wasm-bindgen = ["dep:wasm-bindgen-futures"]
futures-executor = ["futures/thread-pool", "futures/executor"]
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]

View File

@@ -32,11 +32,14 @@
use std::{future::Future, pin::Pin, sync::OnceLock};
use thiserror::Error;
pub(crate) type PinnedFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
pub(crate) type PinnedLocalFuture<T> = Pin<Box<dyn Future<Output = T>>>;
/// A future that has been pinned.
pub type PinnedFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
/// A future that has been pinned.
pub type PinnedLocalFuture<T> = Pin<Box<dyn Future<Output = T>>>;
static SPAWN: OnceLock<fn(PinnedFuture<()>)> = OnceLock::new();
static SPAWN_LOCAL: OnceLock<fn(PinnedLocalFuture<()>)> = OnceLock::new();
static POLL_LOCAL: OnceLock<fn()> = OnceLock::new();
/// Errors that can occur when using the executor.
#[derive(Error, Debug)]
@@ -115,6 +118,14 @@ impl Executor {
});
_ = rx.await;
}
/// Polls the current async executor.
/// Not all async executors support polling, so this function may not do anything.
pub fn poll_local() {
if let Some(poller) = POLL_LOCAL.get() {
poller()
}
}
}
impl Executor {
@@ -193,13 +204,15 @@ impl Executor {
#[cfg_attr(docsrs, doc(cfg(feature = "futures-executor")))]
pub fn init_futures_executor() -> Result<(), ExecutorError> {
use futures::{
executor::{LocalPool, ThreadPool},
executor::{LocalPool, LocalSpawner, ThreadPool},
task::{LocalSpawnExt, SpawnExt},
};
use std::cell::RefCell;
static THREAD_POOL: OnceLock<ThreadPool> = OnceLock::new();
thread_local! {
static LOCAL_POOL: LocalPool = LocalPool::new();
static LOCAL_POOL: RefCell<LocalPool> = RefCell::new(LocalPool::new());
static SPAWNER: LocalSpawner = LOCAL_POOL.with(|pool| pool.borrow().spawner());
}
fn get_thread_pool() -> &'static ThreadPool {
@@ -218,28 +231,131 @@ impl Executor {
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN_LOCAL
.set(|fut| {
LOCAL_POOL.with(|pool| {
let spawner = pool.spawner();
SPAWNER.with(|spawner| {
spawner.spawn_local(fut).expect("failed to spawn future");
});
})
.map_err(|_| ExecutorError::AlreadySet)?;
POLL_LOCAL
.set(|| {
LOCAL_POOL.with(|pool| {
if let Ok(mut pool) = pool.try_borrow_mut() {
pool.run_until_stalled();
}
// If we couldn't borrow_mut, we're in a nested call to poll, so we don't need to do anything.
});
})
.map_err(|_| ExecutorError::AlreadySet)?;
Ok(())
}
/// Globally sets the [`async_executor`] executor as the executor used to spawn tasks,
/// lazily creating a thread pool to spawn tasks into.
///
/// Returns `Err(_)` if an executor has already been set.
///
/// Requires the `async-executor` feature to be activated on this crate.
#[cfg(feature = "async-executor")]
#[cfg_attr(docsrs, doc(cfg(feature = "async-executor")))]
pub fn init_async_executor() -> Result<(), ExecutorError> {
use async_executor::{Executor, LocalExecutor};
static THREAD_POOL: OnceLock<Executor> = OnceLock::new();
thread_local! {
static LOCAL_POOL: LocalExecutor<'static> = const { LocalExecutor::new() };
}
fn get_thread_pool() -> &'static Executor<'static> {
THREAD_POOL.get_or_init(Executor::new)
}
SPAWN
.set(|fut| {
get_thread_pool().spawn(fut).detach();
})
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN_LOCAL
.set(|fut| {
LOCAL_POOL.with(|pool| pool.spawn(fut).detach());
})
.map_err(|_| ExecutorError::AlreadySet)?;
POLL_LOCAL
.set(|| {
LOCAL_POOL.with(|pool| pool.try_tick());
})
.map_err(|_| ExecutorError::AlreadySet)?;
Ok(())
}
/// Globally sets a custom executor as the executor used to spawn tasks.
///
/// Returns `Err(_)` if an executor has already been set.
pub fn init_custom_executor(
custom_executor: impl CustomExecutor + Send + Sync + 'static,
) -> Result<(), ExecutorError> {
static EXECUTOR: OnceLock<Box<dyn CustomExecutor + Send + Sync>> =
OnceLock::new();
EXECUTOR
.set(Box::new(custom_executor))
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN
.set(|fut| {
EXECUTOR.get().unwrap().spawn(fut);
})
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN_LOCAL
.set(|fut| EXECUTOR.get().unwrap().spawn_local(fut))
.map_err(|_| ExecutorError::AlreadySet)?;
POLL_LOCAL
.set(|| EXECUTOR.get().unwrap().poll_local())
.map_err(|_| ExecutorError::AlreadySet)?;
Ok(())
}
/// Locally sets a custom executor as the executor used to spawn tasks
/// in the current thread.
///
/// Returns `Err(_)` if an executor has already been set.
pub fn init_local_custom_executor(
custom_executor: impl CustomExecutor + 'static,
) -> Result<(), ExecutorError> {
thread_local! {
static EXECUTOR: OnceLock<Box<dyn CustomExecutor>> = OnceLock::new();
}
EXECUTOR.with(|this| {
this.set(Box::new(custom_executor))
.map_err(|_| ExecutorError::AlreadySet)
})?;
SPAWN
.set(|fut| {
EXECUTOR.with(|this| this.get().unwrap().spawn(fut));
})
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN_LOCAL
.set(|fut| {
EXECUTOR.with(|this| this.get().unwrap().spawn_local(fut));
})
.map_err(|_| ExecutorError::AlreadySet)?;
POLL_LOCAL
.set(|| {
EXECUTOR.with(|this| this.get().unwrap().poll_local());
})
.map_err(|_| ExecutorError::AlreadySet)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "futures-executor")]
#[test]
fn can_spawn_local_future() {
use crate::Executor;
use std::rc::Rc;
Executor::init_futures_executor().expect("couldn't set executor");
let rc = Rc::new(());
Executor::spawn_local(async {
_ = rc;
});
Executor::spawn(async {});
}
/// A trait for custom executors.
/// Custom executors can be used to integrate with any executor that supports spawning futures.
///
/// All methods can be called recursively.
pub trait CustomExecutor {
/// Spawns a future, usually on a thread pool.
fn spawn(&self, fut: PinnedFuture<()>);
/// Spawns a local future. May require calling `poll_local` to make progress.
fn spawn_local(&self, fut: PinnedLocalFuture<()>);
/// Polls the executor, if it supports polling.
fn poll_local(&self);
}

View File

@@ -0,0 +1,55 @@
#[cfg(feature = "futures-executor")]
use any_spawner::{CustomExecutor, Executor, PinnedFuture, PinnedLocalFuture};
#[cfg(feature = "futures-executor")]
#[test]
fn can_create_custom_executor() {
use futures::{
executor::{LocalPool, LocalSpawner},
task::LocalSpawnExt,
};
use std::{
cell::RefCell,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};
thread_local! {
static LOCAL_POOL: RefCell<LocalPool> = RefCell::new(LocalPool::new());
static SPAWNER: LocalSpawner = LOCAL_POOL.with(|pool| pool.borrow().spawner());
}
struct CustomFutureExecutor;
impl CustomExecutor for CustomFutureExecutor {
fn spawn(&self, _fut: PinnedFuture<()>) {
panic!("not supported in this test");
}
fn spawn_local(&self, fut: PinnedLocalFuture<()>) {
SPAWNER.with(|spawner| {
spawner.spawn_local(fut).expect("failed to spawn future");
});
}
fn poll_local(&self) {
LOCAL_POOL.with(|pool| {
if let Ok(mut pool) = pool.try_borrow_mut() {
pool.run_until_stalled();
}
// If we couldn't borrow_mut, we're in a nested call to poll, so we don't need to do anything.
});
}
}
Executor::init_custom_executor(CustomFutureExecutor)
.expect("couldn't set executor");
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = Arc::clone(&counter);
Executor::spawn_local(async move {
counter_clone.store(1, Ordering::Release);
});
Executor::poll_local();
assert_eq!(counter.load(Ordering::Acquire), 1);
}

View File

@@ -0,0 +1,38 @@
#[cfg(feature = "futures-executor")]
use any_spawner::Executor;
// All tests in this file use the same executor.
#[cfg(feature = "futures-executor")]
#[test]
fn can_spawn_local_future() {
use std::rc::Rc;
let _ = Executor::init_futures_executor();
let rc = Rc::new(());
Executor::spawn_local(async {
_ = rc;
});
Executor::spawn(async {});
}
#[cfg(feature = "futures-executor")]
#[test]
fn can_make_local_progress() {
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
let _ = Executor::init_futures_executor();
let counter = Arc::new(AtomicUsize::new(0));
Executor::spawn_local({
let counter = Arc::clone(&counter);
async move {
assert_eq!(counter.fetch_add(1, Ordering::AcqRel), 0);
Executor::spawn_local(async {
// Should not crash
});
}
});
Executor::poll_local();
assert_eq!(counter.load(Ordering::Acquire), 1);
}

View File

@@ -26,6 +26,7 @@ async fn main() {
};
use axum_js_ssr::app::*;
use http_body_util::BodyExt;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};

View File

@@ -63,7 +63,7 @@ async fn main() -> std::io::Result<()> {
</html>
}
}})
.service(Files::new("/", site_root))
.service(Files::new("/", site_root.as_ref()))
})
.bind(&addr)?
.run()

View File

@@ -6,9 +6,7 @@ use leptos_axum::ResponseOptions;
// A basic function to display errors served by the error boundaries.
// Feel free to do more complicated things here than just displaying them.
#[component]
pub fn ErrorTemplate(
#[prop(into)] errors: MaybeSignal<Errors>,
) -> impl IntoView {
pub fn ErrorTemplate(#[prop(into)] errors: Signal<Errors>) -> impl IntoView {
// Get Errors from Signal
// Downcast lets us take a type that implements `std::error::Error`
let errors = Memo::new(move |_| {

View File

@@ -4,7 +4,7 @@ mod routes;
use leptos_meta::{provide_meta_context, Link, Meta, Stylesheet};
use leptos_router::{
components::{FlatRoutes, Route, Router, RoutingProgress},
ParamSegment, StaticSegment,
OptionalParamSegment, ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
use std::time::Duration;
@@ -28,9 +28,7 @@ pub fn App() -> impl IntoView {
<FlatRoutes fallback=|| "Not found.">
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
<Route path=OptionalParamSegment("stories") view=Stories/>
</FlatRoutes>
</main>
</Router>

View File

@@ -56,7 +56,7 @@ async fn main() -> std::io::Result<()> {
</html>
}
}})
.service(Files::new("/", site_root))
.service(Files::new("/", site_root.as_ref()))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?

View File

@@ -21,10 +21,16 @@ pub fn Nav() -> impl IntoView {
<A href="/job">
<strong>"Jobs"</strong>
</A>
<a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
<a
class="github"
href="http://github.com/leptos-rs/leptos"
target="_blank"
rel="noreferrer"
>
"Built with Leptos"
</a>
</nav>
</header>
}
.into_any()
}

View File

@@ -50,30 +50,42 @@ pub fn Stories() -> impl IntoView {
<div class="news-view">
<div class="news-list-nav">
<span>
{move || if page() > 1 {
Either::Left(view! {
<a class="page-link"
href=move || format!("/{}?page={}", story_type(), page() - 1)
aria-label="Previous Page"
>
"< prev"
</a>
})
} else {
Either::Right(view! {
<span class="page-link disabled" aria-hidden="true">
"< prev"
</span>
})
{move || {
if page() > 1 {
Either::Left(
view! {
<a
class="page-link"
href=move || {
format!("/{}?page={}", story_type(), page() - 1)
}
aria-label="Previous Page"
>
"< prev"
</a>
},
)
} else {
Either::Right(
view! {
<span class="page-link disabled" aria-hidden="true">
"< prev"
</span>
},
)
}
}}
</span>
<span>"page " {page}</span>
<Suspense>
<span class="page-link"
<span
class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
<a
href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
>
"more >"
@@ -83,14 +95,10 @@ pub fn Stories() -> impl IntoView {
</div>
<main class="news-list">
<div>
<Transition
fallback=move || view! { <p>"Loading..."</p> }
set_pending
>
<Show when=move || stories.read().as_ref().map(Option::is_none).unwrap_or(false)>
>
<p>"Error loading stories."</p>
</Show>
<Transition fallback=move || view! { <p>"Loading..."</p> } set_pending>
<Show when=move || {
stories.read().as_ref().map(Option::is_none).unwrap_or(false)
}>> <p>"Error loading stories."</p></Show>
<ul>
<For
each=move || stories.get().unwrap_or_default().unwrap_or_default()
@@ -105,54 +113,78 @@ pub fn Stories() -> impl IntoView {
</main>
</div>
}
.into_any()
}
#[component]
fn Story(story: api::Story) -> impl IntoView {
view! {
<li class="news-item">
<li class="news-item">
<span class="score">{story.points}</span>
<span class="title">
{if !story.url.starts_with("item?id=") {
Either::Left(view! {
<span>
<a href=story.url target="_blank" rel="noreferrer">
{story.title.clone()}
</a>
<span class="host">"("{story.domain}")"</span>
</span>
})
Either::Left(
view! {
<span>
<a href=story.url target="_blank" rel="noreferrer">
{story.title.clone()}
</a>
<span class="host">"(" {story.domain} ")"</span>
</span>
},
)
} else {
let title = story.title.clone();
Either::Right(view! { <A href=format!("/stories/{}", story.id)>{title}</A> })
}}
</span>
<br />
<br/>
<span class="meta">
{if story.story_type != "job" {
Either::Left(view! {
<span>
{"by "}
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
{format!(" {} | ", story.time_ago)}
<A href=format!("/stories/{}", story.id)>
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"discuss".into()
}}
</A>
</span>
})
Either::Left(
view! {
<span>
{"by "}
{story
.user
.map(|user| {
view! {
<A href=format!("/users/{user}")>{user.clone()}</A>
}
})} {format!(" {} | ", story.time_ago)}
<A href=format!(
"/stories/{}",
story.id,
)>
{if story.comments_count.unwrap_or_default() > 0 {
format!(
"{} comments",
story.comments_count.unwrap_or_default(),
)
} else {
"discuss".into()
}}
</A>
</span>
},
)
} else {
let title = story.title.clone();
Either::Right(view! { <A href=format!("/item/{}", story.id)>{title}</A> })
}}
</span>
{(story.story_type != "link").then(|| view! {
" "
<span class="label">{story.story_type}</span>
})}
{(story.story_type != "link")
.then(|| {
view! {
" "
<span class="label">{story.story_type}</span>
}
})}
</li>
}
.into_any()
}

View File

@@ -28,18 +28,21 @@ pub fn Story() -> impl IntoView {
<Meta name="description" content=story.title.clone()/>
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
<h1>{story.title}</h1>
</a>
<span class="host">
"("{story.domain}")"
</span>
{story.user.map(|user| view! { <p class="meta">
{story.points}
" points | by "
<A href=format!("/users/{user}")>{user.clone()}</A>
{format!(" {}", story.time_ago)}
</p>})}
<a href=story.url target="_blank">
<h1>{story.title}</h1>
</a>
<span class="host">"(" {story.domain} ")"</span>
{story
.user
.map(|user| {
view! {
<p class="meta">
{story.points} " points | by "
<A href=format!("/users/{user}")>{user.clone()}</A>
{format!(" {}", story.time_ago)}
</p>
}
})}
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
@@ -48,6 +51,7 @@ pub fn Story() -> impl IntoView {
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
@@ -55,7 +59,7 @@ pub fn Story() -> impl IntoView {
key=|comment| comment.id
let:comment
>
<Comment comment />
<Comment comment/>
</For>
</ul>
</div>
@@ -64,6 +68,7 @@ pub fn Story() -> impl IntoView {
}
}
}))).build())
.into_any()
}
#[component]
@@ -72,43 +77,65 @@ pub fn Comment(comment: api::Comment) -> impl IntoView {
view! {
<li class="comment">
<div class="by">
<A href=format!("/users/{}", comment.user.clone().unwrap_or_default())>{comment.user.clone()}</A>
{format!(" {}", comment.time_ago)}
</div>
<div class="text" inner_html=comment.content></div>
{(!comment.comments.is_empty()).then(|| {
view! {
<div>
<div class="toggle" class:open=open>
<a on:click=move |_| set_open.update(|n| *n = !*n)>
{
let comments_len = comment.comments.len();
move || if open.get() {
"[-]".into()
} else {
format!("[+] {}{} collapsed", comments_len, pluralize(comments_len))
}
}
</a>
</div>
{move || open.get().then({
let comments = comment.comments.clone();
move || view! {
<ul class="comment-children">
<For
each=move || comments.clone()
key=|comment| comment.id
let:comment
>
<Comment comment />
</For>
</ul>
}
})}
</div>
}
})}
<div class="by">
<A href=format!(
"/users/{}",
comment.user.clone().unwrap_or_default(),
)>{comment.user.clone()}</A>
{format!(" {}", comment.time_ago)}
</div>
<div class="text" inner_html=comment.content></div>
{(!comment.comments.is_empty())
.then(|| {
view! {
<div>
<div class="toggle" class:open=open>
<a on:click=move |_| {
set_open.update(|n| *n = !*n)
}>
{
let comments_len = comment.comments.len();
move || {
if open.get() {
"[-]".into()
} else {
format!(
"[+] {}{} collapsed",
comments_len,
pluralize(comments_len),
)
}
}
}
</a>
</div>
{move || {
open
.get()
.then({
let comments = comment.comments.clone();
move || {
view! {
<ul class="comment-children">
<For
each=move || comments.clone()
key=|comment| comment.id
let:comment
>
<Comment comment/>
</For>
</ul>
}
}
})
}}
</div>
}
})}
</li>
}.into_any()
}

View File

@@ -18,30 +18,48 @@ pub fn User() -> impl IntoView {
);
view! {
<div class="user-view">
<Suspense fallback=|| view! { "Loading..." }>
{move || Suspend::new(async move { match user.await.clone() {
None => Either::Left(view! { <h1>"User not found."</h1> }),
Some(user) => Either::Right(view! {
<div>
<h1>"User: " {user.id.clone()}</h1>
<ul class="meta">
<li>
<span class="label">"Created: "</span> {user.created}
</li>
<li>
<span class="label">"Karma: "</span> {user.karma}
</li>
<li inner_html={user.about} class="about"></li>
</ul>
<p class="links">
<a href=format!("https://news.ycombinator.com/submitted?id={}", user.id)>"submissions"</a>
" | "
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
</p>
</div>
})
}})}
<Suspense fallback=|| {
view! { "Loading..." }
}>
{move || Suspend::new(async move {
match user.await.clone() {
None => Either::Left(view! { <h1>"User not found."</h1> }),
Some(user) => {
Either::Right(
view! {
<div>
<h1>"User: " {user.id.clone()}</h1>
<ul class="meta">
<li>
<span class="label">"Created: "</span>
{user.created}
</li>
<li>
<span class="label">"Karma: "</span>
{user.karma}
</li>
<li inner_html=user.about class="about"></li>
</ul>
<p class="links">
<a href=format!(
"https://news.ycombinator.com/submitted?id={}",
user.id,
)>"submissions"</a>
" | "
<a href=format!(
"https://news.ycombinator.com/threads?id={}",
user.id,
)>"comments"</a>
</p>
</div>
},
)
}
}
})}
</Suspense>
</div>
}
.into_any()
}

View File

@@ -4,7 +4,7 @@ mod routes;
use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet};
use leptos_router::{
components::{FlatRoutes, Route, Router, RoutingProgress},
ParamSegment, StaticSegment,
OptionalParamSegment, ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
use std::time::Duration;
@@ -46,9 +46,7 @@ pub fn App() -> impl IntoView {
<FlatRoutes fallback=|| "Not found.">
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
<Route path=OptionalParamSegment("stories") view=Stories/>
</FlatRoutes>
</main>
</Router>

View File

@@ -4,7 +4,7 @@ mod routes;
use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet};
use leptos_router::{
components::{FlatRoutes, Route, Router},
ParamSegment, StaticSegment,
OptionalParamSegment, ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
#[cfg(feature = "ssr")]
@@ -42,9 +42,7 @@ pub fn App() -> impl IntoView {
<FlatRoutes fallback=|| "Not found.">
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
<Route path=OptionalParamSegment("stories") view=Stories/>
</FlatRoutes>
</main>
</Router>

View File

@@ -4,7 +4,7 @@ mod routes;
use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet};
use leptos_router::{
components::{FlatRoutes, Route, Router, RoutingProgress},
ParamSegment, StaticSegment,
OptionalParamSegment, ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
use std::time::Duration;
@@ -46,9 +46,7 @@ pub fn App() -> impl IntoView {
<FlatRoutes fallback=|| "Not found.">
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
<Route path=OptionalParamSegment("stories") view=Stories/>
</FlatRoutes>
</main>
</Router>

View File

@@ -6,7 +6,7 @@ fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
let handle = mount_to(
helpers::document()
document()
.get_element_by_id("app")
.unwrap()
.unchecked_into(),

View File

@@ -3,7 +3,7 @@
<head>
<link data-trunk rel="rust" data-wasm-opt="z"/>
<link data-trunk rel="icon" type="image/ico" href="/public/favicon.ico"/>
<link data-trunk rel="css" href="style.css"/>
<link data-trunk rel="css" href="style.css"/>
</head>
<body></body>
</html>

View File

@@ -5,13 +5,14 @@ use leptos::prelude::*;
use leptos_router::{
components::{
Form, Outlet, ParentRoute, ProtectedRoute, Redirect, Route, Router,
Routes, A,
Routes, RoutingProgress, A,
},
hooks::{use_navigate, use_params, use_query_map},
params::Params,
MatchNestedRoutes,
};
use leptos_router_macro::path;
use std::time::Duration;
use tracing::info;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -26,9 +27,14 @@ pub fn RouterExample() -> impl IntoView {
// 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! {
<Router>
<Router set_is_routing>
// shows a progress bar while async data are loading
<div class="routing-progress">
<RoutingProgress is_routing max_time=Duration::from_millis(250)/>
</div>
<nav>
// ordinary <a> elements can be used for client-side navigation
// using <A> has two effects:
@@ -44,7 +50,7 @@ pub fn RouterExample() -> impl IntoView {
}>{move || if logged_in.get() { "Log Out" } else { "Log In" }}</button>
</nav>
<main>
<Routes fallback=|| "This page could not be found.">
<Routes transition=true fallback=|| "This page could not be found.">
// paths can be created using the path!() macro, or provided as types like
// StaticSegment("about")
<Route path=path!("about") view=About/>

View File

@@ -1,3 +1,8 @@
.routing-progress {
width: 100%;
height: 20px;
}
a[aria-current] {
font-weight: bold;
}
@@ -12,12 +17,8 @@ a[aria-current] {
padding: 1rem;
}
.fadeIn {
animation: 0.5s fadeIn forwards;
}
.fadeOut {
animation: 0.5s fadeOut forwards;
.contact {
view-transition-name: contact;
}
@keyframes fadeIn {
@@ -40,12 +41,44 @@ a[aria-current] {
}
}
.slideIn {
animation: 0.25s slideIn forwards;
.router-outlet-0 main {
view-transition-name: main;
}
.slideOut {
animation: 0.25s slideOut forwards;
.router-back main {
view-transition-name: main-back;
}
.router-outlet-1 .contact-list {
view-transition-name: contact;
}
@media (prefers-reduced-motion: no-preference) {
::view-transition-old(contact) {
animation: 0.5s fadeOut;
}
::view-transition-new(contact) {
animation: 0.5s fadeIn;
}
::view-transition-old(main) {
animation: 0.5s slideOut;
}
::view-transition-new(main) {
animation: 0.5s slideIn;
}
::view-transition-old(main-back) {
color: red;
animation: 0.5s slideOutBack;
}
::view-transition-new(main-back) {
color: blue;
animation: 0.5s slideInBack;
}
}
@keyframes slideIn {
@@ -66,14 +99,6 @@ a[aria-current] {
}
}
.slideInBack {
animation: 0.25s slideInBack forwards;
}
.slideOutBack {
animation: 0.25s slideOutBack forwards;
}
@keyframes slideInBack {
from {
transform: translate(-100vw, 0);

View File

@@ -10,7 +10,7 @@ struct Then {
// the type with Option<...> and marking the option as #[prop(optional)].
#[slot]
struct ElseIf {
cond: MaybeSignal<bool>,
cond: Signal<bool>,
children: ChildrenFn,
}
@@ -22,7 +22,7 @@ struct Fallback {
// Slots are added to components like any other prop.
#[component]
fn SlotIf(
cond: MaybeSignal<bool>,
cond: Signal<bool>,
then: Then,
#[prop(default=vec![])] else_if: Vec<ElseIf>,
#[prop(optional)] fallback: Option<Fallback>,
@@ -43,9 +43,9 @@ fn SlotIf(
#[component]
pub fn App() -> impl IntoView {
let (count, set_count) = signal(0);
let is_even = MaybeSignal::derive(move || count.get() % 2 == 0);
let is_div5 = MaybeSignal::derive(move || count.get() % 5 == 0);
let is_div7 = MaybeSignal::derive(move || count.get() % 7 == 0);
let is_even = Signal::derive(move || count.get() % 2 == 0);
let is_div5 = Signal::derive(move || count.get() % 5 == 0);
let is_div7 = Signal::derive(move || count.get() % 7 == 0);
view! {
<button on:click=move |_| set_count.update(|value| *value += 1)>"+1"</button>

View File

@@ -39,7 +39,7 @@ async fn main() -> std::io::Result<()> {
</html>
}
}})
.service(Files::new("/", site_root))
.service(Files::new("/", site_root.as_ref()))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?

View File

@@ -2,6 +2,7 @@
#[tokio::main]
async fn main() {
use axum::Router;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use ssr_modes_axum::app::*;

View File

@@ -2,6 +2,7 @@
#[tokio::main]
async fn main() {
use axum::Router;
use leptos::logging::log;
use leptos::prelude::*;
use leptos_axum::{generate_route_list_with_ssg, LeptosRoutes};
use static_routing::app::*;

View File

@@ -1,9 +1,9 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use chrono::{Local, NaiveDate};
use leptos::logging::warn;
use leptos::prelude::*;
use reactive_stores::{Field, Patch, Store};
use reactive_stores_macro::{Patch, Store};
use serde::{Deserialize, Serialize};
// ID starts higher than 0 because we have a few starting todos by default
@@ -110,11 +110,7 @@ pub fn App() -> impl IntoView {
// directly implements IntoIterator, so we can use it in <For/> and
// it will manage reactivity for the store fields correctly
<For
each=move || {
leptos::logging::log!("RERUNNING FOR CALCULATION");
store.todos()
}
each=move || store.todos()
key=|row| row.id().get()
let:todo
>

View File

@@ -10,6 +10,7 @@ crate-type = ["cdylib", "rlib"]
actix-files = { version = "0.6.6", optional = true }
actix-web = { version = "4.8", optional = true, features = ["macros"] }
console_error_panic_hook = "0.1.7"
js-sys = { version = "0.3.70", optional = true }
leptos = { path = "../../leptos" }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_router = { path = "../../router" }
@@ -19,7 +20,10 @@ serde = "1.0"
tokio = { version = "1.39", features = ["time", "rt"], optional = true }
[features]
hydrate = ["leptos/hydrate"]
hydrate = [
"dep:js-sys",
"leptos/hydrate",
]
ssr = [
"dep:actix-files",
"dep:actix-web",

View File

@@ -0,0 +1,94 @@
@check_instrumented
Feature: Instrumented Counters showing the expected values
Scenario: I can fresh CSR instrumented counters
Given I see the app
When I access the instrumented counters via CSR
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 0 |
| item_overview | 0 |
| item_inspect | 0 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
Scenario: I should see counter going up after viewing Item Listing
Given I see the app
When I select the following links
| Instrumented |
| Item Listing |
| Counters |
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 1 |
| item_overview | 0 |
| item_inspect | 0 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 1 |
| get_item | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
# the reload has happened in Item Listing, it follows a suspend
# will be called as hydration happens.
Scenario: Refreshing Item Listing should have only suspend counters
Given I see the app
When I access the instrumented counters via SSR
And I select the component Item Listing
And I reload the page
And I select the component Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 1 |
| item_overview | 0 |
| item_inspect | 0 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
Scenario: Reset CSR Counters work as expected.
Given I see the app
When I access the instrumented counters via SSR
And I select the component Item Listing
And I click on Reset CSR Counters
And I select the component Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 0 |
| item_overview | 0 |
| item_inspect | 0 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
Scenario: Standard usage of the instruments traversing down
Given I see the app
When I select the following links
| Instrumented |
| Item Listing |
| Item 2 |
| Inspect path3 |
| Inspect path3/field1 |
And I access the instrumented counters via CSR
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 1 |
| item_overview | 1 |
| item_inspect | 2 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 1 |
| get_item | 1 |
| inspect_item_root | 1 |
| inspect_item_field | 1 |

View File

@@ -0,0 +1,195 @@
@check_instrumented_suspense_resource
Feature: Using instrumented counters for real
Check that the suspend/suspense and the underlying resources are
called with the expected number of times for CSR rendering.
Background:
Given I see the app
And I select the mode Instrumented
Scenario: Emulate steps 1 to 5 of issue #2961
Given I select the link Target 3##
And I refresh the page
When I select the following links
| Item Listing |
| Target 4## |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 1 |
| 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: Emulate step 6 of issue #2961
Given I select the link Target 41#
And I refresh the page
When I select the following links
| Target 4## |
| Target 42# |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 0 |
| item_overview | 1 |
| item_inspect | 2 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 1 |
| inspect_item_root | 2 |
| inspect_item_field | 0 |
Scenario: Emulate step 7 of issue #2961
Given I select the link Target 42#
And I refresh the page
When I select the following links
| Target 4## |
| Target 42# |
| Target 41# |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 0 |
| item_overview | 1 |
| item_inspect | 3 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 1 |
| inspect_item_root | 3 |
| inspect_item_field | 0 |
Scenario: Emulate step 8, "not trigger double fetch".
Given I select the link Target 3##
And I refresh the page
When I select the following links
| Item Listing |
| Target 4## |
| Target 41# |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 1 |
| item_overview | 2 |
| item_inspect | 1 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 2 |
| inspect_item_root | 1 |
| inspect_item_field | 0 |
Scenario: Like above, for the "double fetch" which shouldn't happen
Given I select the link Target 3##
And I refresh the page
When I select the following links
| Item Listing |
| Target 4## |
| Target 41# |
| Target 3## |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 1 |
| item_overview | 3 |
| item_inspect | 1 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 3 |
| inspect_item_root | 1 |
| inspect_item_field | 0 |
Scenario: Like above, but using 4## instead
Given I select the link Target 3##
And I refresh the page
When I select the following links
| Item Listing |
| Target 4## |
| Target 41# |
| Target 4## |
And I go check the Counters
Then I see the following counters under section
| Suspend Calls | |
| item_listing | 1 |
| item_overview | 3 |
| item_inspect | 1 |
And the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 2 |
| inspect_item_root | 1 |
| inspect_item_field | 0 |
# Currently, get_item is invoked with `3` as the argument upon
# selection of `Item Listing` despite that `Item Listing` doesn't
# need `get_item` calls. Seems like it may be due to the system
# still reacting to the unmounting of the component that needed
# view that generated the original `Item 3` (hydrated from SSR).
# Tests above may also have this type of behavior, but is somewhat
# masked because the direction of going down and then back up, but
# if this behavior changes for the better (avoiding this spurious
# resource fetch) then the above tests may need updating to reflect
# the corrected behavior. Note the difference with the fully CSR
# scenario after this one
Scenario: Emulate part of step 8 of issue #2961
Given I select the link Target 3##
And I refresh the page
When I select the link Item Listing
And I go check the Counters
Then I see the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 1 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
# Instead of refreshing the page like above, CSR counters is reset
# instead to keep the starting counter conditions identical.
Scenario: Emulate above, instead of refresh page, reset csr counters
Given I select the link Target 3##
And I click on Reset CSR Counters
When I select the link Item Listing
And I go check the Counters
Then I see the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
# Again, the following two sets demostrates resources making stale
# and redundant requests when hydrated, and not do so when under
# CSR.
Scenario: Start with hydration from Target 41# and go up
Given I select the link Target 41#
And I refresh the page
When I select the link Target 4##
And I select the link Item Listing
And I go check the Counters
Then I see the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 1 |
| inspect_item_root | 1 |
| inspect_item_field | 0 |
Scenario: Start with hydration from Target 41# and go up
Given I select the link Target 41#
And I click on Reset CSR Counters
When I select the link Target 4##
And I select the link Item Listing
And I go check the Counters
Then I see the following counters under section
| Server Calls (CSR) | |
| list_items | 0 |
| get_item | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |

View File

@@ -37,3 +37,19 @@ pub async fn click_second_button(client: &Client) -> Result<()> {
Ok(())
}
pub async fn click_reset_counters_button(client: &Client) -> Result<()> {
let reset_counter = find::reset_counter(client).await?;
reset_counter.click().await?;
Ok(())
}
pub async fn click_reset_csr_counters_button(client: &Client) -> Result<()> {
let reset_counter = find::reset_csr_counter(client).await?;
reset_counter.click().await?;
Ok(())
}

View File

@@ -63,3 +63,21 @@ pub async fn second_count_is(client: &Client, expected: u32) -> Result<()> {
Ok(())
}
pub async fn instrumented_counts(
client: &Client,
expected: &[(&str, u32)],
) -> Result<()> {
let mut actual = Vec::<(&str, u32)>::new();
for (selector, _) in expected.iter() {
actual.push((
selector,
find::instrumented_count(client, selector).await?,
))
}
assert_eq!(actual, expected);
Ok(())
}

View File

@@ -77,6 +77,43 @@ pub async fn second_button(client: &Client) -> Result<Element> {
Ok(counter_button)
}
pub async fn instrumented_count(
client: &Client,
selector: &str,
) -> Result<u32> {
let element = client
.wait()
.for_element(Locator::Id(selector))
.await
.expect(format!("Element #{selector} not found.")
.as_str());
let text = element.text().await?;
let count = text.parse::<u32>()
.expect(format!("Element #{selector} does not contain a number.")
.as_str());
Ok(count)
}
pub async fn reset_counter(client: &Client) -> Result<Element> {
let reset_button = client
.wait()
.for_element(Locator::Id("reset-counters"))
.await
.expect("Reset counter input not found");
Ok(reset_button)
}
pub async fn reset_csr_counter(client: &Client) -> Result<Element> {
let reset_button = client
.wait()
.for_element(Locator::Id("reset-csr-counters"))
.await
.expect("Reset CSR counter input not found");
Ok(reset_button)
}
async fn component_message(client: &Client, id: &str) -> Result<String> {
let element =
client.wait().for_element(Locator::Id(id)).await.expect(

View File

@@ -1,6 +1,6 @@
use crate::fixtures::{action, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::{given, when};
use cucumber::{given, when, gherkin::Step};
#[given("I see the app")]
#[when("I open the app")]
@@ -12,19 +12,13 @@ async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
}
#[given(regex = r"^I select the mode (.*)$")]
async fn i_select_the_mode(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::click_link(client, &text).await?;
Ok(())
}
#[given(regex = r"^I select the component (.*)$")]
#[when(regex = "^I select the component (.*)$")]
async fn i_select_the_component(
world: &mut AppWorld,
text: String,
) -> Result<()> {
#[given(regex = "^I select the link (.*)$")]
#[when(regex = "^I select the link (.*)$")]
#[when(regex = "^I click on the link (.*)$")]
#[when(regex = "^I go check the (.*)$")]
async fn i_select_the_link(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::click_link(client, &text).await?;
@@ -59,3 +53,69 @@ async fn i_click_the_second_button_n_times(
Ok(())
}
#[given(regex = "^I (refresh|reload) the (browser|page)$")]
#[when(regex = "^I (refresh|reload) the (browser|page)$")]
async fn i_refresh_the_browser(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
client.refresh().await?;
Ok(())
}
#[when(expr = "I click on Reset Counters")]
async fn i_click_on_reset_counters(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::click_reset_counters_button(client).await?;
Ok(())
}
#[given(expr = "I click on Reset CSR Counters")]
#[when(expr = "I click on Reset CSR Counters")]
async fn i_click_on_reset_csr_counters(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::click_reset_csr_counters_button(client).await?;
Ok(())
}
#[when(expr = "I access the instrumented counters via SSR")]
async fn i_access_the_instrumented_counters_page_via_ssr(
world: &mut AppWorld,
) -> Result<()> {
let client = &world.client;
action::click_link(client, "Instrumented").await?;
action::click_link(client, "Counters").await?;
client.refresh().await?;
Ok(())
}
#[when(expr = "I access the instrumented counters via CSR")]
async fn i_access_the_instrumented_counters_page_via_csr(
world: &mut AppWorld,
) -> Result<()> {
let client = &world.client;
action::click_link(client, "Instrumented").await?;
action::click_link(client, "Counters").await?;
Ok(())
}
#[given(expr = "I select the following links")]
#[when(expr = "I select the following links")]
async fn i_select_the_following_links(
world: &mut AppWorld,
step: &Step,
) -> Result<()> {
let client = &world.client;
if let Some(table) = step.table.as_ref() {
for row in table.rows.iter() {
action::click_link(client, &row[0]).await?;
}
}
Ok(())
}

View File

@@ -1,6 +1,6 @@
use crate::fixtures::{check, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::then;
use cucumber::{then, gherkin::Step};
#[then(regex = r"^I see the page title is (.*)$")]
async fn i_see_the_page_title_is(
@@ -79,3 +79,23 @@ async fn i_see_the_second_count_is(
Ok(())
}
#[then(expr = "I see the following counters under section")]
#[then(expr = "the following counters under section")]
async fn i_see_the_following_counters_under_section(
world: &mut AppWorld,
step: &Step,
) -> Result<()> {
// FIXME ideally check the mode; for now leave it because effort
let client = &world.client;
if let Some(table) = step.table.as_ref() {
let expected = table.rows
.iter()
.skip(1)
.map(|row| (row[0].as_str(), row[1].parse::<u32>().unwrap()))
.collect::<Vec<_>>();
check::instrumented_counts(client, &expected).await?;
}
Ok(())
}

View File

@@ -1,3 +1,4 @@
use crate::instrumented::InstrumentedRoutes;
use leptos::prelude::*;
use leptos_router::{
components::{Outlet, ParentRoute, Redirect, Route, Router, Routes, A},
@@ -41,6 +42,7 @@ pub fn App() -> impl IntoView {
<A href="/out-of-order">"Out-of-Order"</A>
<A href="/in-order">"In-Order"</A>
<A href="/async">"Async"</A>
<A href="/instrumented/">"Instrumented"</A>
</nav>
<main>
<Routes fallback=|| "Page not found.">
@@ -110,6 +112,7 @@ pub fn App() -> impl IntoView {
<Route path=StaticSegment("local") view=LocalResource/>
<Route path=StaticSegment("none") view=None/>
</ParentRoute>
<InstrumentedRoutes/>
</Routes>
</main>
</Router>

View File

@@ -0,0 +1,667 @@
use leptos::prelude::*;
use leptos_router::{
components::{ParentRoute, Route, A},
hooks::use_params,
nested_router::Outlet,
params::Params,
MatchNestedRoutes, ParamSegment, SsrMode, StaticSegment, WildcardSegment,
};
#[cfg(feature = "ssr")]
pub(super) mod counter {
use std::{
collections::HashMap,
sync::{
atomic::{AtomicU32, Ordering},
LazyLock, Mutex,
},
};
#[derive(Default)]
pub struct Counter(AtomicU32);
impl Counter {
pub const fn new() -> Self {
Self(AtomicU32::new(0))
}
pub fn get(&self) -> u32 {
self.0.load(Ordering::SeqCst)
}
pub fn inc(&self) -> u32 {
self.0.fetch_add(1, Ordering::SeqCst)
}
pub fn reset(&self) {
self.0.store(0, Ordering::SeqCst);
}
}
#[derive(Default)]
pub struct Counters {
pub list_items: Counter,
pub get_item: Counter,
pub inspect_item_root: Counter,
pub inspect_item_field: Counter,
}
impl From<&mut Counters> for super::Counters {
fn from(counter: &mut Counters) -> Self {
Self {
get_item: counter.get_item.get(),
inspect_item_root: counter.inspect_item_root.get(),
inspect_item_field: counter.inspect_item_field.get(),
list_items: counter.list_items.get(),
}
}
}
impl Counters {
pub fn reset(&self) {
self.get_item.reset();
self.inspect_item_root.reset();
self.inspect_item_field.reset();
self.list_items.reset();
}
}
pub static COUNTERS: LazyLock<Mutex<HashMap<u64, Counters>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct Item {
id: i64,
name: Option<String>,
field: Option<String>,
}
#[server]
async fn list_items(ticket: u64) -> Result<Vec<i64>, ServerFnError> {
// emulate database query overhead
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
(*counter::COUNTERS)
.lock()
.expect("somehow panicked elsewhere")
.entry(ticket)
.or_default()
.list_items
.inc();
Ok(vec![1, 2, 3, 4])
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct GetItemResult(pub Item, pub Vec<String>);
#[server]
async fn get_item(
ticket: u64,
id: i64,
) -> Result<GetItemResult, ServerFnError> {
// emulate database query overhead
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
(*counter::COUNTERS)
.lock()
.expect("somehow panicked elsewhere")
.entry(ticket)
.or_default()
.get_item
.inc();
let name = None::<String>;
let field = None::<String>;
Ok(GetItemResult(
Item { id, name, field },
["path1", "path2", "path3"]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>(),
))
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct InspectItemResult(pub Item, pub String, pub Vec<String>);
#[server]
async fn inspect_item(
ticket: u64,
id: i64,
path: String,
) -> Result<InspectItemResult, ServerFnError> {
// emulate database query overhead
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
let mut split = path.split('/');
let name = split.next().map(str::to_string);
let path = name
.clone()
.expect("name should have been defined at this point");
let field = split
.next()
.and_then(|s| (!s.is_empty()).then(|| s.to_string()));
if field.is_none() {
(*counter::COUNTERS)
.lock()
.expect("somehow panicked elsewhere")
.entry(ticket)
.or_default()
.inspect_item_root
.inc();
} else {
(*counter::COUNTERS)
.lock()
.expect("somehow panicked elsewhere")
.entry(ticket)
.or_default()
.inspect_item_field
.inc();
}
Ok(InspectItemResult(
Item { id, name, field },
path,
["field1", "field2", "field3"]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>(),
))
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct Counters {
pub get_item: u32,
pub inspect_item_root: u32,
pub inspect_item_field: u32,
pub list_items: u32,
}
#[server]
async fn get_counters(ticket: u64) -> Result<Counters, ServerFnError> {
Ok((*counter::COUNTERS)
.lock()
.expect("somehow panicked elsewhere")
.entry(ticket)
.or_default()
.into())
}
#[server(ResetCounters)]
async fn reset_counters(ticket: u64) -> Result<(), ServerFnError> {
(*counter::COUNTERS)
.lock()
.expect("somehow panicked elsewhere")
.entry(ticket)
.or_default()
.reset();
// leptos::logging::log!("counters for ticket {ticket} have been reset");
Ok(())
}
#[derive(Clone, Default)]
pub struct SuspenseCounters {
item_overview: u32,
item_inspect: u32,
item_listing: u32,
}
#[component]
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/>
<ParentRoute path=StaticSegment("item") view=ItemRoot>
<Route path=StaticSegment("/") view=ItemListing/>
<ParentRoute path=ParamSegment("id") view=ItemTop>
<Route path=StaticSegment("/") view=ItemOverview/>
<Route path=WildcardSegment("path") view=ItemInspect/>
</ParentRoute>
</ParentRoute>
<Route path=StaticSegment("counters") view=ShowCounters/>
</ParentRoute>
}
.into_inner()
}
#[derive(Copy, Clone)]
pub struct Ticket(pub u64);
#[derive(Copy, Clone)]
pub struct CSRTicket(pub u64);
#[cfg(feature = "ssr")]
fn inst_ticket() -> u64 {
// SSR will always use 0 for the ticket
0
}
#[cfg(not(feature = "ssr"))]
fn inst_ticket() -> u64 {
// CSR will use a random number for the ticket
(js_sys::Math::random() * ((u64::MAX - 1) as f64) + 1f64) as u64
}
#[component]
fn InstrumentedRoot() -> impl IntoView {
let counters = RwSignal::new(SuspenseCounters::default());
provide_context(counters);
provide_field_nav_portlet_context();
// Generate a ID directly on this component. Rather than relying on
// additional server functions, doing it this way emulates more
// standard workflows better and to avoid having to add another
// thing to instrument/interfere with the typical use case.
// Downside is that randomness has a chance to conflict.
//
// Furthermore, this approach **will** result in unintuitive
// behavior when it isn't accounted for - specifically, the reason
// for this design is that when SSR it will guarantee usage of `0`
// as the ticket, while CSR it will be of some other value as the
// version it uses will be random. However, when trying to get back
// the counters associated with the ticket, rendering using SSR will
// always produce the SSR version and this quirk will need to be
// accounted for.
let ticket = inst_ticket();
// leptos::logging::log!(
// "Ticket for this InstrumentedRoot instance: {ticket}"
// );
provide_context(Ticket(ticket));
let csr_ticket = RwSignal::<Option<CSRTicket>>::new(None);
let reset_counters = ServerAction::<ResetCounters>::new();
Effect::new(move |_| {
let ticket = expect_context::<Ticket>().0;
csr_ticket.set(Some(CSRTicket(ticket)));
});
view! {
<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>
</nav>
<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>
<footer>
<nav>
<A href="item/3/">"Target 3##"</A>
<A href="item/4/">"Target 4##"</A>
<A href="item/4/path1/">"Target 41#"</A>
<A href="item/4/path2/">"Target 42#"</A>
<A href="item/4/path2/field1">"Target 421"</A>
<A href="item/1/path2/field3">"Target 123"</A>
</nav>
</footer>
</section>
}
}
#[component]
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>
<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>
</ul>
}
}
#[component]
fn ItemRoot() -> impl IntoView {
let ticket = expect_context::<Ticket>().0;
provide_context(Resource::new_blocking(
move || (),
move |_| async move { list_items(ticket).await },
));
view! {
<h2>"<ItemRoot/>"</h2>
<Outlet/>
}
}
#[component]
fn ItemListing() -> impl IntoView {
let suspense_counters = expect_context::<RwSignal<SuspenseCounters>>();
let resource =
expect_context::<Resource<Result<Vec<i64>, ServerFnError>>>();
let item_listing = move || {
Suspend::new(async move {
let result = resource.await.map(|items| items
.into_iter()
.map(move |item|
// FIXME seems like relative link isn't working, it is currently
// 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>
}
)
.collect_view()
);
suspense_counters.update_untracked(|c| c.item_listing += 1);
result
})
};
view! {
<h3>"<ItemListing/>"</h3>
<ul>
<Suspense>
{item_listing}
</Suspense>
</ul>
}
}
#[derive(Params, PartialEq, Clone, Debug)]
struct ItemTopParams {
id: Option<i64>,
}
#[component]
fn ItemTop() -> impl IntoView {
let ticket = expect_context::<Ticket>().0;
let params = use_params::<ItemTopParams>();
// map result to an option as the focus isn't error rendering
provide_context(Resource::new_blocking(
move || params.get().map(|p| p.id),
move |id| async move {
match id {
Err(_) => None,
Ok(Some(id)) => get_item(ticket, id).await.ok(),
_ => None,
}
},
));
view! {
<h4>"<ItemTop/>"</h4>
<Outlet/>
}
}
#[component]
fn ItemOverview() -> impl IntoView {
let suspense_counters = expect_context::<RwSignal<SuspenseCounters>>();
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| {
// 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
})
};
view! {
<h5>"<ItemOverview/>"</h5>
<Suspense>
{item_view}
</Suspense>
}
}
#[derive(Params, PartialEq, Clone, Debug)]
struct ItemInspectParams {
path: Option<String>,
}
#[component]
fn ItemInspect() -> impl IntoView {
let ticket = expect_context::<Ticket>().0;
let suspense_counters = expect_context::<RwSignal<SuspenseCounters>>();
let params = use_params::<ItemInspectParams>();
let res_overview = expect_context::<Resource<Option<GetItemResult>>>();
let res_inspect = Resource::new_blocking(
move || params.get().map(|p| p.path),
move |p| async move {
// leptos::logging::log!("res_inspect: res_overview.await");
let overview = res_overview.await;
// leptos::logging::log!("res_inspect: resolved res_overview.await");
// let result =
match (overview, p) {
(Some(item), Ok(Some(path))) => {
// leptos::logging::log!("res_inspect: inspect_item().await");
inspect_item(ticket, item.0.id, path.clone()).await.ok()
}
_ => None,
}
// ;
// leptos::logging::log!("res_inspect: resolved inspect_item().await");
// result
},
);
on_cleanup(|| {
if let Some(c) = use_context::<WriteSignal<Option<FieldNavCtx>>>() {
c.set(None);
}
});
let inspect_view = move || {
// leptos::logging::log!("inspect_view closure invoked");
Suspend::new(async move {
// leptos::logging::log!("inspect_view Suspend::new() called");
let result = res_inspect.await.map(|InspectItemResult(item, name, fields)| {
// leptos::logging::log!("inspect_view res_inspect awaited");
let id = item.id;
expect_context::<WriteSignal<Option<FieldNavCtx>>>().set(Some(
fields.iter()
.map(|field| FieldNavItem {
href: format!("/instrumented/item/{id}/{name}/{field}"),
text: field.to_string(),
})
.collect::<Vec<_>>()
.into()
));
view! {
<p>{format!("Inspecting {item:?}")}</p>
<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>
// }
view! {
<li><a href=format!("/instrumented/item/{id}/{name}/{field}")>{
format!("Inspect {name}/{field}")
}</a></li>
}
})
.collect_view()
}</ul>
}
});
suspense_counters.update_untracked(|c| c.item_inspect += 1);
// leptos::logging::log!(
// "returning result, result.is_some() = {}, count = {}",
// result.is_some(),
// suspense_counters.get().item_inspect,
// );
result
})
};
view! {
<h5>"<ItemInspect/>"</h5>
<Suspense>
{inspect_view}
</Suspense>
}
}
#[component]
fn ShowCounters() -> impl IntoView {
// There is _weirdness_ in this view. The `Server Calls` counters
// will be acquired via the expected mode and be rendered as such.
//
// However, upon `Reset Counters`, the mode from which the reset
// was issued will result in the rendering be reflected as such, so
// if the intial state was SSR, resetting under CSR will result in
// the CSR counters be rendered after. However for the intents and
// purpose for the testing only the CSR is cared for.
//
// At the end of the day, it is possible to have both these be
// separated out, but for the purpose of this test the focus is not
// on the SSR side of things (at least until further regression is
// discovered that affects SSR directly).
let ticket = expect_context::<Ticket>().0;
let suspense_counters = expect_context::<RwSignal<SuspenseCounters>>();
let reset_counters = ServerAction::<ResetCounters>::new();
let res_counter = Resource::new(
move || reset_counters.version().get(),
move |_| async move {
(
get_counters(ticket).await,
if ticket == 0 { "SSR" } else { "CSR" }.to_string(),
ticket,
)
},
);
let counter_view = move || {
Suspend::new(async move {
// ensure current mode and ticket are both updated
let (counters, mode, ticket) = res_counter.await;
counters.map(|counters| {
let clear_suspense_counters = move |_| {
suspense_counters.update(|c| {
// leptos::logging::log!("resetting suspense counters");
*c = SuspenseCounters::default();
});
};
view! {
<h3 id="server-calls">"Server Calls ("{mode}")"</h3>
<dl>
<dt>"list_items"</dt>
<dd id="list_items">{counters.list_items}</dd>
<dt>"get_item"</dt>
<dd id="get_item">{counters.get_item}</dd>
<dt>"inspect_item_root"</dt>
<dd id="inspect_item_root">{counters.inspect_item_root}</dd>
<dt>"inspect_item_field"</dt>
<dd id="inspect_item_field">{counters.inspect_item_field}</dd>
</dl>
<ActionForm action=reset_counters>
<input type="hidden" name="ticket" value=format!("{ticket}") />
<input
id="reset-counters"
type="submit"
value="Reset Counters"
on:click=clear_suspense_counters/>
</ActionForm>
}
})
})
};
view! {
<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>
})}
<Suspense>
{counter_view}
</Suspense>
}
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
pub struct FieldNavItem {
pub href: String,
pub text: String,
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)]
pub struct FieldNavCtx(pub Option<Vec<FieldNavItem>>);
impl From<Vec<FieldNavItem>> for FieldNavCtx {
fn from(item: Vec<FieldNavItem>) -> Self {
Self(Some(item))
}
}
#[component]
pub fn FieldNavPortlet() -> impl IntoView {
let ctx = expect_context::<ReadSignal<Option<FieldNavCtx>>>();
move || {
let ctx = ctx.get();
ctx.map(|ctx| {
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>
</div>
}
})
}
}
pub fn provide_field_nav_portlet_context() {
// wrapping the Ctx in an Option allows better ergonomics whenever it isn't needed
let (ctx, set_ctx) = signal(None::<FieldNavCtx>);
provide_context(ctx);
provide_context(set_ctx);
}

View File

@@ -1,4 +1,5 @@
pub mod app;
mod instrumented;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]

View File

@@ -41,7 +41,7 @@ async fn main() -> std::io::Result<()> {
</html>
}
}})
.service(Files::new("/", site_root))
.service(Files::new("/", site_root.as_ref()))
})
.bind(addr)?
.workers(1)

View File

@@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
test("should see the welcome message", async ({ page }) => {
test("homepage has title 'Leptos + Tailwindcss'", async ({ page }) => {
await page.goto("http://localhost:3000/");
await expect(page.locator("h2")).toHaveText("Welcome to Leptos with Tailwind");
await expect(page).toHaveTitle("Leptos + Tailwindcss");
});

View File

@@ -22,24 +22,29 @@ pub fn App() -> impl IntoView {
#[component]
fn Home() -> impl IntoView {
let (count, set_count) = signal(0);
let (value, set_value) = signal(0);
// thanks to https://tailwindcomponents.com/component/blue-buttons-example for the showcase layout
view! {
<main class="my-0 mx-auto max-w-3xl text-center">
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
<button
class="bg-amber-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
on:click=move |_| set_count.update(|count| *count += 1)
>
"Something's here | "
{move || if count.get() == 0 {
"Click me!".to_string()
} else {
count.get().to_string()
}}
" | Some more text"
</button>
<Title text="Leptos + Tailwindcss"/>
<main>
<div class="bg-gradient-to-tl from-blue-800 to-blue-500 text-white font-mono flex flex-col min-h-screen">
<div class="flex flex-row-reverse flex-wrap m-auto">
<button on:click=move |_| set_value.update(|value| *value += 1) class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white">
"+"
</button>
<button class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-800 border-blue-900 text-white">
{value}
</button>
<button
on:click=move |_| set_value.update(|value| *value -= 1)
class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white"
class:invisible=move || {value.get() < 1}
>
"-"
</button>
</div>
</div>
</main>
}
}

View File

@@ -40,7 +40,7 @@ async fn main() -> std::io::Result<()> {
</html>
}
}})
.service(Files::new("/", site_root))
.service(Files::new("/", site_root.as_ref()))
.wrap(middleware::Compress::default())
})
.bind(&addr)?

View File

@@ -1,7 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: {
content: {
files: ["*.html", "./src/**/*.rs"],
transform: {
rs: (content) => content.replace(/(?:^|\s)class:/g, ' '),
},
},
theme: {
extend: {},

View File

@@ -1,9 +1,7 @@
import { test, expect } from "@playwright/test";
test("homepage has title and links to intro page", async ({ page }) => {
test("homepage has title 'Leptos + Tailwindcss'", async ({ page }) => {
await page.goto("http://localhost:3000/");
await expect(page).toHaveTitle("Welcome to Leptos");
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
await expect(page).toHaveTitle("Leptos + Tailwindcss");
});

View File

@@ -54,7 +54,11 @@ fn Home() -> impl IntoView {
<button class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-800 border-blue-900 text-white">
{value}
</button>
<button on:click=move |_| set_value.update(|value| *value -= 1) class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white">
<button
on:click=move |_| set_value.update(|value| *value -= 1)
class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white"
class:invisible=move || {value.get() < 1}
>
"-"
</button>
</div>

View File

@@ -1,9 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["*.html", "./src/**/*.rs",],
content: {
files: ["*.html", "./src/**/*.rs"],
transform: {
rs: (content) => content.replace(/(?:^|\s)class:/g, ' '),
},
},
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,9 +1,7 @@
import { test, expect } from "@playwright/test";
test("homepage has title and links to intro page", async ({ page }) => {
test("homepage has title 'Leptos + Tailwindcss'", async ({ page }) => {
await page.goto("http://localhost:8080/");
await expect(page).toHaveTitle("Leptos • Counter with Tailwind");
await expect(page.locator("h2")).toHaveText("Welcome to Leptos with Tailwind");
await expect(page).toHaveTitle("Leptos + Tailwindcss");
});

View File

@@ -22,24 +22,29 @@ pub fn App() -> impl IntoView {
#[component]
fn Home() -> impl IntoView {
let (count, set_count) = signal(0);
let (value, set_value) = signal(0);
// thanks to https://tailwindcomponents.com/component/blue-buttons-example for the showcase layout
view! {
<div class="my-0 mx-auto max-w-3xl text-center">
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
<button
class="bg-amber-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
on:click=move |_| set_count.update(|count| *count += 1)
>
"Something's here | "
{move || if count.get() == 0 {
"Click me!".to_string()
} else {
count.get().to_string()
}}
" | Some more text"
</button>
</div>
<Title text="Leptos + Tailwindcss"/>
<main>
<div class="bg-gradient-to-tl from-blue-800 to-blue-500 text-white font-mono flex flex-col min-h-screen">
<div class="flex flex-row-reverse flex-wrap m-auto">
<button on:click=move |_| set_value.update(|value| *value += 1) class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white">
"+"
</button>
<button class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-800 border-blue-900 text-white">
{value}
</button>
<button
on:click=move |_| set_value.update(|value| *value -= 1)
class="rounded px-3 py-2 m-1 border-b-4 border-l-2 shadow-lg bg-blue-700 border-blue-800 text-white"
class:invisible=move || {value.get() < 1}
>
"-"
</button>
</div>
</div>
</main>
}
}

View File

@@ -1,7 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: {
content: {
files: ["*.html", "./src/**/*.rs"],
transform: {
rs: (content) => content.replace(/(?:^|\s)class:/g, ' '),
},
},
theme: {
extend: {},

View File

@@ -38,7 +38,7 @@ pub fn TimerDemo() -> impl IntoView {
pub fn use_interval<T, F>(interval_millis: T, f: F)
where
F: Fn() + Clone + 'static,
T: Into<MaybeSignal<u64>> + 'static,
T: Into<Signal<u64>> + 'static,
{
let interval_millis = interval_millis.into();
Effect::new(move |prev_handle: Option<IntervalHandle>| {

View File

@@ -59,7 +59,7 @@ async fn main() -> std::io::Result<()> {
</html>
}
}})
.service(Files::new("/", site_root))
.service(Files::new("/", site_root.as_ref()))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?

View File

@@ -1,6 +1,6 @@
[package]
name = "hydration_context"
version = "0.2.0-gamma"
version = "0.2.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"

View File

@@ -44,6 +44,18 @@ pub type PinnedStream<T> = Pin<Box<dyn Stream<Item = T> + Send + Sync>>;
/// from the server to the client.
pub struct SerializedDataId(usize);
impl SerializedDataId {
/// Create a new instance of [`SerializedDataId`].
pub fn new(id: usize) -> Self {
SerializedDataId(id)
}
/// Consume into the inner usize identifier.
pub fn into_inner(self) -> usize {
self.0
}
}
impl From<SerializedDataId> for ErrorId {
fn from(value: SerializedDataId) -> Self {
value.0.into()

View File

@@ -58,6 +58,27 @@ impl SsrSharedContext {
..Default::default()
}
}
/// Consume the data buffers, awaiting all async resources,
/// returning both sync and async buffers.
/// Useful to implement custom hydration contexts.
///
/// WARNING: this will clear the internal buffers, it should only be called once.
/// A second call would return an empty `vec![]`.
pub async fn consume_buffers(&self) -> Vec<(SerializedDataId, String)> {
let sync_data = mem::take(&mut *self.sync_buf.write().or_poisoned());
let async_data = mem::take(&mut *self.async_buf.write().or_poisoned());
let mut all_data = Vec::new();
for resolved in sync_data {
all_data.push((resolved.0, resolved.1));
}
for (id, fut) in async_data {
let data = fut.await;
all_data.push((id, data));
}
all_data
}
}
impl Debug for SsrSharedContext {

View File

@@ -35,7 +35,7 @@ use leptos_router::{
components::provide_server_redirect,
location::RequestUrl,
static_routes::{RegenerationFn, ResolvedStaticPath},
Method, PathSegment, RouteList, RouteListing, SsrMode,
ExpandOptionals, Method, PathSegment, RouteList, RouteListing, SsrMode,
};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
@@ -44,6 +44,7 @@ use server_fn::{
redirect::REDIRECT_HEADER, request::actix::ActixRequest, ServerFnError,
};
use std::{
collections::HashSet,
fmt::{Debug, Display},
future::Future,
ops::{Deref, DerefMut},
@@ -900,7 +901,7 @@ trait ActixPath {
fn to_actix_path(&self) -> String;
}
impl ActixPath for &[PathSegment] {
impl ActixPath for Vec<PathSegment> {
fn to_actix_path(&self) -> String {
let mut path = String::new();
for segment in self.iter() {
@@ -922,6 +923,14 @@ impl ActixPath for &[PathSegment] {
path.push_str(":.*}");
}
PathSegment::Unit => {}
PathSegment::OptionalParam(_) => {
#[cfg(feature = "tracing")]
tracing::error!(
"to_axum_path should only be called on expanded \
paths, which do not have OptionalParam any longer"
);
Default::default()
}
}
}
path
@@ -935,25 +944,38 @@ pub struct ActixRouteListing {
mode: SsrMode,
methods: Vec<leptos_router::Method>,
regenerate: Vec<RegenerationFn>,
exclude: bool,
}
impl From<RouteListing> for ActixRouteListing {
fn from(value: RouteListing) -> Self {
let path = value.path().to_actix_path();
let path = if path.is_empty() {
"/".to_string()
} else {
path
};
let mode = value.mode();
let methods = value.methods().collect();
let regenerate = value.regenerate().into();
Self {
path,
mode: mode.clone(),
methods,
regenerate,
}
trait IntoRouteListing: Sized {
fn into_route_listing(self) -> Vec<ActixRouteListing>;
}
impl IntoRouteListing for RouteListing {
fn into_route_listing(self) -> Vec<ActixRouteListing> {
self.path()
.to_vec()
.expand_optionals()
.into_iter()
.map(|path| {
let path = path.to_actix_path();
let path = if path.is_empty() {
"/".to_string()
} else {
path
};
let mode = self.mode();
let methods = self.methods().collect();
let regenerate = self.regenerate().into();
ActixRouteListing {
path,
mode: mode.clone(),
methods,
regenerate,
exclude: false,
}
})
.collect()
}
}
@@ -970,6 +992,7 @@ impl ActixRouteListing {
mode,
methods: methods.into_iter().collect(),
regenerate: regenerate.into(),
exclude: false,
}
}
@@ -1027,27 +1050,37 @@ where
let mut routes = routes
.into_inner()
.into_iter()
.map(ActixRouteListing::from)
.flat_map(IntoRouteListing::into_route_listing)
.collect::<Vec<_>>();
(
if routes.is_empty() {
vec![ActixRouteListing::new(
"/".to_string(),
Default::default(),
[leptos_router::Method::Get],
vec![],
)]
} else {
// Routes to exclude from auto generation
if let Some(excluded_routes) = excluded_routes {
routes
.retain(|p| !excluded_routes.iter().any(|e| e == p.path()))
}
routes
},
generator,
)
let routes = if routes.is_empty() {
vec![ActixRouteListing::new(
"/".to_string(),
Default::default(),
[leptos_router::Method::Get],
vec![],
)]
} else {
// Routes to exclude from auto generation
if let Some(excluded_routes) = &excluded_routes {
routes.retain(|p| !excluded_routes.iter().any(|e| e == p.path()))
}
routes
};
let excluded =
excluded_routes
.into_iter()
.flatten()
.map(|path| ActixRouteListing {
path,
mode: Default::default(),
methods: Vec::new(),
regenerate: Vec::new(),
exclude: true,
});
(routes.into_iter().chain(excluded).collect(), generator)
}
/// Allows generating any prerendered routes.
@@ -1353,15 +1386,24 @@ where
{
let mut router = self;
let excluded = paths
.iter()
.filter(|&p| p.exclude)
.map(|p| p.path.as_str())
.collect::<HashSet<_>>();
// register server functions first to allow for wildcard route in Leptos's Router
for (path, _) in server_fn::actix::server_fn_paths() {
let additional_context = additional_context.clone();
let handler = handle_server_fns_with_context(additional_context);
router = router.route(path, handler);
if !excluded.contains(path) {
let additional_context = additional_context.clone();
let handler =
handle_server_fns_with_context(additional_context);
router = router.route(path, handler);
}
}
// register routes defined in Leptos's Router
for listing in paths.iter() {
for listing in paths.iter().filter(|p| !p.exclude) {
let path = listing.path();
let mode = listing.mode();
@@ -1457,15 +1499,24 @@ impl LeptosRoutes for &mut ServiceConfig {
{
let mut router = self;
let excluded = paths
.iter()
.filter(|&p| p.exclude)
.map(|p| p.path.as_str())
.collect::<HashSet<_>>();
// register server functions first to allow for wildcard route in Leptos's Router
for (path, _) in server_fn::actix::server_fn_paths() {
let additional_context = additional_context.clone();
let handler = handle_server_fns_with_context(additional_context);
router = router.route(path, handler);
if !excluded.contains(path) {
let additional_context = additional_context.clone();
let handler =
handle_server_fns_with_context(additional_context);
router = router.route(path, handler);
}
}
// register routes defined in Leptos's Router
for listing in paths.iter() {
for listing in paths.iter().filter(|p| !p.exclude) {
let path = listing.path();
let mode = listing.mode();
@@ -1554,7 +1605,10 @@ where
ServerFnError::new("HttpRequest should have been provided via context")
})?;
T::extract(&req)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))
SendWrapper::new(async move {
T::extract(&req)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))
})
.await
}

View File

@@ -16,8 +16,6 @@ axum = { version = "0.7.5", default-features = false, features = [
] }
dashmap = "6"
futures = "0.3.30"
http = "1.1"
http-body-util = "0.1.2"
leptos = { workspace = true, features = ["nonce", "ssr"] }
server_fn = { workspace = true, features = ["axum-no-default"] }
leptos_macro = { workspace = true, features = ["axum"] }
@@ -26,7 +24,6 @@ leptos_router = { workspace = true, features = ["ssr"] }
leptos_integration_utils = { workspace = true }
once_cell = "1"
parking_lot = "0.12.3"
serde_json = "1.0"
tokio = { version = "1.39", default-features = false }
tower = { version = "0.4.13", features = ["util"] }
tower-http = "0.5.2"

View File

@@ -66,7 +66,7 @@ use leptos_router::{
components::provide_server_redirect,
location::RequestUrl,
static_routes::{RegenerationFn, StaticParamsMap},
PathSegment, RouteList, RouteListing, SsrMode,
ExpandOptionals, PathSegment, RouteList, RouteListing, SsrMode,
};
#[cfg(feature = "default")]
use once_cell::sync::Lazy;
@@ -74,7 +74,7 @@ use parking_lot::RwLock;
use server_fn::{redirect::REDIRECT_HEADER, ServerFnError};
#[cfg(feature = "default")]
use std::path::Path;
use std::{fmt::Debug, io, pin::Pin, sync::Arc};
use std::{collections::HashSet, fmt::Debug, io, pin::Pin, sync::Arc};
#[cfg(feature = "default")]
use tower::util::ServiceExt;
#[cfg(feature = "default")]
@@ -606,9 +606,9 @@ where
/// use axum::{
/// body::Body,
/// extract::Path,
/// http::Request,
/// response::{IntoResponse, Response},
/// };
/// use http::Request;
/// use leptos::{config::LeptosOptions, context::provide_context, prelude::*};
///
/// async fn custom_handler(
@@ -806,9 +806,9 @@ where
/// use axum::{
/// body::Body,
/// extract::Path,
/// http::Request,
/// response::{IntoResponse, Response},
/// };
/// use http::Request;
/// use leptos::context::provide_context;
///
/// async fn custom_handler(
@@ -1025,9 +1025,9 @@ where
/// use axum::{
/// body::Body,
/// extract::Path,
/// http::Request,
/// response::{IntoResponse, Response},
/// };
/// use http::Request;
/// use leptos::context::provide_context;
///
/// async fn custom_handler(
@@ -1093,9 +1093,9 @@ where
/// use axum::{
/// body::Body,
/// extract::Path,
/// http::Request,
/// response::{IntoResponse, Response},
/// };
/// use http::Request;
/// use leptos::context::provide_context;
///
/// async fn custom_handler(
@@ -1263,25 +1263,38 @@ pub struct AxumRouteListing {
methods: Vec<leptos_router::Method>,
#[allow(unused)]
regenerate: Vec<RegenerationFn>,
exclude: bool,
}
impl From<RouteListing> for AxumRouteListing {
fn from(value: RouteListing) -> Self {
let path = value.path().to_axum_path();
let path = if path.is_empty() {
"/".to_string()
} else {
path
};
let mode = value.mode();
let methods = value.methods().collect();
let regenerate = value.regenerate().into();
Self {
path,
mode: mode.clone(),
methods,
regenerate,
}
trait IntoRouteListing: Sized {
fn into_route_listing(self) -> Vec<AxumRouteListing>;
}
impl IntoRouteListing for RouteListing {
fn into_route_listing(self) -> Vec<AxumRouteListing> {
self.path()
.to_vec()
.expand_optionals()
.into_iter()
.map(|path| {
let path = path.to_axum_path();
let path = if path.is_empty() {
"/".to_string()
} else {
path
};
let mode = self.mode();
let methods = self.methods().collect();
let regenerate = self.regenerate().into();
AxumRouteListing {
path,
mode: mode.clone(),
methods,
regenerate,
exclude: false,
}
})
.collect()
}
}
@@ -1298,6 +1311,7 @@ impl AxumRouteListing {
mode,
methods: methods.into_iter().collect(),
regenerate: regenerate.into(),
exclude: false,
}
}
@@ -1342,8 +1356,7 @@ where
.with(|| {
// stub out a path for now
provide_context(RequestUrl::new(""));
let (mock_parts, _) =
http::Request::new(Body::from("")).into_parts();
let (mock_parts, _) = Request::new(Body::from("")).into_parts();
let (mock_meta, _) = ServerMetaContext::new();
provide_contexts("", &mock_meta, mock_parts, Default::default());
additional_context();
@@ -1361,27 +1374,36 @@ where
let mut routes = routes
.into_inner()
.into_iter()
.map(AxumRouteListing::from)
.flat_map(IntoRouteListing::into_route_listing)
.collect::<Vec<_>>();
(
if routes.is_empty() {
vec![AxumRouteListing::new(
"/".to_string(),
Default::default(),
[leptos_router::Method::Get],
vec![],
)]
} else {
// Routes to exclude from auto generation
if let Some(excluded_routes) = excluded_routes {
routes
.retain(|p| !excluded_routes.iter().any(|e| e == p.path()))
}
routes
},
generator,
)
let routes = if routes.is_empty() {
vec![AxumRouteListing::new(
"/".to_string(),
Default::default(),
[leptos_router::Method::Get],
vec![],
)]
} else {
// Routes to exclude from auto generation
if let Some(excluded_routes) = &excluded_routes {
routes.retain(|p| !excluded_routes.iter().any(|e| e == p.path()))
}
routes
};
let excluded =
excluded_routes
.into_iter()
.flatten()
.map(|path| AxumRouteListing {
path,
mode: Default::default(),
methods: Vec::new(),
regenerate: Vec::new(),
exclude: true,
});
(routes.into_iter().chain(excluded).collect(), generator)
}
/// Allows generating any prerendered routes.
@@ -1402,8 +1424,8 @@ impl StaticRouteGenerator {
let add_context = additional_context.clone();
move || {
let full_path = format!("http://leptos.dev{path}");
let mock_req = http::Request::builder()
.method(http::Method::GET)
let mock_req = Request::builder()
.method(Method::GET)
.header("Accept", "text/html")
.body(Body::empty())
.unwrap();
@@ -1495,10 +1517,12 @@ impl StaticRouteGenerator {
_ = routes;
_ = app_fn;
_ = additional_context;
panic!(
"Static routes are not currently supported on WASM32 server \
targets."
);
Self(Box::new(|_| {
panic!(
"Static routes are not currently supported on WASM32 \
server targets."
);
}))
}
}
@@ -1692,7 +1716,7 @@ trait AxumPath {
fn to_axum_path(&self) -> String;
}
impl AxumPath for &[PathSegment] {
impl AxumPath for Vec<PathSegment> {
fn to_axum_path(&self) -> String {
let mut path = String::new();
for segment in self.iter() {
@@ -1712,6 +1736,14 @@ impl AxumPath for &[PathSegment] {
path.push_str(s);
}
PathSegment::Unit => {}
PathSegment::OptionalParam(_) => {
#[cfg(feature = "tracing")]
tracing::error!(
"to_axum_path should only be called on expanded \
paths, which do not have OptionalParam any longer"
);
Default::default()
}
}
}
path
@@ -1767,32 +1799,41 @@ where
let mut router = self;
let excluded = paths
.iter()
.filter(|&p| p.exclude)
.map(|p| p.path.as_str())
.collect::<HashSet<_>>();
// register server functions
for (path, method) in server_fn::axum::server_fn_paths() {
let cx_with_state = cx_with_state.clone();
let handler = move |req: Request<Body>| async move {
handle_server_fns_with_context(cx_with_state, req).await
};
router = router.route(
path,
match method {
Method::GET => get(handler),
Method::POST => post(handler),
Method::PUT => put(handler),
Method::DELETE => delete(handler),
Method::PATCH => patch(handler),
_ => {
panic!(
"Unsupported server function HTTP method: \
{method:?}"
);
}
},
);
if !excluded.contains(path) {
router = router.route(
path,
match method {
Method::GET => get(handler),
Method::POST => post(handler),
Method::PUT => put(handler),
Method::DELETE => delete(handler),
Method::PATCH => patch(handler),
_ => {
panic!(
"Unsupported server function HTTP method: \
{method:?}"
);
}
},
);
}
}
// register router paths
for listing in paths.iter() {
for listing in paths.iter().filter(|p| !p.exclude) {
let path = listing.path();
for method in listing.methods() {
@@ -1901,7 +1942,7 @@ where
T: 'static,
{
let mut router = self;
for listing in paths.iter() {
for listing in paths.iter().filter(|p| !p.exclude) {
for method in listing.methods() {
router = router.route(
listing.path(),
@@ -1933,7 +1974,7 @@ where
///
/// #[server]
/// pub async fn request_method() -> Result<String, ServerFnError> {
/// use http::Method;
/// use axum::http::Method;
/// use leptos_axum::extract;
///
/// // you can extract anything that a regular Axum extractor can extract
@@ -1992,7 +2033,7 @@ where
move |uri: Uri, State(options): State<S>, req: Request<Body>| {
Box::pin(async move {
let options = LeptosOptions::from_ref(&options);
let res = get_static_file(uri, &options.site_root);
let res = get_static_file(uri, &options.site_root, req.headers());
let res = res.await.unwrap();
if res.status() == StatusCode::OK {
@@ -2026,14 +2067,26 @@ where
async fn get_static_file(
uri: Uri,
root: &str,
headers: &HeaderMap<HeaderValue>,
) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
use axum::http::header::ACCEPT_ENCODING;
let req = Request::builder().uri(uri);
let req = match headers.get(ACCEPT_ENCODING) {
Some(value) => req.header(ACCEPT_ENCODING, value),
None => req,
};
let req = req.body(Body::empty()).unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
match ServeDir::new(root)
.precompressed_gzip()
.precompressed_br()
.oneshot(req)
.await
{
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,

View File

@@ -28,7 +28,7 @@ paste = "1.0"
rand = { version = "0.8.5", optional = true }
reactive_graph = { workspace = true, features = ["serde"] }
rustc-hash = "2.0"
tachys = { workspace = true, features = ["reactive_graph", "oco"] }
tachys = { workspace = true, features = ["reactive_graph", "reactive_stores", "oco"] }
thiserror = "1.0"
tracing = { version = "0.1.40", optional = true }
typed-builder = "0.19.1"
@@ -45,7 +45,7 @@ web-sys = { version = "0.3.70", features = [
"ShadowRootInit",
"ShadowRootMode",
] }
wasm-bindgen = "0.2.93"
wasm-bindgen = "0.2.95"
serde_qs = "0.13.0"
slotmap = "1.0"
futures = "0.3.30"

View File

@@ -1,59 +0,0 @@
#![allow(deprecated)]
use crate::TextProp;
use std::rc::Rc;
/// A collection of additional HTML attributes to be applied to an element,
/// each of which may or may not be reactive.
#[derive(Clone)]
#[repr(transparent)]
#[deprecated = "Most uses of `AdditionalAttributes` can be replaced with `#[prop(attrs)]` \
and the `attr:` syntax. If you have a use case that still requires `AdditionalAttributes`, please \
open a GitHub issue here and share it: https://github.com/leptos-rs/leptos"]
pub struct AdditionalAttributes(pub(crate) Rc<[(String, TextProp)]>);
impl<I, T, U> From<I> for AdditionalAttributes
where
I: IntoIterator<Item = (T, U)>,
T: Into<String>,
U: Into<TextProp>,
{
fn from(value: I) -> Self {
Self(
value
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
)
}
}
impl Default for AdditionalAttributes {
fn default() -> Self {
Self([].into_iter().collect())
}
}
/// Iterator over additional HTML attributes.
#[repr(transparent)]
pub struct AdditionalAttributesIter<'a>(
std::slice::Iter<'a, (String, TextProp)>,
);
impl<'a> Iterator for AdditionalAttributesIter<'a> {
type Item = &'a (String, TextProp);
#[inline(always)]
fn next(&mut self) -> Option<Self::Item> {
self.0.next()
}
}
impl<'a> IntoIterator for &'a AdditionalAttributes {
type Item = &'a (String, TextProp);
type IntoIter = AdditionalAttributesIter<'a>;
fn into_iter(self) -> Self::IntoIter {
AdditionalAttributesIter(self.0.iter())
}
}

View File

@@ -1,12 +1,15 @@
use crate::{ChildrenFn, Show};
use crate::{children::ChildrenFn, component, control_flow::Show, IntoView};
use core::time::Duration;
use leptos::component;
use leptos_dom::{helpers::TimeoutHandle, IntoView};
use leptos_dom::helpers::TimeoutHandle;
use leptos_macro::view;
use leptos_reactive::{
create_render_effect, on_cleanup, signal_prelude::*, store_value,
StoredValue,
use reactive_graph::{
effect::RenderEffect,
owner::{on_cleanup, StoredValue},
signal::RwSignal,
traits::{Get, GetUntracked, GetValue, Set, SetValue},
wrappers::read::Signal,
};
use tachys::prelude::*;
/// A component that will show its children when the `when` condition is `true`.
/// Additionally, you need to specify a `hide_delay`. If the `when` condition changes to `false`,
@@ -16,10 +19,10 @@ use leptos_reactive::{
///
/// ```rust
/// # use core::time::Duration;
/// # use leptos::*;
/// # use leptos::prelude::*;
/// # #[component]
/// # pub fn App() -> impl IntoView {
/// let show = create_rw_signal(false);
/// let show = RwSignal::new(false);
///
/// view! {
/// <div
@@ -50,7 +53,7 @@ pub fn AnimatedShow(
children: ChildrenFn,
/// If the component should show or not
#[prop(into)]
when: MaybeSignal<bool>,
when: Signal<bool>,
/// Optional CSS class to apply if `when == true`
#[prop(optional)]
show_class: &'static str,
@@ -60,15 +63,15 @@ pub fn AnimatedShow(
/// The timeout after which the component will be unmounted if `when == false`
hide_delay: Duration,
) -> impl IntoView {
let handle: StoredValue<Option<TimeoutHandle>> = store_value(None);
let cls = create_rw_signal(if when.get_untracked() {
let handle: StoredValue<Option<TimeoutHandle>> = StoredValue::new(None);
let cls = RwSignal::new(if when.get_untracked() {
show_class
} else {
hide_class
});
let show = create_rw_signal(when.get_untracked());
let show = RwSignal::new(when.get_untracked());
create_render_effect(move |_| {
let eff = RenderEffect::new(move |_| {
if when.get() {
// clear any possibly active timer
if let Some(h) = handle.get_value() {
@@ -93,6 +96,7 @@ pub fn AnimatedShow(
if let Some(Some(h)) = handle.try_get_value() {
h.clear();
}
drop(eff);
});
view! {

View File

@@ -50,7 +50,7 @@ pub fn HydrationScripts(
path.parent().map(|p| p.to_path_buf()).unwrap_or_default()
})
.unwrap_or_default()
.join(&options.hash_file);
.join(options.hash_file.as_ref());
if hash_path.exists() {
let hashes = std::fs::read_to_string(&hash_path)
.expect("failed to read hash file");

View File

@@ -163,7 +163,7 @@ pub mod prelude {
form::*, hydration::*, into_view::*, mount::*, suspense::*,
};
pub use leptos_config::*;
pub use leptos_dom::{helpers::*, *};
pub use leptos_dom::helpers::*;
pub use leptos_macro::*;
pub use leptos_server::*;
pub use oco_ref::*;
@@ -174,7 +174,9 @@ pub mod prelude {
pub use server_fn::{self, ServerFnError};
pub use tachys::{
reactive_graph::{bind::BindAttribute, node_ref::*, Suspend},
view::template::ViewTemplate,
view::{
any_view::AnyView, fragment::Fragment, template::ViewTemplate,
},
};
}
pub use export_types::*;
@@ -202,8 +204,9 @@ pub mod error {
/// Control-flow components like `<Show>`, `<For>`, and `<Await>`.
pub mod control_flow {
pub use crate::{await_::*, for_loop::*, show::*};
pub use crate::{animated_show::*, await_::*, for_loop::*, show::*};
}
mod animated_show;
mod await_;
mod for_loop;
mod show;
@@ -326,233 +329,3 @@ pub use tracing;
pub use wasm_bindgen;
#[doc(hidden)]
pub use web_sys;
/*mod additional_attributes;
pub use additional_attributes::*;
pub use await_::*;
pub use leptos_config::{self, get_configuration, LeptosOptions};
#[cfg(not(all(
target_arch = "wasm32",
any(feature = "csr", feature = "hydrate")
)))]
/// Utilities for server-side rendering HTML.
pub mod ssr {
pub use leptos_dom::{ssr::*, ssr_in_order::*};
}
pub use leptos_dom::{
self, create_node_ref, document, ev,
helpers::{
event_target, event_target_checked, event_target_value,
request_animation_frame, request_animation_frame_with_handle,
request_idle_callback, request_idle_callback_with_handle, set_interval,
set_interval_with_handle, set_timeout, set_timeout_with_handle,
window_event_listener, window_event_listener_untyped,
},
html,
html::Binding,
math, mount_to, mount_to_body, nonce, svg, window, Attribute, Class,
CollectView, Errors, EventHandlerFn, Fragment, HtmlElement, IntoAttribute,
IntoClass, IntoProperty, IntoStyle, IntoView, NodeRef, Property, View,
};
/// Types to make it easier to handle errors in your application.
pub mod error {
pub use server_fn::error::{Error, Result};
}
#[cfg(all(target_arch = "wasm32", feature = "template_macro"))]
pub use leptos_macro::template;
#[cfg(not(all(target_arch = "wasm32", feature = "template_macro")))]
pub use leptos_macro::view as template;
pub use leptos_macro::{component, island, slice, slot, view, Params};
cfg_if::cfg_if!(
if #[cfg(feature="spin")] {
pub use leptos_spin_macro::server;
} else {
pub use leptos_macro::server;
}
);
pub use leptos_reactive::*;
pub use leptos_server::{
self, create_action, create_multi_action, create_server_action,
create_server_multi_action, Action, MultiAction, ServerFnError,
ServerFnErrorErr,
};
pub use server_fn::{self, ServerFn as _};
mod error_boundary;
pub use error_boundary::*;
mod animated_show;
mod for_loop;
mod provider;
mod show;
pub use animated_show::*;
pub use for_loop::*;
pub use provider::*;
#[cfg(feature = "experimental-islands")]
pub use serde;
#[cfg(feature = "experimental-islands")]
pub use serde_json;
pub use show::*;
//pub use suspense_component::*;
mod suspense_component;
//mod transition;
#[cfg(feature = "tracing")]
#[doc(hidden)]
pub use tracing;
pub use transition::*;
#[doc(hidden)]
pub use typed_builder;
#[doc(hidden)]
pub use typed_builder::Optional;
#[doc(hidden)]
pub use typed_builder_macro;
#[doc(hidden)]
#[cfg(any(
feature = "csr",
feature = "hydrate",
feature = "template_macro"
))]
pub use wasm_bindgen; // used in islands
#[doc(hidden)]
#[cfg(any(
feature = "csr",
feature = "hydrate",
feature = "template_macro"
))]
pub use web_sys; // used in islands
mod children;
mod portal;
mod view_fn;
pub use children::*;
pub use portal::*;
pub use view_fn::*;
extern crate self as leptos;
/// A type for taking anything that implements [`IntoAttribute`].
///
/// ```rust
/// use leptos::*;
///
/// #[component]
/// pub fn MyHeading(
/// text: String,
/// #[prop(optional, into)] class: Option<AttributeValue>,
/// ) -> impl IntoView {
/// view! {
/// <h1 class=class>{text}</h1>
/// }
/// }
/// ```
pub type AttributeValue = Box<dyn IntoAttribute>;
#[doc(hidden)]
pub trait Component<P> {}
#[doc(hidden)]
pub trait Props {
type Builder;
fn builder() -> Self::Builder;
}
#[doc(hidden)]
pub trait DynAttrs {
fn dyn_attrs(self, _args: Vec<(&'static str, Attribute)>) -> Self
where
Self: Sized,
{
self
}
}
impl DynAttrs for () {}
#[doc(hidden)]
pub trait DynBindings {
fn dyn_bindings<B: Into<Binding>>(
self,
_args: impl IntoIterator<Item = B>,
) -> Self
where
Self: Sized,
{
self
}
}
impl DynBindings for () {}
#[doc(hidden)]
pub trait PropsOrNoPropsBuilder {
type Builder;
fn builder_or_not() -> Self::Builder;
}
#[doc(hidden)]
#[derive(Copy, Clone, Debug, Default)]
pub struct EmptyPropsBuilder {}
impl EmptyPropsBuilder {
pub fn build(self) {}
}
impl<P: Props> PropsOrNoPropsBuilder for P {
type Builder = <P as Props>::Builder;
fn builder_or_not() -> Self::Builder {
Self::builder()
}
}
impl PropsOrNoPropsBuilder for EmptyPropsBuilder {
type Builder = EmptyPropsBuilder;
fn builder_or_not() -> Self::Builder {
EmptyPropsBuilder {}
}
}
impl<F, R> Component<EmptyPropsBuilder> for F where F: FnOnce() -> R {}
impl<P, F, R> Component<P> for F
where
F: FnOnce(P) -> R,
P: Props,
{
}
#[doc(hidden)]
pub fn component_props_builder<P: PropsOrNoPropsBuilder>(
_f: &impl Component<P>,
) -> <P as PropsOrNoPropsBuilder>::Builder {
<P as PropsOrNoPropsBuilder>::builder_or_not()
}
#[doc(hidden)]
pub fn component_view<P>(f: impl ComponentConstructor<P>, props: P) -> View {
f.construct(props)
}
#[doc(hidden)]
pub trait ComponentConstructor<P> {
fn construct(self, props: P) -> View;
}
impl<Func, V> ComponentConstructor<()> for Func
where
Func: FnOnce() -> V,
V: IntoView,
{
fn construct(self, (): ()) -> View {
(self)().into_view()
}
}
impl<Func, V, P> ComponentConstructor<P> for Func
where
Func: FnOnce(P) -> V,
V: IntoView,
P: PropsOrNoPropsBuilder,
{
fn construct(self, props: P) -> View {
(self)(props).into_view()
}
}*/

View File

@@ -1,30 +0,0 @@
use leptos_dom::{IntoView, View};
use std::rc::Rc;
/// New-type wrapper for the a function that returns a view with `From` and `Default` traits implemented
/// to enable optional props in for example `<Show>` and `<Suspense>`.
#[derive(Clone)]
pub struct ViewFn(Rc<dyn Fn() -> View>);
impl Default for ViewFn {
fn default() -> Self {
Self(Rc::new(|| ().into_view()))
}
}
impl<F, IV> From<F> for ViewFn
where
F: Fn() -> IV + 'static,
IV: IntoView,
{
fn from(value: F) -> Self {
Self(Rc::new(move || value().into_view()))
}
}
impl ViewFn {
/// Execute the wrapped function
pub fn run(&self) -> View {
(self.0)()
}
}

View File

@@ -15,7 +15,7 @@ config = { version = "0.14.0", default-features = false, features = [
"convert-case",
] }
regex = "1.10"
serde = { version = "1.0", features = ["derive"] }
serde = { version = "1.0", features = ["derive", "rc"] }
thiserror = "1.0"
typed-builder = "0.19.1"

View File

@@ -5,7 +5,9 @@ pub mod errors;
use crate::errors::LeptosConfigError;
use config::{Case, Config, File, FileFormat};
use regex::Regex;
use std::{env::VarError, fs, net::SocketAddr, path::Path, str::FromStr};
use std::{
env::VarError, fs, net::SocketAddr, path::Path, str::FromStr, sync::Arc,
};
use typed_builder::TypedBuilder;
/// A Struct to allow us to parse LeptosOptions from the file. Not really needed, most interactions should
@@ -25,17 +27,17 @@ pub struct ConfFile {
pub struct LeptosOptions {
/// The name of the WASM and JS files generated by wasm-bindgen. Defaults to the crate name with underscores instead of dashes
#[builder(setter(into), default=default_output_name())]
pub output_name: String,
pub output_name: Arc<str>,
/// The path of the all the files generated by cargo-leptos. This defaults to '.' for convenience when integrating with other
/// tools.
#[builder(setter(into), default=default_site_root())]
#[serde(default = "default_site_root")]
pub site_root: String,
pub site_root: Arc<str>,
/// The path of the WASM and JS files generated by wasm-bindgen from the root of your app
/// By default, wasm-bindgen puts them in `pkg`.
#[builder(setter(into), default=default_site_pkg_dir())]
#[serde(default = "default_site_pkg_dir")]
pub site_pkg_dir: String,
pub site_pkg_dir: Arc<str>,
/// Used to configure the running environment of Leptos. Can be used to load dev constants and keys v prod, or change
/// things based on the deployment environment
/// I recommend passing in the result of `env::var("LEPTOS_ENV")`
@@ -66,11 +68,11 @@ pub struct LeptosOptions {
/// The path of a custom 404 Not Found page to display when statically serving content, defaults to `site_root/404.html`
#[builder(default = default_not_found_path())]
#[serde(default = "default_not_found_path")]
pub not_found_path: String,
pub not_found_path: Arc<str>,
/// The file name of the hash text file generated by cargo-leptos. Defaults to `hash.txt`.
#[builder(default = default_hash_file_name())]
#[serde(default = "default_hash_file_name")]
pub hash_file: String,
pub hash_file: Arc<str>,
/// If true, hashes will be generated for all files in the site_root and added to their file names.
/// Defaults to `true`.
#[builder(default = default_hash_files())]
@@ -96,9 +98,9 @@ impl LeptosOptions {
);
}
Ok(LeptosOptions {
output_name,
site_root: env_w_default("LEPTOS_SITE_ROOT", "target/site")?,
site_pkg_dir: env_w_default("LEPTOS_SITE_PKG_DIR", "pkg")?,
output_name: output_name.into(),
site_root: env_w_default("LEPTOS_SITE_ROOT", "target/site")?.into(),
site_pkg_dir: env_w_default("LEPTOS_SITE_PKG_DIR", "pkg")?.into(),
env: env_from_str(env_w_default("LEPTOS_ENV", "DEV")?.as_str())?,
site_addr: env_w_default("LEPTOS_SITE_ADDR", "127.0.0.1:3000")?
.parse()?,
@@ -113,8 +115,10 @@ impl LeptosOptions {
reload_ws_protocol: ws_from_str(
env_w_default("LEPTOS_RELOAD_WS_PROTOCOL", "ws")?.as_str(),
)?,
not_found_path: env_w_default("LEPTOS_NOT_FOUND_PATH", "/404")?,
hash_file: env_w_default("LEPTOS_HASH_FILE_NAME", "hash.txt")?,
not_found_path: env_w_default("LEPTOS_NOT_FOUND_PATH", "/404")?
.into(),
hash_file: env_w_default("LEPTOS_HASH_FILE_NAME", "hash.txt")?
.into(),
hash_files: env_w_default("LEPTOS_HASH_FILES", "false")?.parse()?,
})
}
@@ -126,16 +130,16 @@ impl Default for LeptosOptions {
}
}
fn default_output_name() -> String {
env!("CARGO_CRATE_NAME").replace('-', "_")
fn default_output_name() -> Arc<str> {
env!("CARGO_CRATE_NAME").replace('-', "_").into()
}
fn default_site_root() -> String {
".".to_string()
fn default_site_root() -> Arc<str> {
".".into()
}
fn default_site_pkg_dir() -> String {
"pkg".to_string()
fn default_site_pkg_dir() -> Arc<str> {
"pkg".into()
}
fn default_env() -> Env {
@@ -150,12 +154,12 @@ fn default_reload_port() -> u32 {
3001
}
fn default_not_found_path() -> String {
"/404".to_string()
fn default_not_found_path() -> Arc<str> {
"/404".into()
}
fn default_hash_file_name() -> String {
"hash.txt".to_string()
fn default_hash_file_name() -> Arc<str> {
"hash.txt".into()
}
fn default_hash_files() -> bool {

View File

@@ -76,9 +76,9 @@ fn try_from_env_test() {
|| LeptosOptions::try_from_env().unwrap(),
);
assert_eq!(config.output_name, "app_test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app_test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()

View File

@@ -50,9 +50,9 @@ async fn get_configuration_from_file_ok() {
)
.await;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -106,9 +106,9 @@ async fn get_config_from_file_ok() {
)
.await;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -151,9 +151,9 @@ fn get_config_from_str_content() {
|| get_config_from_str(CARGO_TOML_CONTENT_OK).unwrap(),
);
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -178,9 +178,9 @@ async fn get_config_from_env() {
)
.await;
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -202,8 +202,8 @@ async fn get_config_from_env() {
)
.await;
assert_eq!(config.site_root, "target/site");
assert_eq!(config.site_pkg_dir, "pkg");
assert_eq!(config.site_root.as_ref(), "target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("127.0.0.1:3000").unwrap()
@@ -215,10 +215,10 @@ async fn get_config_from_env() {
#[test]
fn leptos_options_builder_default() {
let conf = LeptosOptions::builder().output_name("app-test").build();
assert_eq!(conf.output_name, "app-test");
assert_eq!(conf.output_name.as_ref(), "app-test");
assert!(matches!(conf.env, Env::DEV));
assert_eq!(conf.site_pkg_dir, "pkg");
assert_eq!(conf.site_root, ".");
assert_eq!(conf.site_pkg_dir.as_ref(), "pkg");
assert_eq!(conf.site_root.as_ref(), ".");
assert_eq!(
conf.site_addr,
SocketAddr::from_str("127.0.0.1:3000").unwrap()
@@ -242,9 +242,9 @@ fn environment_variable_override() {
|| get_config_from_str(CARGO_TOML_CONTENT_OK).unwrap(),
);
assert_eq!(config.output_name, "app-test");
assert_eq!(config.site_root, "my_target/site");
assert_eq!(config.site_pkg_dir, "my_pkg");
assert_eq!(config.output_name.as_ref(), "app-test");
assert_eq!(config.site_root.as_ref(), "my_target/site");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:80").unwrap()
@@ -265,9 +265,9 @@ fn environment_variable_override() {
|| get_config_from_str(CARGO_TOML_CONTENT_OK).unwrap(),
);
assert_eq!(config.output_name, "app-test2");
assert_eq!(config.site_root, "my_target/site2");
assert_eq!(config.site_pkg_dir, "my_pkg2");
assert_eq!(config.output_name.as_ref(), "app-test2");
assert_eq!(config.site_root.as_ref(), "my_target/site2");
assert_eq!(config.site_pkg_dir.as_ref(), "my_pkg2");
assert_eq!(
config.site_addr,
SocketAddr::from_str("0.0.0.0:82").unwrap()

View File

@@ -8,5 +8,4 @@ pub mod helpers;
pub mod macro_helpers;
/// Utilities for simple isomorphic logging to the console or terminal.
#[macro_use]
pub mod logging;

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_macro"
version = "0.7.0-gamma"
version = "0.7.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
@@ -48,14 +48,35 @@ experimental-islands = []
trace-component-props = []
actix = ["server_fn_macro/actix"]
axum = ["server_fn_macro/axum"]
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"]
[
"csr",
"hydrate",
],
[
"hydrate",
"csr",
],
[
"hydrate",
"ssr",
],
[
"actix",
"axum",
],
[
"actix",
"generic",
],
[
"generic",
"axum",
],
]
[package.metadata.docs.rs]

View File

@@ -926,7 +926,7 @@ pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// Derives a trait that parses a map of string keys and values into a typed
/// data structure, e.g., for route params.
#[proc_macro_derive(Params, attributes(params))]
#[proc_macro_derive(Params)]
pub fn params_derive(
input: proc_macro::TokenStream,
) -> proc_macro::TokenStream {

View File

@@ -32,7 +32,7 @@ pub fn params_impl(ast: &syn::DeriveInput) -> proc_macro::TokenStream {
let gen = quote! {
impl Params for #name {
fn from_map(map: &::leptos_router::params::ParamsMap) -> Result<Self, ::leptos_router::params::ParamsError> {
fn from_map(map: &::leptos_router::params::ParamsMap) -> ::core::result::Result<Self, ::leptos_router::params::ParamsError> {
Ok(Self {
#(#fields,)*
})

View File

@@ -1,4 +1,6 @@
use super::{fragment_to_tokens, TagType};
use super::{
fragment_to_tokens, utils::is_nostrip_optional_and_update_key, TagType,
};
use crate::view::{attribute_absolute, utils::filter_prefixed_attrs};
use proc_macro2::{Ident, TokenStream, TokenTree};
use quote::{format_ident, quote, quote_spanned};
@@ -44,9 +46,10 @@ pub(crate) fn component_to_tokens(
})
.unwrap_or_else(|| node.attributes().len());
let attrs = node
.attributes()
.iter()
// Initially using uncloned mutable reference, as the node.key might be mutated during prop extraction (for nostrip:)
let mut attrs = node
.attributes_mut()
.iter_mut()
.filter_map(|node| {
if let NodeAttribute::Attribute(node) = node {
Some(node)
@@ -54,39 +57,46 @@ pub(crate) fn component_to_tokens(
None
}
})
.cloned()
.collect::<Vec<_>>();
let props = attrs
.iter()
.enumerate()
.filter(|(idx, attr)| {
idx < &spread_marker && {
let attr_key = attr.key.to_string();
!is_attr_let(&attr.key)
&& !attr_key.starts_with("clone:")
&& !attr_key.starts_with("class:")
&& !attr_key.starts_with("style:")
&& !attr_key.starts_with("attr:")
&& !attr_key.starts_with("prop:")
&& !attr_key.starts_with("on:")
&& !attr_key.starts_with("use:")
}
})
.map(|(_, attr)| {
let name = &attr.key;
let mut required_props = vec![];
let mut optional_props = vec![];
for (_, attr) in attrs.iter_mut().enumerate().filter(|(idx, attr)| {
idx < &spread_marker && {
let attr_key = attr.key.to_string();
!is_attr_let(&attr.key)
&& !attr_key.starts_with("clone:")
&& !attr_key.starts_with("class:")
&& !attr_key.starts_with("style:")
&& !attr_key.starts_with("attr:")
&& !attr_key.starts_with("prop:")
&& !attr_key.starts_with("on:")
&& !attr_key.starts_with("use:")
}
}) {
let optional = is_nostrip_optional_and_update_key(&mut attr.key);
let name = &attr.key;
let value = attr
.value()
.map(|v| {
quote! { #v }
})
.unwrap_or_else(|| quote! { #name });
let value = attr
.value()
.map(|v| {
quote! { #v }
})
.unwrap_or_else(|| quote! { #name });
quote! {
if optional {
optional_props.push(quote! {
props.#name = { #value }.map(Into::into);
})
} else {
required_props.push(quote! {
.#name(#[allow(unused_braces)] { #value })
}
});
})
}
}
// Drop the mutable reference to the node, go to an owned clone:
let attrs = attrs.into_iter().map(|a| a.clone()).collect::<Vec<_>>();
let items_to_bind = attrs
.iter()
@@ -264,14 +274,20 @@ pub(crate) fn component_to_tokens(
let mut component = quote! {
{
#[allow(unreachable_code)]
#[allow(unused_mut)]
#[allow(clippy::let_and_return)]
::leptos::component::component_view(
#[allow(clippy::needless_borrows_for_generic_args)]
&#name,
::leptos::component::component_props_builder(&#name #generics)
#(#props)*
#(#slots)*
#children
.build()
{
let mut props = ::leptos::component::component_props_builder(&#name #generics)
#(#required_props)*
#(#slots)*
#children
.build();
#(#optional_props)*
props
}
)
#spreads
}

View File

@@ -23,8 +23,8 @@ use std::{
collections::{HashMap, HashSet, VecDeque},
};
use syn::{
spanned::Spanned, Expr, Expr::Tuple, ExprLit, ExprRange, Lit, LitStr,
RangeLimits, Stmt,
spanned::Spanned, Expr, Expr::Tuple, ExprArray, ExprLit, ExprRange, Lit,
LitStr, RangeLimits, Stmt,
};
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -650,6 +650,9 @@ pub(crate) fn element_to_tokens(
_ => None,
};
match (key_a.as_deref(), key_b.as_deref()) {
(Some("class"), Some("class")) | (Some("style"), Some("style")) => {
Ordering::Equal
}
(Some("class"), _) | (Some("style"), _) => Ordering::Less,
(_, Some("class")) | (_, Some("style")) => Ordering::Greater,
_ => Ordering::Equal,
@@ -661,9 +664,18 @@ pub(crate) fn element_to_tokens(
for attr in node.attributes() {
if let NodeAttribute::Attribute(attr) = attr {
let mut name = attr.key.to_string();
if let Some(tuple_name) = tuple_name(&name, attr) {
name.push(':');
name.push_str(&tuple_name);
match tuple_name(&name, attr) {
TupleName::None => {}
TupleName::Str(tuple_name) => {
name.push(':');
name.push_str(&tuple_name);
}
TupleName::Array(names) => {
for tuple_name in names {
name.push(':');
name.push_str(&tuple_name);
}
}
}
if names.contains(&name) {
proc_macro_error2::emit_error!(
@@ -1183,6 +1195,32 @@ fn class_to_tokens(
class: TokenStream,
class_name: Option<&str>,
) -> TokenStream {
// case of class=(["foo", "bar"], /* something */)
// just expands to multiple uses of class:
if let Some(Tuple(tuple)) = node.value() {
if tuple.elems.len() == 2 {
let name = &tuple.elems[0];
let value = &tuple.elems[1];
if let Expr::Array(ExprArray { elems, .. }) = name {
return elems
.iter()
.map(|elem| match elem {
Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) => quote! {
.#class((#s, #value))
},
_ => proc_macro_error2::abort!(
elem.span(),
"invalid name"
),
})
.collect();
}
}
}
// default case
let value = attribute_value(node, false);
if let Some(class_name) = class_name {
quote! {
@@ -1617,7 +1655,7 @@ pub(crate) fn directive_call_from_attribute_node(
quote! { .directive(#handler, #[allow(clippy::useless_conversion)] #param) }
}
fn tuple_name(name: &str, node: &KeyedAttribute) -> Option<String> {
fn tuple_name(name: &str, node: &KeyedAttribute) -> TupleName {
if name == "style" || name == "class" {
if let Some(Tuple(tuple)) = node.value() {
{
@@ -1627,12 +1665,37 @@ fn tuple_name(name: &str, node: &KeyedAttribute) -> Option<String> {
lit: Lit::Str(s), ..
}) = style_name
{
return Some(s.value());
return TupleName::Str(s.value());
} else if let Expr::Array(ExprArray { elems, .. }) =
style_name
{
return TupleName::Array(
elems
.iter()
.filter_map(|elem| match elem {
Expr::Lit(ExprLit {
lit: Lit::Str(s),
..
}) => Some(s.value()),
_ => proc_macro_error2::abort!(
elem.span(),
"invalid name"
),
})
.collect(),
);
}
}
}
}
}
None
TupleName::None
}
#[derive(Debug)]
enum TupleName {
None,
Str(String),
Array(Vec<String>),
}

View File

@@ -1,7 +1,7 @@
use proc_macro2::Ident;
use quote::format_ident;
use rstml::node::KeyedAttribute;
use syn::spanned::Spanned;
use rstml::node::{KeyedAttribute, NodeName};
use syn::{spanned::Spanned, ExprPath};
pub fn filter_prefixed_attrs<'a, A>(attrs: A, prefix: &str) -> Vec<Ident>
where
@@ -17,3 +17,37 @@ where
})
.collect()
}
/// Handle nostrip: prefix:
/// if there strip from the name, and return true to indicate that
/// the prop should be an Option<T> and shouldn't be called on the builder if None,
/// if Some(T) then T supplied to the builder.
pub fn is_nostrip_optional_and_update_key(key: &mut NodeName) -> bool {
let maybe_cleaned_name_and_span = if let NodeName::Punctuated(punct) = &key
{
if punct.len() == 2 {
if let Some(cleaned_name) = key.to_string().strip_prefix("nostrip:")
{
punct
.get(1)
.map(|segment| (cleaned_name.to_string(), segment.span()))
} else {
None
}
} else {
None
}
} else {
None
};
if let Some((cleaned_name, span)) = maybe_cleaned_name_and_span {
*key = NodeName::Path(ExprPath {
attrs: vec![],
qself: None,
path: format_ident!("{}", cleaned_name, span = span).into(),
});
true
} else {
false
}
}

View File

@@ -4,6 +4,7 @@ use leptos::prelude::*;
#[component]
fn Component(
#[prop(optional)] optional: bool,
#[prop(optional, into)] optional_into: Option<String>,
#[prop(optional_no_strip)] optional_no_strip: Option<String>,
#[prop(strip_option)] strip_option: Option<u8>,
#[prop(default = NonZeroUsize::new(10).unwrap())] default: NonZeroUsize,
@@ -11,6 +12,7 @@ fn Component(
impl_trait: impl Fn() -> i32 + 'static,
) -> impl IntoView {
_ = optional;
_ = optional_into;
_ = optional_no_strip;
_ = strip_option;
_ = default;
@@ -26,9 +28,29 @@ fn component() {
.impl_trait(|| 42)
.build();
assert!(!cp.optional);
assert_eq!(cp.optional_into, None);
assert_eq!(cp.optional_no_strip, None);
assert_eq!(cp.strip_option, Some(9));
assert_eq!(cp.default, NonZeroUsize::new(10).unwrap());
assert_eq!(cp.into, "");
assert_eq!((cp.impl_trait)(), 42);
}
#[test]
fn component_nostrip() {
// Should compile (using nostrip:optional_into in second <Component />)
view! {
<Component
optional_into="foo"
strip_option=9
into=""
impl_trait=|| 42
/>
<Component
nostrip:optional_into=Some("foo")
strip_option=9
into=""
impl_trait=|| 42
/>
};
}

View File

@@ -185,7 +185,7 @@ impl FromEncodedStr for [u8] {
mod view_implementations {
use crate::Resource;
use reactive_graph::traits::Read;
use std::{future::Future, pin::Pin};
use std::future::Future;
use tachys::{
html::attribute::Attribute,
hydration::Cursor,
@@ -219,16 +219,11 @@ mod view_implementations {
{
type Output<SomeNewAttr: Attribute> = Box<
dyn FnMut() -> Suspend<
Pin<
Box<
dyn Future<
Output = <T as AddAnyAttr>::Output<
<SomeNewAttr::CloneableOwned as Attribute>::CloneableOwned,
>,
> + Send,
>,
>,
> + Send,
<T as AddAnyAttr>::Output<
<SomeNewAttr::CloneableOwned as Attribute>::CloneableOwned,
>,
>
+ Send
>;
fn add_any_attr<NewAttr: Attribute>(

View File

@@ -18,7 +18,7 @@ use std::{
};
pub struct ArcLocalResource<T> {
data: ArcAsyncDerived<T>,
data: ArcAsyncDerived<SendWrapper<T>>,
#[cfg(debug_assertions)]
defined_at: &'static Location<'static>,
}
@@ -59,8 +59,12 @@ impl<T> ArcLocalResource<T> {
}
}
};
let fetcher = SendWrapper::new(fetcher);
Self {
data: ArcAsyncDerived::new_unsync(fetcher),
data: ArcAsyncDerived::new(move || {
let fut = fetcher();
SendWrapper::new(async move { SendWrapper::new(fut.await) })
}),
#[cfg(debug_assertions)]
defined_at: Location::caller(),
}
@@ -72,9 +76,14 @@ where
T: Clone + 'static,
{
type Output = T;
type IntoFuture = AsyncDerivedFuture<T>;
type IntoFuture = futures::future::Map<
AsyncDerivedFuture<SendWrapper<T>>,
fn(SendWrapper<T>) -> T,
>;
fn into_future(self) -> Self::IntoFuture {
use futures::FutureExt;
if let Some(mut notifier) = use_context::<LocalResourceNotifier>() {
notifier.notify();
} else if cfg!(feature = "ssr") {
@@ -84,7 +93,7 @@ where
always pending on the server."
);
}
self.data.into_future()
self.data.into_future().map(|value| (*value).clone())
}
}
@@ -105,7 +114,8 @@ impl<T> ReadUntracked for ArcLocalResource<T>
where
T: 'static,
{
type Value = ReadGuard<Option<T>, AsyncPlain<Option<T>>>;
type Value =
ReadGuard<Option<SendWrapper<T>>, AsyncPlain<Option<SendWrapper<T>>>>;
fn try_read_untracked(&self) -> Option<Self::Value> {
if let Some(mut notifier) = use_context::<LocalResourceNotifier>() {
@@ -373,3 +383,23 @@ where
self.data.clear_sources(subscriber);
}
}
impl<T: 'static> From<ArcLocalResource<T>> for LocalResource<T> {
fn from(arc: ArcLocalResource<T>) -> Self {
Self {
data: arc.data.into(),
#[cfg(debug_assertions)]
defined_at: arc.defined_at,
}
}
}
impl<T: 'static> From<LocalResource<T>> for ArcLocalResource<T> {
fn from(local: LocalResource<T>) -> Self {
Self {
data: local.data.into(),
#[cfg(debug_assertions)]
defined_at: local.defined_at,
}
}
}

View File

@@ -76,6 +76,38 @@ impl<T, Ser> Debug for ArcResource<T, Ser> {
}
}
impl<T, Ser> From<ArcResource<T, Ser>> for Resource<T, Ser>
where
T: Send + Sync,
{
#[track_caller]
fn from(arc_resource: ArcResource<T, Ser>) -> Self {
Resource {
ser: PhantomData,
data: arc_resource.data.into(),
refetch: arc_resource.refetch.into(),
#[cfg(debug_assertions)]
defined_at: Location::caller(),
}
}
}
impl<T, Ser> From<Resource<T, Ser>> for ArcResource<T, Ser>
where
T: Send + Sync,
{
#[track_caller]
fn from(resource: Resource<T, Ser>) -> Self {
ArcResource {
ser: PhantomData,
data: resource.data.into(),
refetch: resource.refetch.into(),
#[cfg(debug_assertions)]
defined_at: Location::caller(),
}
}
}
impl<T, Ser> DefinedAt for ArcResource<T, Ser> {
fn defined_at(&self) -> Option<&'static Location<'static>> {
#[cfg(debug_assertions)]
@@ -129,22 +161,23 @@ where
#[cfg(all(feature = "hydration", debug_assertions))]
{
use reactive_graph::{
computed::suspense::SuspenseContext, owner::use_context,
computed::suspense::SuspenseContext, effect::in_effect_scope,
owner::use_context,
};
let suspense = use_context::<SuspenseContext>();
if suspense.is_none() {
if !in_effect_scope() && use_context::<SuspenseContext>().is_none()
{
let location = std::panic::Location::caller();
reactive_graph::log_warning(format_args!(
"At {location}, you are reading a resource in `hydrate` \
mode outside a <Suspense/> or <Transition/>. This can \
cause hydration mismatch errors and loses out on a \
significant performance optimization. To fix this issue, \
you can either: \n1. Wrap the place where you read the \
resource in a <Suspense/> or <Transition/> component, or \
\n2. Switch to using ArcLocalResource::new(), which will \
wait to load the resource until the app is hydrated on \
the client side. (This will have worse performance in \
most cases.)",
mode outside a <Suspense/> or <Transition/> or effect. \
This can cause hydration mismatch errors and loses out \
on a significant performance optimization. To fix this \
issue, you can either: \n1. Wrap the place where you \
read the resource in a <Suspense/> or <Transition/> \
component, or \n2. Switch to using \
ArcLocalResource::new(), which will wait to load the \
resource until the app is hydrated on the client side. \
(This will have worse performance in most cases.)",
));
}
}
@@ -641,22 +674,23 @@ where
#[cfg(all(feature = "hydration", debug_assertions))]
{
use reactive_graph::{
computed::suspense::SuspenseContext, owner::use_context,
computed::suspense::SuspenseContext, effect::in_effect_scope,
owner::use_context,
};
let suspense = use_context::<SuspenseContext>();
if suspense.is_none() {
if !in_effect_scope() && use_context::<SuspenseContext>().is_none()
{
let location = std::panic::Location::caller();
reactive_graph::log_warning(format_args!(
"At {location}, you are reading a resource in `hydrate` \
mode outside a <Suspense/> or <Transition/>. This can \
cause hydration mismatch errors and loses out on a \
significant performance optimization. To fix this issue, \
you can either: \n1. Wrap the place where you read the \
resource in a <Suspense/> or <Transition/> component, or \
\n2. Switch to using LocalResource::new(), which will \
wait to load the resource until the app is hydrated on \
the client side. (This will have worse performance in \
most cases.)",
mode outside a <Suspense/> or <Transition/> or effect. \
This can cause hydration mismatch errors and loses out \
on a significant performance optimization. To fix this \
issue, you can either: \n1. Wrap the place where you \
read the resource in a <Suspense/> or <Transition/> \
component, or \n2. Switch to using LocalResource::new(), \
which will wait to load the resource until the app is \
hydrated on the client side. (This will have worse \
performance in most cases.)",
));
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_meta"
version = "0.7.0-gamma"
version = "0.7.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"

View File

@@ -54,7 +54,7 @@ pub fn HashedStylesheet(
path.parent().map(|p| p.to_path_buf()).unwrap_or_default()
})
.unwrap_or_default()
.join(&options.hash_file);
.join(options.hash_file.as_ref());
if hash_path.exists() {
let hashes = std::fs::read_to_string(&hash_path)
.expect("failed to read hash file");

View File

@@ -1,6 +1,6 @@
[package]
name = "next_tuple"
version = "0.1.0-gamma"
version = "0.1.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"

View File

@@ -1,6 +1,6 @@
[package]
name = "reactive_graph"
version = "0.1.0-gamma"
version = "0.1.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"

View File

@@ -6,7 +6,7 @@ use crate::{
SyncStorage,
},
signal::{ArcRwSignal, RwSignal},
traits::{DefinedAt, Dispose, Get, GetUntracked, GetValue, Update},
traits::{DefinedAt, Dispose, Get, GetUntracked, GetValue, Set, Update},
unwrap_signal,
};
use any_spawner::Executor;
@@ -202,6 +202,15 @@ where
defined_at: Location::caller(),
}
}
/// Clears the value of the action, setting its current value to `None`.
///
/// This has no other effect: i.e., it will not cancel in-flight actions, set the
/// input, etc.
#[track_caller]
pub fn clear(&self) {
self.value.set(None);
}
}
/// A handle that allows aborting an in-flight action. It is returned from [`Action::dispatch`] or
@@ -678,6 +687,22 @@ where
}
}
impl<I, O, S> Action<I, O, S>
where
I: 'static,
O: 'static,
S: Storage<ArcAction<I, O>>,
{
/// Clears the value of the action, setting its current value to `None`.
///
/// This has no other effect: i.e., it will not cancel in-flight actions, set the
/// input, etc.
#[track_caller]
pub fn clear(&self) {
self.inner.try_with_value(|inner| inner.value.set(None));
}
}
impl<I, O> Action<I, O, LocalStorage>
where
I: 'static,

View File

@@ -109,6 +109,19 @@ where
}
}
impl<T> From<AsyncDerived<T>> for ArcAsyncDerived<T>
where
T: Send + Sync + 'static,
{
#[track_caller]
fn from(value: AsyncDerived<T>) -> Self {
value
.inner
.try_get_value()
.unwrap_or_else(unwrap_signal!(value))
}
}
impl<T> FromLocal<ArcAsyncDerived<T>> for AsyncDerived<T, LocalStorage>
where
T: 'static,

View File

@@ -130,8 +130,9 @@ where
changed
} else {
let mut lock = self.write().or_poisoned();
lock.state = ReactiveNodeState::Clean;
if let Ok(mut lock) = self.try_write() {
lock.state = ReactiveNodeState::Clean;
}
false
}
}
@@ -142,7 +143,9 @@ where
S: Storage<T>,
{
fn add_subscriber(&self, subscriber: AnySubscriber) {
self.write().or_poisoned().subscribers.subscribe(subscriber);
if let Ok(mut lock) = self.try_write() {
lock.subscribers.subscribe(subscriber);
}
}
fn remove_subscriber(&self, subscriber: &AnySubscriber) {

View File

@@ -13,10 +13,11 @@ use futures::StreamExt;
use or_poisoned::OrPoisoned;
use std::{
mem,
sync::{Arc, RwLock},
sync::{atomic::AtomicBool, Arc, RwLock},
};
/// Effects run a certain chunk of code whenever the signals they depend on change.
///
/// Creating an effect runs the given function once after any current synchronous work is done.
/// This tracks its reactive values read within it, and reruns the function whenever the value
/// of a dependency changes.
@@ -109,6 +110,29 @@ fn effect_base() -> (Receiver, Owner, Arc<RwLock<EffectInner>>) {
(rx, owner, inner)
}
thread_local! {
static EFFECT_SCOPE_ACTIVE: AtomicBool = const { AtomicBool::new(false) };
}
/// Returns whether the current thread is currently running an effect.
pub fn in_effect_scope() -> bool {
EFFECT_SCOPE_ACTIVE
.with(|scope| scope.load(std::sync::atomic::Ordering::Relaxed))
}
/// Set a static to true whilst running the given function.
/// [`is_in_effect_scope`] will return true whilst the function is running.
fn run_in_effect_scope<T>(fun: impl FnOnce() -> T) -> T {
// For the theoretical nested case, set back to initial value rather than false:
let initial = EFFECT_SCOPE_ACTIVE
.with(|scope| scope.swap(true, std::sync::atomic::Ordering::Relaxed));
let result = fun();
EFFECT_SCOPE_ACTIVE.with(|scope| {
scope.store(initial, std::sync::atomic::Ordering::Relaxed)
});
result
}
impl<S> Effect<S>
where
S: Storage<StoredEffect>,
@@ -157,7 +181,9 @@ impl Effect<LocalStorage> {
let old_value =
mem::take(&mut *value.write().or_poisoned());
let new_value = owner.with_cleanup(|| {
subscriber.with_observer(|| fun.run(old_value))
subscriber.with_observer(|| {
run_in_effect_scope(|| fun.run(old_value))
})
});
*value.write().or_poisoned() = Some(new_value);
}
@@ -375,7 +401,9 @@ impl Effect<SyncStorage> {
let old_value =
mem::take(&mut *value.write().or_poisoned());
let new_value = owner.with_cleanup(|| {
subscriber.with_observer(|| fun.run(old_value))
subscriber.with_observer(|| {
run_in_effect_scope(|| fun.run(old_value))
})
});
*value.write().or_poisoned() = Some(new_value);
}
@@ -419,7 +447,9 @@ impl Effect<SyncStorage> {
let old_value =
mem::take(&mut *value.write().or_poisoned());
let new_value = owner.with_cleanup(|| {
subscriber.with_observer(|| fun.run(old_value))
subscriber.with_observer(|| {
run_in_effect_scope(|| fun.run(old_value))
})
});
*value.write().or_poisoned() = Some(new_value);
}

View File

@@ -84,6 +84,7 @@ pub mod owner;
#[cfg(feature = "serde")]
mod serde;
pub mod signal;
mod trait_options;
pub mod traits;
pub mod transition;
pub mod wrappers;

View File

@@ -1,3 +1,5 @@
#[allow(deprecated)]
use crate::wrappers::read::{MaybeProp, MaybeSignal};
use crate::{
computed::{ArcMemo, Memo},
owner::Storage,
@@ -7,7 +9,7 @@ use crate::{
},
traits::{Get, Set},
wrappers::{
read::{ArcSignal, MaybeProp, MaybeSignal, Signal},
read::{ArcSignal, Signal, SignalTypes},
write::SignalSetter,
},
};
@@ -112,7 +114,8 @@ macro_rules! impl_get_fn_traits_get_arena {
($($ty:ident),*) => {
$(
#[cfg(feature = "nightly")]
impl<T, S> FnOnce<()> for $ty<T, S> where $ty<T, S>: Get, S: Storage<T> + Storage<Option<T>> {
#[allow(deprecated)]
impl<T, S> FnOnce<()> for $ty<T, S> where $ty<T, S>: Get, S: Storage<T> + Storage<Option<T>> + Storage<SignalTypes<Option<T>, S>> {
type Output = <Self as Get>::Value;
#[inline(always)]
@@ -122,7 +125,8 @@ macro_rules! impl_get_fn_traits_get_arena {
}
#[cfg(feature = "nightly")]
impl<T, S> FnMut<()> for $ty<T, S> where $ty<T, S>: Get, S: Storage<T> + Storage<Option<T>> {
#[allow(deprecated)]
impl<T, S> FnMut<()> for $ty<T, S> where $ty<T, S>: Get, S: Storage<T> + Storage<Option<T>> + Storage<SignalTypes<Option<T>, S>> {
#[inline(always)]
extern "rust-call" fn call_mut(&mut self, _args: ()) -> Self::Output {
self.get()
@@ -130,7 +134,8 @@ macro_rules! impl_get_fn_traits_get_arena {
}
#[cfg(feature = "nightly")]
impl<T, S> Fn<()> for $ty<T, S> where $ty<T, S>: Get, S: Storage<T> + Storage<Option<T>> {
#[allow(deprecated)]
impl<T, S> Fn<()> for $ty<T, S> where $ty<T, S>: Get, S: Storage<T> + Storage<Option<T>> + Storage<SignalTypes<Option<T>, S>> {
#[inline(always)]
extern "rust-call" fn call(&self, _args: ()) -> Self::Output {
self.get()

View File

@@ -168,9 +168,10 @@ pub fn provide_context<T: Send + Sync + 'static>(value: T) {
}
}
/// Extracts a context value of type `T` from the reactive system by traversing
/// it upwards, beginning from the current reactive [`Owner`] and iterating
/// through its parents, if any. When the value is found, it is cloned.
/// Extracts a context value of type `T` from the reactive system.
///
/// This traverses the reactive ownership graph, beginning from the current reactive
/// [`Owner`] and iterating through its parents, if any. When the value is found, it is cloned.
///
/// The context value should have been provided elsewhere using
/// [`provide_context`](provide_context).
@@ -212,9 +213,11 @@ pub fn use_context<T: Clone + 'static>() -> Option<T> {
Owner::current().and_then(|owner| owner.use_context())
}
/// Extracts a context value of type `T` from the reactive system by traversing
/// it upwards, beginning from the current reactive [`Owner`] and iterating
/// through its parents, if any. When the value is found, it is cloned.
/// Extracts a context value of type `T` from the reactive system, and
/// panics if it can't be found.
///
/// This traverses the reactive ownership graph, beginning from the current reactive
/// [`Owner`] and iterating through its parents, if any. When the value is found, it is cloned.
///
/// Panics if no value is found.
///
@@ -270,9 +273,11 @@ pub fn expect_context<T: Clone + 'static>() -> T {
})
}
/// Extracts a context value of type `T` from the reactive system by traversing
/// it upwards, beginning from the current reactive [`Owner`] and iterating
/// through its parents, if any. When the value is found, it is removed from the context,
/// Extracts a context value of type `T` from the reactive system, and takes ownership,
/// removing it from the context system.
///
/// This traverses the reactive ownership graph, beginning from the current reactive
/// [`Owner`] and iterating through its parents, if any. When the value is found, it is removed,
/// and is not available to any other [`use_context`] or [`take_context`] calls.
///
/// If the value is `Clone`, use [`use_context`] instead.

View File

@@ -1,9 +1,11 @@
#[allow(deprecated)]
use crate::wrappers::read::{MaybeProp, MaybeSignal};
use crate::{
computed::{ArcMemo, Memo},
owner::Storage,
signal::{ArcReadSignal, ArcRwSignal, ReadSignal, RwSignal},
traits::With,
wrappers::read::{MaybeProp, MaybeSignal, Signal, SignalTypes},
wrappers::read::{Signal, SignalTypes},
};
use serde::{Deserialize, Serialize};
@@ -73,9 +75,10 @@ impl<T: Serialize + 'static, St: Storage<T>> Serialize for ArcMemo<T, St> {
}
}
#[allow(deprecated)]
impl<T, St> Serialize for MaybeSignal<T, St>
where
T: Send + Sync + Serialize,
T: Clone + Send + Sync + Serialize,
St: Storage<SignalTypes<T, St>> + Storage<T>,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
@@ -96,15 +99,8 @@ where
S: serde::Serializer,
{
match &self.0 {
None | Some(MaybeSignal::Static(None)) => {
None::<T>.serialize(serializer)
}
Some(MaybeSignal::Static(Some(value))) => {
value.serialize(serializer)
}
Some(MaybeSignal::Dynamic(signal)) => {
signal.with(|value| value.serialize(serializer))
}
None => None::<T>.serialize(serializer),
Some(signal) => signal.with(|value| value.serialize(serializer)),
}
}
}
@@ -146,6 +142,7 @@ impl<'de, T: Deserialize<'de>> Deserialize<'de> for ArcRwSignal<T> {
}
}
#[allow(deprecated)]
impl<'de, T: Deserialize<'de>, St> Deserialize<'de> for MaybeSignal<T, St>
where
St: Storage<T>,

View File

@@ -623,3 +623,46 @@ where
Display::fmt(&**self, f)
}
}
/// A wrapper that implements [`Deref`] and [`Borrow`] for itself.
pub struct Derefable<T>(pub T);
impl<T> Clone for Derefable<T>
where
T: Clone,
{
fn clone(&self) -> Self {
Derefable(self.0.clone())
}
}
impl<T> std::ops::Deref for Derefable<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> Borrow<T> for Derefable<T> {
fn borrow(&self) -> &T {
self.deref()
}
}
impl<T> PartialEq<T> for Derefable<T>
where
T: PartialEq,
{
fn eq(&self, other: &T) -> bool {
self.deref() == other
}
}
impl<T> Display for Derefable<T>
where
T: Display,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(&**self, f)
}
}

View File

@@ -0,0 +1,238 @@
use crate::{
traits::{
DefinedAt, Get, GetUntracked, Read, ReadUntracked, Track, With,
WithUntracked,
},
unwrap_signal,
};
use std::panic::Location;
impl<T> DefinedAt for Option<T>
where
T: DefinedAt,
{
fn defined_at(&self) -> Option<&'static Location<'static>> {
self.as_ref().map(DefinedAt::defined_at).unwrap_or(None)
}
}
impl<T> Track for Option<T>
where
T: Track,
{
fn track(&self) {
if let Some(signal) = self {
signal.track();
}
}
}
/// An alternative [`ReadUntracked`](crate) trait that works with `Option<Readable>` types.
pub trait ReadUntrackedOptional: Sized + DefinedAt {
/// The guard type that will be returned, which can be dereferenced to the value.
type Value;
/// Returns the guard, or `None` if the signal has already been disposed.
#[track_caller]
fn try_read_untracked(&self) -> Option<Self::Value>;
/// Returns the guard.
///
/// # Panics
/// Panics if you try to access a signal that has been disposed.
#[track_caller]
fn read_untracked(&self) -> Self::Value {
self.try_read_untracked()
.unwrap_or_else(unwrap_signal!(self))
}
}
impl<T> ReadUntrackedOptional for Option<T>
where
Self: DefinedAt,
T: ReadUntracked,
{
type Value = Option<<T as ReadUntracked>::Value>;
fn try_read_untracked(&self) -> Option<Self::Value> {
Some(if let Some(signal) = self {
Some(signal.try_read_untracked()?)
} else {
None
})
}
}
/// An alternative [`Read`](crate) trait that works with `Option<Readable>` types.
pub trait ReadOptional: DefinedAt {
/// The guard type that will be returned, which can be dereferenced to the value.
type Value;
/// Subscribes to the signal, and returns the guard, or `None` if the signal has already been disposed.
#[track_caller]
fn try_read(&self) -> Option<Self::Value>;
/// Subscribes to the signal, and returns the guard.
///
/// # Panics
/// Panics if you try to access a signal that has been disposed.
#[track_caller]
fn read(&self) -> Self::Value {
self.try_read().unwrap_or_else(unwrap_signal!(self))
}
}
impl<T> ReadOptional for Option<T>
where
Self: DefinedAt,
T: Read,
{
type Value = Option<<T as Read>::Value>;
fn try_read(&self) -> Option<Self::Value> {
Some(if let Some(readable) = self {
Some(readable.try_read()?)
} else {
None
})
}
}
/// An alternative [`WithUntracked`](crate) trait that works with `Option<Withable>` types.
pub trait WithUntrackedOptional: DefinedAt {
/// The type of the value contained in the signal.
type Value: ?Sized;
/// Applies the closure to the value, and returns the result,
/// or `None` if the signal has already been disposed.
#[track_caller]
fn try_with_untracked<U>(
&self,
fun: impl FnOnce(Option<&Self::Value>) -> U,
) -> Option<U>;
/// Applies the closure to the value, and returns the result.
///
/// # Panics
/// Panics if you try to access a signal that has been disposed.
#[track_caller]
fn with_untracked<U>(
&self,
fun: impl FnOnce(Option<&Self::Value>) -> U,
) -> U {
self.try_with_untracked(fun)
.unwrap_or_else(unwrap_signal!(self))
}
}
impl<T> WithUntrackedOptional for Option<T>
where
Self: DefinedAt,
T: WithUntracked,
<T as WithUntracked>::Value: Sized,
{
type Value = <T as WithUntracked>::Value;
fn try_with_untracked<U>(
&self,
fun: impl FnOnce(Option<&Self::Value>) -> U,
) -> Option<U> {
if let Some(signal) = self {
Some(signal.try_with_untracked(|val| fun(Some(val)))?)
} else {
Some(fun(None))
}
}
}
/// An alternative [`With`](crate) trait that works with `Option<Withable>` types.
pub trait WithOptional: DefinedAt {
/// The type of the value contained in the signal.
type Value: ?Sized;
/// Subscribes to the signal, applies the closure to the value, and returns the result,
/// or `None` if the signal has already been disposed.
#[track_caller]
fn try_with<U>(
&self,
fun: impl FnOnce(Option<&Self::Value>) -> U,
) -> Option<U>;
/// Subscribes to the signal, applies the closure to the value, and returns the result.
///
/// # Panics
/// Panics if you try to access a signal that has been disposed.
#[track_caller]
fn with<U>(&self, fun: impl FnOnce(Option<&Self::Value>) -> U) -> U {
self.try_with(fun).unwrap_or_else(unwrap_signal!(self))
}
}
impl<T> WithOptional for Option<T>
where
Self: DefinedAt,
T: With,
<T as With>::Value: Sized,
{
type Value = <T as With>::Value;
fn try_with<U>(
&self,
fun: impl FnOnce(Option<&Self::Value>) -> U,
) -> Option<U> {
if let Some(signal) = self {
Some(signal.try_with(|val| fun(Some(val)))?)
} else {
Some(fun(None))
}
}
}
impl<T> GetUntracked for Option<T>
where
Self: DefinedAt,
T: GetUntracked,
{
type Value = Option<<T as GetUntracked>::Value>;
fn try_get_untracked(&self) -> Option<Self::Value> {
Some(if let Some(signal) = self {
Some(signal.try_get_untracked()?)
} else {
None
})
}
}
impl<T> Get for Option<T>
where
Self: DefinedAt,
T: Get,
{
type Value = Option<<T as Get>::Value>;
fn try_get(&self) -> Option<Self::Value> {
Some(if let Some(signal) = self {
Some(signal.try_get()?)
} else {
None
})
}
}
/// Helper trait to implement flatten() on Option<&Option<T>>.
pub trait FlattenOptionRefOption {
/// The type of the value contained in the double option.
type Value;
/// Converts from `Option<&Option<T>>` to `Option<&T>`.
fn flatten(&self) -> Option<&Self::Value>;
}
impl<'a, T> FlattenOptionRefOption for Option<&'a Option<T>> {
type Value = T;
fn flatten(&self) -> Option<&'a T> {
self.map(Option::as_ref).flatten()
}
}

View File

@@ -48,6 +48,7 @@
//! there isn't an `RwLock` so you can't wrap in a [`ReadGuard`](crate::signal::guards::ReadGuard),
//! but you can still implement [`WithUntracked`] and [`Track`], the same traits will still be implemented.
pub use crate::trait_options::*;
use crate::{
effect::Effect,
graph::{Observer, Source, Subscriber, ToAnySource},
@@ -168,11 +169,20 @@ pub trait ReadUntracked: Sized + DefinedAt {
self.try_read_untracked()
.unwrap_or_else(unwrap_signal!(self))
}
/// This is a backdoor to allow overriding the [`Read::try_read`] implementation despite it being auto implemented.
///
/// If your type contains a [`Signal`](crate::wrappers::read::Signal),
/// call it's [`ReadUntracked::custom_try_read`] here, else return `None`.
#[track_caller]
fn custom_try_read(&self) -> Option<Option<Self::Value>> {
None
}
}
/// Give read-only access to a signal's value by reference through a guard type,
/// and subscribes the active reactive observer (an effect or computed) to changes in its value.
pub trait Read {
pub trait Read: DefinedAt {
/// The guard type that will be returned, which can be dereferenced to the value.
type Value: Deref;
@@ -185,7 +195,9 @@ pub trait Read {
/// # Panics
/// Panics if you try to access a signal that has been disposed.
#[track_caller]
fn read(&self) -> Self::Value;
fn read(&self) -> Self::Value {
self.try_read().unwrap_or_else(unwrap_signal!(self))
}
}
impl<T> Read for T
@@ -195,13 +207,18 @@ where
type Value = T::Value;
fn try_read(&self) -> Option<Self::Value> {
self.track();
self.try_read_untracked()
}
fn read(&self) -> Self::Value {
self.track();
self.read_untracked()
// The [`Read`] trait is auto implemented for types that implement [`ReadUntracked`] + [`Track`]. The [`Read`] trait then auto implements the [`With`] and [`Get`] traits too.
//
// This is a problem for e.g. the [`Signal`](crate::wrappers::read::Signal) type,
// this type must use a custom [`Read::try_read`] implementation to avoid an unnecessary clone.
//
// This is a backdoor to allow overriding the [`Read::try_read`] implementation despite it being auto implemented.
if let Some(custom) = self.custom_try_read() {
custom
} else {
self.track();
self.try_read_untracked()
}
}
}
@@ -307,14 +324,13 @@ pub trait With: DefinedAt {
impl<T> With for T
where
T: WithUntracked + Track,
T: Read,
{
type Value = <T as WithUntracked>::Value;
type Value = <<T as Read>::Value as Deref>::Target;
#[track_caller]
fn try_with<U>(&self, fun: impl FnOnce(&Self::Value) -> U) -> Option<U> {
self.track();
self.try_with_untracked(fun)
self.try_read().map(|val| fun(&val))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,11 @@
[package]
name = "reactive_stores"
version = "0.1.0-gamma"
version = "0.1.0-rc1"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
repository = "https://github.com/leptos-rs/leptos"
description = "Stores for holding deeply-nested reactive state while maintaining fine-grained reactive tracking."
rust-version.workspace = true
edition.workspace = true
@@ -11,10 +16,10 @@ or_poisoned = { workspace = true }
paste = "1.0"
reactive_graph = { workspace = true }
rustc-hash = "2.0"
reactive_stores_macro = { workspace = true }
[dev-dependencies]
tokio = { version = "1.39", features = ["rt-multi-thread", "macros"] }
tokio-test = { version = "0.4.4" }
any_spawner = { workspace = true, features = ["futures-executor", "tokio"] }
reactive_stores_macro = { workspace = true }
reactive_graph = { workspace = true, features = ["effects"] }

15
reactive_stores/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Stores
Stores are a data structure for nested reactivity.
The [`reactive_graph`](https://crates.io/crates/reactive_graph) crate provides primitives for fine-grained reactivity
via signals, memos, and effects.
This crate extends that reactivity to support reactive access to nested dested, without the need to create nested signals.
Using the `#[derive(Store)]` macro on a struct creates a series of getters that allow accessing each field. Individual fields
can then be read as if they were signals. Changes to parents will notify their children, but changing one sibling field will
not notify any of the others, nor will it require diffing those sibling fields (unlike earlier solutions using memoized “slices”).
This is published for use with the Leptos framework but can be used in any scenario where `reactive_graph` is being used
for reactivity.

View File

@@ -1,10 +1,13 @@
use crate::{
path::{StorePath, StorePathSegment},
AtIndex, AtKeyed, KeyMap, KeyedSubfield, StoreField, StoreFieldTrigger,
Subfield,
ArcStore, AtIndex, AtKeyed, KeyMap, KeyedSubfield, Store, StoreField,
StoreFieldTrigger, Subfield,
};
use reactive_graph::traits::{
DefinedAt, IsDisposed, Notify, ReadUntracked, Track, UntrackableGuard,
use reactive_graph::{
owner::Storage,
traits::{
DefinedAt, IsDisposed, Notify, ReadUntracked, Track, UntrackableGuard,
},
};
use std::{
fmt::Debug,
@@ -26,6 +29,7 @@ where
read: Arc<dyn Fn() -> Option<StoreFieldReader<T>> + Send + Sync>,
write: Arc<dyn Fn() -> Option<StoreFieldWriter<T>> + Send + Sync>,
keys: Arc<dyn Fn() -> Option<KeyMap> + Send + Sync>,
track_field: Arc<dyn Fn() + Send + Sync>,
}
pub struct StoreFieldReader<T>(Box<dyn Deref<Target = T>>);
@@ -98,6 +102,62 @@ impl<T> StoreField for ArcField<T> {
}
}
impl<T, S> From<Store<T, S>> for ArcField<T>
where
T: 'static,
S: Storage<ArcStore<T>>,
{
#[track_caller]
fn from(value: Store<T, S>) -> Self {
ArcField {
#[cfg(debug_assertions)]
defined_at: Location::caller(),
path: value.path().into_iter().collect(),
trigger: value.get_trigger(value.path().into_iter().collect()),
get_trigger: Arc::new(move |path| value.get_trigger(path)),
read: Arc::new(move || value.reader().map(StoreFieldReader::new)),
write: Arc::new(move || value.writer().map(StoreFieldWriter::new)),
keys: Arc::new(move || value.keys()),
track_field: Arc::new(move || value.track_field()),
}
}
}
impl<T> From<ArcStore<T>> for ArcField<T>
where
T: Send + Sync + 'static,
{
#[track_caller]
fn from(value: ArcStore<T>) -> Self {
ArcField {
#[cfg(debug_assertions)]
defined_at: Location::caller(),
path: value.path().into_iter().collect(),
trigger: value.get_trigger(value.path().into_iter().collect()),
get_trigger: Arc::new({
let value = value.clone();
move |path| value.get_trigger(path)
}),
read: Arc::new({
let value = value.clone();
move || value.reader().map(StoreFieldReader::new)
}),
write: Arc::new({
let value = value.clone();
move || value.writer().map(StoreFieldWriter::new)
}),
keys: Arc::new({
let value = value.clone();
move || value.keys()
}),
track_field: Arc::new({
let value = value.clone();
move || value.track_field()
}),
}
}
}
impl<Inner, Prev, T> From<Subfield<Inner, Prev, T>> for ArcField<T>
where
T: Send + Sync,
@@ -128,6 +188,10 @@ where
let value = value.clone();
move || value.keys()
}),
track_field: Arc::new({
let value = value.clone();
move || value.track_field()
}),
}
}
}
@@ -162,6 +226,10 @@ where
let value = value.clone();
move || value.keys()
}),
track_field: Arc::new({
let value = value.clone();
move || value.track_field()
}),
}
}
}
@@ -200,6 +268,10 @@ where
let value = value.clone();
move || value.keys()
}),
track_field: Arc::new({
let value = value.clone();
move || value.track_field()
}),
}
}
}
@@ -215,6 +287,7 @@ impl<T> Clone for ArcField<T> {
read: Arc::clone(&self.read),
write: Arc::clone(&self.write),
keys: Arc::clone(&self.keys),
track_field: Arc::clone(&self.track_field),
}
}
}
@@ -240,8 +313,7 @@ impl<T> Notify for ArcField<T> {
impl<T> Track for ArcField<T> {
fn track(&self) {
self.trigger.this.track();
self.trigger.children.track();
(self.track_field)();
}
}

View File

@@ -1,8 +1,8 @@
use crate::{
arc_field::{StoreFieldReader, StoreFieldWriter},
path::{StorePath, StorePathSegment},
ArcField, AtIndex, AtKeyed, KeyMap, KeyedSubfield, StoreField,
StoreFieldTrigger, Subfield,
ArcField, ArcStore, AtIndex, AtKeyed, KeyMap, KeyedSubfield, Store,
StoreField, StoreFieldTrigger, Subfield,
};
use reactive_graph::{
owner::{ArenaItem, Storage, SyncStorage},
@@ -55,6 +55,36 @@ where
}
}
impl<T, S> From<Store<T, S>> for Field<T, S>
where
T: 'static,
S: Storage<ArcStore<T>> + Storage<ArcField<T>>,
{
#[track_caller]
fn from(value: Store<T, S>) -> Self {
Field {
#[cfg(debug_assertions)]
defined_at: Location::caller(),
inner: ArenaItem::new_with_storage(value.into()),
}
}
}
impl<T, S> From<ArcStore<T>> for Field<T, S>
where
T: Send + Sync + 'static,
S: Storage<ArcStore<T>> + Storage<ArcField<T>>,
{
#[track_caller]
fn from(value: ArcStore<T>) -> Self {
Field {
#[cfg(debug_assertions)]
defined_at: Location::caller(),
inner: ArenaItem::new_with_storage(value.into()),
}
}
}
impl<Inner, Prev, T, S> From<Subfield<Inner, Prev, T>> for Field<T, S>
where
T: Send + Sync,

View File

@@ -152,9 +152,7 @@ where
Prev::Output: Sized + 'static,
{
fn track(&self) {
let trigger = self.get_trigger(self.path().into_iter().collect());
trigger.this.track();
trigger.children.track();
self.track_field();
}
}
@@ -197,9 +195,9 @@ pub trait StoreFieldIterator<Prev>
where
Self: StoreField<Value = Prev>,
{
fn at(self, index: usize) -> AtIndex<Self, Prev>;
fn at_unkeyed(self, index: usize) -> AtIndex<Self, Prev>;
fn iter(self) -> StoreFieldIter<Self, Prev>;
fn iter_unkeyed(self) -> StoreFieldIter<Self, Prev>;
}
impl<Inner, Prev> StoreFieldIterator<Prev> for Inner
@@ -209,12 +207,12 @@ where
Prev: IndexMut<usize> + AsRef<[Prev::Output]>,
{
#[track_caller]
fn at(self, index: usize) -> AtIndex<Inner, Prev> {
fn at_unkeyed(self, index: usize) -> AtIndex<Inner, Prev> {
AtIndex::new(self.clone(), index)
}
#[track_caller]
fn iter(self) -> StoreFieldIter<Inner, Prev> {
fn iter_unkeyed(self) -> StoreFieldIter<Inner, Prev> {
// reactively track changes to this field
let trigger = self.get_trigger(self.path().into_iter().collect());
trigger.this.track();

View File

@@ -124,6 +124,16 @@ where
fn keys(&self) -> Option<KeyMap> {
self.inner.keys()
}
fn track_field(&self) {
let inner = self
.inner
.get_trigger(self.inner.path().into_iter().collect());
inner.this.track();
let trigger = self.get_trigger(self.path().into_iter().collect());
trigger.this.track();
trigger.children.track();
}
}
impl<Inner, Prev, K, T> KeyedSubfield<Inner, Prev, K, T>
@@ -287,13 +297,7 @@ where
K: Debug + Send + Sync + PartialEq + Eq + Hash + 'static,
{
fn track(&self) {
let inner = self
.inner
.get_trigger(self.inner.path().into_iter().collect());
inner.this.track();
let trigger = self.get_trigger(self.path().into_iter().collect());
trigger.this.track();
trigger.children.track();
self.track_field();
}
}
@@ -551,9 +555,7 @@ where
T::Output: Sized,
{
fn track(&self) {
let trigger = self.get_trigger(self.path().into_iter().collect());
trigger.this.track();
trigger.children.track();
self.track_field();
}
}

Some files were not shown because too many files have changed in this diff Show More