Compare commits

..

1 Commits
v0.7.1 ... 3014

Author SHA1 Message Date
Greg Johnston
faf0cb3378 fix: correct Owner restoration in FlatRoutes (closes #3014) 2024-09-24 19:35:19 -04:00
298 changed files with 5924 additions and 22038 deletions

13
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "cargo"
directories:
- "/"
- "/examples/*"
- "/benchmarks"
schedule:
interval: "daily"

View File

@@ -1,49 +0,0 @@
name: autofix.ci
on:
pull_request:
# Running this workflow on main branch pushes requires write permission to apply changes.
# Leave it alone for future uses.
# push:
# branches: ["main"]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
autofix:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with: {toolchain: nightly, components: "rustfmt, clippy", target: "wasm32-unknown-unknown", rustflags: ""}
- name: Install jq
run: sudo apt-get install jq
- run: |
echo "Formatting the workspace"
cargo fmt --all
echo "Running Clippy against each member's features (default features included)"
for member in $(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | .name'); do
echo "Working on member $member":
echo -e "\tdefault-features/no-features:"
# this will also run on members with no features or default features
cargo clippy --allow-dirty --fix --lib --package "$member"
features=$(cargo metadata --no-deps --format-version 1 | jq -r ".packages[] | select(.name == \"$member\") | .features | keys[]")
for feature in $features; do
if [ "$feature" = "default" ]; then
continue
fi
echo -e "\tfeature $feature"
cargo clippy --allow-dirty --fix --lib --package "$member" --features "$feature"
done
done
- uses: autofix-ci/action@v1.3.1
if: ${{ always() }}
with:
fail-fast: false

View File

@@ -13,7 +13,7 @@ jobs:
uses: ./.github/workflows/get-leptos-changed.yml
test:
needs: [get-leptos-changed]
if: needs.get-leptos-changed.outputs.leptos_changed == 'true' && github.event.pull_request.labels[0].name != 'breaking'
if: github.event.pull_request.labels[0].name == 'semver' # needs.get-leptos-changed.outputs.leptos_changed == 'true' && github.event.pull_request.labels[0].name != 'breaking'
name: Run semver check (nightly-2024-08-01)
runs-on: ubuntu-latest
steps:

View File

@@ -19,19 +19,6 @@ jobs:
name: Run ${{ inputs.cargo_make_task }} (${{ inputs.toolchain }})
runs-on: ubuntu-latest
steps:
- name: Free Disk Space
run: |
echo "Disk space before cleanup:"
df -h
sudo rm -rf /usr/local/.ghcup
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo rm -rf /usr/local/lib/android/sdk/ndk
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /usr/local/share/boost
sudo apt-get clean
echo "Disk space after cleanup:"
df -h
# Setup environment
- uses: actions/checkout@v4
- name: Setup Rust
@@ -107,7 +94,7 @@ jobs:
fi
done
- name: Install Deno
uses: denoland/setup-deno@v2
uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: Maybe install gtk-rs dependencies

7
.gitignore vendored
View File

@@ -3,9 +3,7 @@ dist
pkg
comparisons
blob.rs
**/projects/**/Cargo.lock
**/examples/**/Cargo.lock
**/benchmarks/**/Cargo.lock
Cargo.lock
**/*.rs.bk
.DS_Store
.idea
@@ -13,5 +11,4 @@ blob.rs
.envrc
.vscode
vendor
hash.txt
vendor

4485
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,36 +40,36 @@ members = [
exclude = ["benchmarks", "examples", "projects"]
[workspace.package]
version = "0.7.1"
version = "0.7.0-beta6"
edition = "2021"
rust-version = "1.76"
[workspace.dependencies]
throw_error = { path = "./any_error/", version = "0.2.0" }
any_spawner = { path = "./any_spawner/", version = "0.2.0" }
throw_error = { path = "./any_error/", version = "0.2.0-beta6" }
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" }
leptos = { path = "./leptos", version = "0.7.1" }
leptos_config = { path = "./leptos_config", version = "0.7.1" }
leptos_dom = { path = "./leptos_dom", version = "0.7.1" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.1" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.1" }
leptos_macro = { path = "./leptos_macro", version = "0.7.1" }
leptos_router = { path = "./router", version = "0.7.1" }
leptos_router_macro = { path = "./router_macro", version = "0.7.1" }
leptos_server = { path = "./leptos_server", version = "0.7.1" }
leptos_meta = { path = "./meta", version = "0.7.1" }
next_tuple = { path = "./next_tuple", version = "0.1.0" }
hydration_context = { path = "./hydration_context", version = "0.2.0-beta6" }
leptos = { path = "./leptos", version = "0.7.0-beta6" }
leptos_config = { path = "./leptos_config", version = "0.7.0-beta6" }
leptos_dom = { path = "./leptos_dom", version = "0.7.0-beta6" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.0-beta6" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.0-beta6" }
leptos_macro = { path = "./leptos_macro", version = "0.7.0-beta6" }
leptos_router = { path = "./router", version = "0.7.0-beta6" }
leptos_router_macro = { path = "./router_macro", version = "0.7.0-beta6" }
leptos_server = { path = "./leptos_server", version = "0.7.0-beta6" }
leptos_meta = { path = "./meta", version = "0.7.0-beta6" }
next_tuple = { path = "./next_tuple", version = "0.1.0-beta6" }
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" }
reactive_stores = { path = "./reactive_stores", version = "0.1.0" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0" }
server_fn = { path = "./server_fn", version = "0.7.1" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.1" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.1" }
tachys = { path = "./tachys", version = "0.1.0" }
reactive_graph = { path = "./reactive_graph", version = "0.1.0-beta6" }
reactive_stores = { path = "./reactive_stores", version = "0.1.0-beta6" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0-beta6" }
server_fn = { path = "./server_fn", version = "0.7.0-beta6" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.0-beta6" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.0-beta6" }
tachys = { path = "./tachys", version = "0.1.0-beta6" }
[profile.release]
codegen-units = 1
@@ -78,6 +78,3 @@ opt-level = 'z'
[workspace.metadata.cargo-all-features]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
[workspace.lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(leptos_debuginfo)', 'cfg(erase_components)'] }

View File

@@ -12,6 +12,8 @@
You can find a list of useful libraries and example projects at [`awesome-leptos`](https://github.com/leptos-rs/awesome-leptos).
# The `main` branch is currently undergoing major changes in preparation for the [0.7](https://github.com/leptos-rs/leptos/milestone/4) release. For a stable version, please use the [v0.6.13 tag](https://github.com/leptos-rs/leptos/tree/v0.6.13)
# Leptos
```rust
@@ -157,7 +159,9 @@ Sure! Obviously the `view` macro is for generating DOM nodes but you can use the
- Use event listeners to update signals
- Create effects to update the UI
The 0.7 update originally set out to create a "generic rendering" approach that would allow us to reuse most of the same view logic to do all of the above. Unfortunately, this has had to be shelved for now due to difficulties encountered by the Rust compiler when building larger-scale applications with the number of generics spread throughout the codebase that this required. It's an approach I'm looking forward to exploring again in the future; feel free to reach out if you're interested in this kind of work.
I've put together a [very simple GTK example](https://github.com/leptos-rs/leptos/blob/main/examples/gtk/src/main.rs) so you can see what I mean.
The new rendering approach being developed for 0.7 supports “universal rendering,” i.e., it can use any rendering library that supports a small set of 6-8 functions. (This is intended as a layer over typical retained-mode, OOP-style GUI toolkits like the DOM, GTK, etc.) That future rendering work will allow creating native UI in a way that is much more similar to the declarative approach used by the web framework.
### How is this different from Yew?

View File

@@ -1,6 +1,6 @@
[package]
name = "throw_error"
version = "0.2.0"
version = "0.2.0-beta6"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -10,4 +10,4 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
pin-project-lite = "0.2.15"
pin-project-lite = "0.2.14"

View File

@@ -1,6 +1,6 @@
[package]
name = "any_spawner"
version = "0.2.1"
version = "0.1.1"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -9,25 +9,22 @@ description = "Spawn asynchronous tasks in an executor-independent way."
edition.workspace = true
[dependencies]
async-executor = { version = "1.13.1", optional = true }
futures = "0.3.31"
glib = { version = "0.20.6", optional = true }
thiserror = "2.0"
tokio = { version = "1.41", optional = true, default-features = false, features = [
futures = "0.3.30"
glib = { version = "0.20.0", optional = true }
thiserror = "1.0"
tokio = { version = "1.39", optional = true, default-features = false, features = [
"rt",
] }
tracing = { version = "0.1.41", optional = true }
wasm-bindgen-futures = { version = "0.4.47", optional = true }
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,14 +32,11 @@
use std::{future::Future, pin::Pin, sync::OnceLock};
use thiserror::Error;
/// 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>>>;
pub(crate) type PinnedFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
pub(crate) 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)]
@@ -118,14 +115,6 @@ 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 {
@@ -204,15 +193,13 @@ impl Executor {
#[cfg_attr(docsrs, doc(cfg(feature = "futures-executor")))]
pub fn init_futures_executor() -> Result<(), ExecutorError> {
use futures::{
executor::{LocalPool, LocalSpawner, ThreadPool},
executor::{LocalPool, ThreadPool},
task::{LocalSpawnExt, SpawnExt},
};
use std::cell::RefCell;
static THREAD_POOL: OnceLock<ThreadPool> = OnceLock::new();
thread_local! {
static LOCAL_POOL: RefCell<LocalPool> = RefCell::new(LocalPool::new());
static SPAWNER: LocalSpawner = LOCAL_POOL.with(|pool| pool.borrow().spawner());
static LOCAL_POOL: LocalPool = LocalPool::new();
}
fn get_thread_pool() -> &'static ThreadPool {
@@ -231,131 +218,28 @@ impl Executor {
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN_LOCAL
.set(|fut| {
SPAWNER.with(|spawner| {
LOCAL_POOL.with(|pool| {
let spawner = pool.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(())
}
}
/// 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);
#[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 {});
}
}

View File

@@ -1,55 +0,0 @@
#[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

@@ -1,38 +0,0 @@
#[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

@@ -1,6 +1,6 @@
[package]
name = "either_of"
version = "0.1.2"
version = "0.1.0"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -10,4 +10,4 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
pin-project-lite = "0.2.15"
pin-project-lite = "0.2.14"

View File

@@ -134,7 +134,7 @@ tuples!(EitherOf14 + EitherOf14Future + EitherOf14FutureProj => A, B, C, D, E, F
tuples!(EitherOf15 + EitherOf15Future + EitherOf15FutureProj => A, B, C, D, E, F, G, H, I, J, K, L, M, N, O);
tuples!(EitherOf16 + EitherOf16Future + EitherOf16FutureProj => A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P);
/// Matches over the first expression and returns an either ([`Either`], [`EitherOf3`], ... [`EitherOf8`])
/// Matches over the first expression and returns an either ([`Either`], [`EitherOf3`], ... [`EitherOf6`])
/// composed of the values returned by the match arms.
///
/// The pattern syntax is exactly the same as found in a match arm.
@@ -197,29 +197,6 @@ macro_rules! either {
$e_pattern => $crate::EitherOf6::E($e_expression),
$f_pattern => $crate::EitherOf6::F($f_expression),
}
};
($match:expr, $a_pattern:pat => $a_expression:expr, $b_pattern:pat => $b_expression:expr, $c_pattern:pat => $c_expression:expr, $d_pattern:pat => $d_expression:expr, $e_pattern:pat => $e_expression:expr, $f_pattern:pat => $f_expression:expr, $g_pattern:pat => $g_expression:expr,) => {
match $match {
$a_pattern => $crate::EitherOf7::A($a_expression),
$b_pattern => $crate::EitherOf7::B($b_expression),
$c_pattern => $crate::EitherOf7::C($c_expression),
$d_pattern => $crate::EitherOf7::D($d_expression),
$e_pattern => $crate::EitherOf7::E($e_expression),
$f_pattern => $crate::EitherOf7::F($f_expression),
$g_pattern => $crate::EitherOf7::G($g_expression),
}
};
($match:expr, $a_pattern:pat => $a_expression:expr, $b_pattern:pat => $b_expression:expr, $c_pattern:pat => $c_expression:expr, $d_pattern:pat => $d_expression:expr, $e_pattern:pat => $e_expression:expr, $f_pattern:pat => $f_expression:expr, $g_pattern:pat => $g_expression:expr, $h_pattern:pat => $h_expression:expr,) => {
match $match {
$a_pattern => $crate::EitherOf8::A($a_expression),
$b_pattern => $crate::EitherOf8::B($b_expression),
$c_pattern => $crate::EitherOf8::C($c_expression),
$d_pattern => $crate::EitherOf8::D($d_expression),
$e_pattern => $crate::EitherOf8::E($e_expression),
$f_pattern => $crate::EitherOf8::F($f_expression),
$g_pattern => $crate::EitherOf8::G($g_expression),
$h_pattern => $crate::EitherOf8::H($h_expression),
}
}; // if you need more eithers feel free to open a PR ;-)
}
@@ -256,23 +233,4 @@ fn either_macro() {
16 => 24u8,
_ => 12,
);
let _: EitherOf7<&str, f64, char, f32, u8, i8, i32> = either!(12,
12 => "12",
13 => 0.0,
14 => ' ',
15 => 0.0f32,
16 => 24u8,
17 => 2i8,
_ => 12,
);
let _: EitherOf8<&str, f64, char, f32, u8, i8, u32, i32> = either!(12,
12 => "12",
13 => 0.0,
14 => ' ',
15 => 0.0f32,
16 => 24u8,
17 => 2i8,
18 => 42u32,
_ => 12,
);
}

View File

@@ -26,7 +26,6 @@ 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

@@ -1,9 +1,7 @@
#![allow(dead_code)]
use counter::*;
use leptos::mount::mount_to;
use leptos::prelude::*;
use leptos::task::tick;
use leptos::spawn::tick;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;

View File

@@ -1,4 +1,4 @@
use leptos::prelude::*;
use leptos::{prelude::*, reactive_graph::actions::Action};
use leptos_router::{
components::{FlatRoutes, Route, Router, A},
StaticSegment,

View File

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

View File

@@ -1,7 +1,5 @@
#![allow(dead_code)]
use counter_without_macros::counter;
use leptos::{prelude::*, task::tick};
use leptos::{prelude::*, spawn::tick};
use pretty_assertions::assert_eq;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;

View File

@@ -1,12 +1,10 @@
#![allow(dead_code)]
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
use counters::Counters;
use leptos::prelude::*;
use leptos::task::tick;
use leptos::spawn::tick;
use web_sys::HtmlElement;
#[wasm_bindgen_test]

View File

@@ -1,7 +1,5 @@
#![allow(dead_code)]
use directives::App;
use leptos::{prelude::*, task::tick};
use leptos::{prelude::*, spawn::tick};
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
use web_sys::HtmlElement;

View File

@@ -6,7 +6,9 @@ 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: Signal<Errors>) -> impl IntoView {
pub fn ErrorTemplate(
#[prop(into)] errors: MaybeSignal<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},
OptionalParamSegment, ParamSegment, StaticSegment,
ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
use std::time::Duration;
@@ -28,7 +28,9 @@ 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=OptionalParamSegment("stories") view=Stories/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
</FlatRoutes>
</main>
</Router>

View File

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

View File

@@ -21,16 +21,10 @@ 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,42 +50,30 @@ 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 >"
@@ -95,10 +83,14 @@ 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()
@@ -113,78 +105,54 @@ 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,21 +28,18 @@ 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">
@@ -51,7 +48,6 @@ pub fn Story() -> impl IntoView {
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
@@ -59,7 +55,7 @@ pub fn Story() -> impl IntoView {
key=|comment| comment.id
let:comment
>
<Comment comment/>
<Comment comment />
</For>
</ul>
</div>
@@ -68,7 +64,6 @@ pub fn Story() -> impl IntoView {
}
}
}))).build())
.into_any()
}
#[component]
@@ -77,65 +72,43 @@ 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,48 +18,30 @@ 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},
OptionalParamSegment, ParamSegment, StaticSegment,
ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
use std::time::Duration;
@@ -46,7 +46,9 @@ 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=OptionalParamSegment("stories") view=Stories/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
</FlatRoutes>
</main>
</Router>

View File

@@ -12,7 +12,7 @@ lto = true
[dependencies]
console_error_panic_hook = "0.1.7"
leptos = { path = "../../leptos", features = ["islands"] }
leptos = { path = "../../leptos", features = ["experimental-islands"] }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../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},
OptionalParamSegment, ParamSegment, StaticSegment,
ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
#[cfg(feature = "ssr")]
@@ -42,7 +42,9 @@ 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=OptionalParamSegment("stories") view=Stories/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") 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},
OptionalParamSegment, ParamSegment, StaticSegment,
ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
use std::time::Duration;
@@ -46,7 +46,9 @@ 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=OptionalParamSegment("stories") view=Stories/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
</FlatRoutes>
</main>
</Router>

View File

@@ -12,7 +12,7 @@ futures = "0.3.30"
http = "1.1"
leptos = { path = "../../leptos", features = [
"tracing",
"islands",
"experimental-islands",
] }
server_fn = { path = "../../server_fn", features = ["serde-lite"] }
leptos_axum = { path = "../../integrations/axum", optional = true }

View File

@@ -12,12 +12,12 @@ futures = "0.3.30"
http = "1.1"
leptos = { path = "../../leptos", features = [
"tracing",
"islands",
"experimental-islands",
] }
leptos_router = { path = "../../router" }
server_fn = { path = "../../server_fn", features = ["serde-lite"] }
leptos_axum = { path = "../../integrations/axum", features = [
"dont-use-islands-router",
"islands-router",
], optional = true }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }

View File

@@ -1,14 +1,19 @@
# Work in Progress
# Leptos Todo App Sqlite with Axum
This example is something I wrote on a long layover in the Orlando airport in July. (It was really hot!)
This example creates a basic todo app with an Axum backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server.
It is the culmination of a couple years of thinking and working toward being able to do this, which you can see
described pretty well in the pinned roadmap issue (#1830) and its discussion of different modes of client-side
routing when you use islands.
## Getting Started
This uses *only* server rendering, with no actual islands, but still maintains client-side state across page navigations.
It does this by building on the fact that we now have a statically-typed view tree to do pretty smart updates with
new HTML from the client, with extremely minimal diffing.
See the [Examples README](../README.md) for setup and run instructions.
The demo itself works, but the feature that supports it is incomplete. A couple people have accidentally
used it and broken their applications in ways they don't understand, so I've renamed the feature to `dont-use-islands-router`.
## E2E Testing
See the [E2E README](./e2e/README.md) for more information about the testing strategy.
## Rendering
See the [SSR Notes](../SSR_NOTES.md) for more information about Server Side Rendering.
## Quick Start
Run `cargo leptos watch` to run this example.

View File

@@ -44,8 +44,8 @@ window.addEventListener("click", async (ev) => {
// TODO parse from the request stream instead?
const doc = parser.parseFromString(htmlString, 'text/html');
// The 'doc' variable now contains the parsed DOM
const transition = async () => {
// The 'doc' variable now contains the parsed DOM
const transition = document.startViewTransition(async () => {
const oldDocWalker = document.createTreeWalker(document);
const newDocWalker = doc.createTreeWalker(doc);
let oldNode = oldDocWalker.currentNode;
@@ -128,13 +128,8 @@ window.addEventListener("click", async (ev) => {
}
} }
}
};
// Not all browsers support startViewTransition; see https://caniuse.com/?search=startViewTransition
if (document.startViewTransition) {
await document.startViewTransition(transition);
} else {
await transition()
}
});
await transition;
window.history.pushState(undefined, null, url);
});

View File

@@ -1,5 +1,5 @@
use js_framework_benchmark_leptos::*;
use leptos::{prelude::*, task::tick};
use leptos::{prelude::*, spawn::tick};
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;

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(
document()
helpers::document()
.get_element_by_id("app")
.unwrap()
.unchecked_into(),

View File

@@ -1,11 +1,9 @@
#![allow(dead_code)]
use portal::App;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
use leptos::task::tick;
use leptos::spawn::tick;
use leptos::{leptos_dom::helpers::document, mount::mount_to};
use web_sys::HtmlButtonElement;

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,14 +5,13 @@ use leptos::prelude::*;
use leptos_router::{
components::{
Form, Outlet, ParentRoute, ProtectedRoute, Redirect, Route, Router,
Routes, RoutingProgress, A,
Routes, 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)]
@@ -27,14 +26,9 @@ 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 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>
<Router>
<nav>
// ordinary <a> elements can be used for client-side navigation
// using <A> has two effects:
@@ -50,7 +44,7 @@ pub fn RouterExample() -> impl IntoView {
}>{move || if logged_in.get() { "Log Out" } else { "Log In" }}</button>
</nav>
<main>
<Routes transition=true fallback=|| "This page could not be found.">
<Routes 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/>
@@ -70,7 +64,7 @@ pub fn RouterExample() -> impl IntoView {
// You can define other routes in their own component.
// Routes implement the MatchNestedRoutes
#[component(transparent)]
#[component]
pub fn ContactRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("") view=ContactList>

View File

@@ -1,8 +1,3 @@
.routing-progress {
width: 100%;
height: 20px;
}
a[aria-current] {
font-weight: bold;
}
@@ -17,8 +12,12 @@ a[aria-current] {
padding: 1rem;
}
.contact {
view-transition-name: contact;
.fadeIn {
animation: 0.5s fadeIn forwards;
}
.fadeOut {
animation: 0.5s fadeOut forwards;
}
@keyframes fadeIn {
@@ -41,44 +40,12 @@ a[aria-current] {
}
}
.router-outlet-0 main {
view-transition-name: main;
.slideIn {
animation: 0.25s slideIn 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;
}
.slideOut {
animation: 0.25s slideOut forwards;
}
@keyframes slideIn {
@@ -99,6 +66,14 @@ a[aria-current] {
}
}
.slideInBack {
animation: 0.25s slideInBack forwards;
}
.slideOutBack {
animation: 0.25s slideOutBack forwards;
}
@keyframes slideInBack {
from {
transform: translate(-100vw, 0);

View File

@@ -40,8 +40,6 @@ pin-project-lite = "0.2.14"
dashmap = { version = "6.0", optional = true }
once_cell = { version = "1.19", optional = true }
async-broadcast = { version = "0.7.1", optional = true }
bytecheck = "0.8.0"
rkyv = { version = "0.8.8" }
[features]
hydrate = ["leptos/hydrate"]

View File

@@ -1,6 +1,6 @@
use futures::StreamExt;
use http::Method;
use leptos::{html::Input, prelude::*, task::spawn_local};
use leptos::{html::Input, prelude::*, spawn::spawn_local};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use server_fn::{
client::{browser::BrowserClient, Client},
@@ -417,6 +417,7 @@ pub fn FileUploadWithProgress() -> impl IntoView {
/// This requires us to store some global state of all the uploads. In a real app, you probably
/// shouldn't do exactly what I'm doing here in the demo. For example, this map just
/// distinguishes between files by filename, not by user.
#[cfg(feature = "ssr")]
mod progress {
use async_broadcast::{broadcast, Receiver, Sender};

View File

@@ -4,8 +4,6 @@ use leptos::{config::get_configuration, logging};
use leptos_axum::{generate_route_list, LeptosRoutes};
use server_fns_axum::*;
// cargo make cli: error: unneeded `return` statement
#[allow(clippy::needless_return)]
#[tokio::main]
async fn main() {
simple_logger::init_with_level(log::Level::Error)

View File

@@ -10,7 +10,7 @@ struct Then {
// the type with Option<...> and marking the option as #[prop(optional)].
#[slot]
struct ElseIf {
cond: Signal<bool>,
cond: MaybeSignal<bool>,
children: ChildrenFn,
}
@@ -22,7 +22,7 @@ struct Fallback {
// Slots are added to components like any other prop.
#[component]
fn SlotIf(
cond: Signal<bool>,
cond: MaybeSignal<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 = 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);
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);
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.as_ref()))
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?

View File

@@ -2,7 +2,6 @@
#[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,7 +2,6 @@
#[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,7 +110,11 @@ 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 || store.todos()
each=move || {
leptos::logging::log!("RERUNNING FOR CALCULATION");
store.todos()
}
key=|row| row.id().get()
let:todo
>

View File

@@ -10,7 +10,6 @@ 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.72" }
leptos = { path = "../../leptos" }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_router = { path = "../../router" }
@@ -20,10 +19,7 @@ serde = "1.0"
tokio = { version = "1.39", features = ["time", "rt"], optional = true }
[features]
hydrate = [
"leptos/hydrate",
]
hydrate = ["leptos/hydrate"]
ssr = [
"dep:actix-files",
"dep:actix-web",

View File

@@ -1,121 +0,0 @@
@check_aria_current
Feature: Check aria-current being applied to make links bolded
Background:
Given I see the app
Scenario: Should see the base case working
Then I see the Out-of-Order link being bolded
And I see the following links being bolded
| Out-of-Order |
| Nested |
And I see the In-Order link not being bolded
And I see the following links not being bolded
| In-Order |
| Single |
Scenario: Should see client-side render the correct bolded links
When I select the link In-Order
And I select the link Single
Then I see the following links being bolded
| In-Order |
| Single |
And I see the following links not being bolded
| Out-of-Order |
| Nested |
Scenario: Should see server-side render the correct bolded links
When I select the link In-Order
And I select the link Single
And I reload the page
Then I see the following links being bolded
| In-Order |
| Single |
And I see the following links not being bolded
| Out-of-Order |
| Nested |
Scenario: Check that the base nested route links are working
When I select the link Instrumented
Then I see the Instrumented link being bolded
And I see the Item Listing link not being bolded
Scenario: Should see going deep down into nested routes bold links
When I select the link Instrumented
And I select the link Target 421
Then I see the following links being bolded
| Instrumented |
| Item Listing |
| Target 4## |
| Target 42# |
| Target 421 |
| field1 |
Scenario: Should see going deep down into nested routes in SSR bold links
When I select the link Instrumented
And I select the link Target 421
And I reload the page
Then I see the following links being bolded
| Instrumented |
| Item Listing |
| Target 4## |
| Target 42# |
| Target 421 |
| field1 |
Scenario: Going deep down navigate around nested links bold correctly
When I select the link Instrumented
And I select the link Target 421
And I select the link Inspect path2/field3
Then I see the following links being bolded
| Instrumented |
| Item Listing |
| Target 4## |
| Target 42# |
| field3 |
And I see the following links not being bolded
| Target 421 |
| field1 |
Scenario: Going deep down navigate around nested links bold correctly, SSR
When I select the link Instrumented
And I select the link Target 421
And I select the link Inspect path2/field3
And I reload the page
Then I see the following links being bolded
| Instrumented |
| Item Listing |
| Target 4## |
| Target 42# |
| field3 |
And I see the following links not being bolded
| Target 421 |
| field1 |
Scenario: Going deep down back out nested routes reset bolded states
When I select the link Instrumented
And I select the link Target 421
And I select the link Counters
Then I see the following links being bolded
| Instrumented |
| Counters |
And I see the following links not being bolded
| Item Listing |
| Target 4## |
| Target 42# |
| Target 421 |
Scenario: Going deep down back out nested routes reset bolded states, SSR
When I select the link Instrumented
And I select the link Target 421
And I select the link Counters
And I reload the page
Then I see the following links being bolded
| Instrumented |
| Counters |
And I see the following links not being bolded
| Item Listing |
| Target 4## |
| Target 42# |
| Target 421 |

View File

@@ -1,94 +0,0 @@
@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

@@ -1,187 +0,0 @@
@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 | 1 |
| 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 | 0 |
| inspect_item_root | 1 |
| 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 | 0 |
| inspect_item_root | 2 |
| 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 | 1 |
| 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 | 2 |
| 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 | 1 |
| inspect_item_root | 1 |
| inspect_item_field | 0 |
# The following tests previously showed the clear difference between
# hydration and CSR, where hydration resulting in extra server API
# calls via the resource while CSR did not suffer from the issue.
# With #3182 merged the issue is corrected, going up to components
# specified by the parent route should no longer result in the
# superfluous fetches for resources needed by component about to be
# unmounted.
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 | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
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 |
# Further two sets for good measure.
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 | 0 |
| inspect_item_root | 0 |
| inspect_item_field | 0 |
Scenario: Emulate the same csr counter reset, for Target 41#.
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,19 +37,3 @@ 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,38 +63,3 @@ 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(())
}
pub async fn link_text_is_aria_current(client: &Client, text: &str) -> Result<()> {
let link = find::link_with_text(client, text).await?;
link.attr("aria-current").await?
.expect(format!("aria-current missing for {text}").as_str());
Ok(())
}
pub async fn link_text_is_not_aria_current(client: &Client, text: &str) -> Result<()> {
let link = find::link_with_text(client, text).await?;
link.attr("aria-current").await?
.map(|_| anyhow::bail!("aria-current mistakenly set for {text}"))
.unwrap_or(Ok(()))
}

View File

@@ -77,43 +77,6 @@ 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(
@@ -124,12 +87,3 @@ async fn component_message(client: &Client, id: &str) -> Result<String> {
Ok(text)
}
pub async fn link_with_text(client: &Client, text: &str) -> Result<Element> {
let link = client
.wait()
.for_element(Locator::LinkText(text))
.await
.expect(format!("Link not found by `{}`", text).as_str());
Ok(link)
}

View File

@@ -1,6 +1,6 @@
use crate::fixtures::{action, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::{given, when, gherkin::Step};
use cucumber::{given, when};
#[given("I see the app")]
#[when("I open the app")]
@@ -12,13 +12,19 @@ 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 (.*)$")]
#[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<()> {
async fn i_select_the_component(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
action::click_link(client, &text).await?;
@@ -53,69 +59,3 @@ 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, gherkin::Step};
use cucumber::then;
#[then(regex = r"^I see the page title is (.*)$")]
async fn i_see_the_page_title_is(
@@ -79,75 +79,3 @@ async fn i_see_the_second_count_is(
Ok(())
}
#[then(regex = r"^I see the (.*) link being bolded$")]
async fn i_see_the_link_being_bolded(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::link_text_is_aria_current(client, &text).await?;
Ok(())
}
#[then(expr = "I see the following links being bolded")]
async fn i_see_the_following_links_being_bolded(
world: &mut AppWorld,
step: &Step,
) -> Result<()> {
let client = &world.client;
if let Some(table) = step.table.as_ref() {
for row in table.rows.iter() {
check::link_text_is_aria_current(client, &row[0]).await?;
}
}
Ok(())
}
#[then(regex = r"^I see the (.*) link not being bolded$")]
async fn i_see_the_link_being_not_bolded(
world: &mut AppWorld,
text: String,
) -> Result<()> {
let client = &world.client;
check::link_text_is_not_aria_current(client, &text).await?;
Ok(())
}
#[then(expr = "I see the following links not being bolded")]
async fn i_see_the_following_links_not_being_bolded(
world: &mut AppWorld,
step: &Step,
) -> Result<()> {
let client = &world.client;
if let Some(table) = step.table.as_ref() {
for row in table.rows.iter() {
check::link_text_is_not_aria_current(client, &row[0]).await?;
}
}
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,4 +1,3 @@
use crate::instrumented::InstrumentedRoutes;
use leptos::prelude::*;
use leptos_router::{
components::{Outlet, ParentRoute, Redirect, Route, Router, Routes, A},
@@ -42,7 +41,6 @@ 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.">
@@ -112,7 +110,6 @@ pub fn App() -> impl IntoView {
<Route path=StaticSegment("local") view=LocalResource/>
<Route path=StaticSegment("none") view=None/>
</ParentRoute>
<InstrumentedRoutes/>
</Routes>
</main>
</Router>

View File

@@ -1,667 +0,0 @@
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,5 +1,4 @@
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.as_ref()))
.service(Files::new("/", site_root))
})
.bind(addr)?
.workers(1)

View File

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

View File

@@ -22,29 +22,24 @@ pub fn App() -> impl IntoView {
#[component]
fn Home() -> impl IntoView {
let (value, set_value) = signal(0);
let (count, set_count) = signal(0);
// thanks to https://tailwindcomponents.com/component/blue-buttons-example for the showcase layout
view! {
<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 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>
</main>
}
}

View File

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

View File

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

View File

@@ -54,11 +54,7 @@ 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"
class:invisible=move || {value.get() < 1}
>
<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>
</div>

View File

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

View File

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

View File

@@ -22,29 +22,24 @@ pub fn App() -> impl IntoView {
#[component]
fn Home() -> impl IntoView {
let (value, set_value) = signal(0);
let (count, set_count) = signal(0);
// thanks to https://tailwindcomponents.com/component/blue-buttons-example for the showcase layout
view! {
<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>
<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>
}
}

View File

@@ -1,10 +1,7 @@
/** @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<Signal<u64>> + 'static,
T: Into<MaybeSignal<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.as_ref()))
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?

View File

@@ -16,15 +16,15 @@ leptos_router = { path = "../../router" }
leptos_integration_utils = { path = "../../integrations/utils", optional = true }
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.7.5", optional = true }
tower = { version = "0.5.1", features = ["util"], optional = true }
tower-http = { version = "0.6.1", features = ["fs"], optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }
http = { version = "1.1" }
sqlx = { version = "0.8.0", features = [
"runtime-tokio-rustls",
"sqlite",
], optional = true }
thiserror = "2.0"
thiserror = "1.0"
wasm-bindgen = "0.2.93"
[features]

View File

@@ -9,7 +9,7 @@ use leptos::{
hydration::{AutoReload, HydrationScripts},
prelude::*,
};
use tower::util::ServiceExt;
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_or_index_handler(

View File

@@ -4,6 +4,25 @@ use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use server_fn::ServerFnError;
pub fn shell(leptos_options: &LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=leptos_options.clone() />
<HydrationScripts options=leptos_options.clone()/>
<link rel="stylesheet" id="leptos" href="/pkg/todo_app_sqlite_csr.css"/>
<link rel="shortcut icon" type="image/ico" href="/favicon.ico"/>
</head>
<body>
<TodoApp/>
</body>
</html>
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Todo {

View File

@@ -319,7 +319,10 @@ pub fn Todo(todo: Todo) -> impl IntoView {
node_ref=todo_input
class="toggle"
type="checkbox"
bind:checked=todo.completed
prop:checked=move || todo.completed.get()
on:input:target=move |ev| {
todo.completed.set(ev.target().checked());
}
/>
<label on:dblclick=move |_| {

58
flake.lock generated
View File

@@ -5,11 +5,29 @@
"systems": "systems"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
@@ -20,11 +38,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1727634051,
"narHash": "sha256-S5kVU7U82LfpEukbn/ihcyNt2+EvG7Z5unsKW9H/yFA=",
"lastModified": 1703961334,
"narHash": "sha256-M1mV/Cq+pgjk0rt6VxoyyD+O8cOUiai8t9Q6Yyq4noY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "06cf0e1da4208d3766d898b7fdab6513366d45b9",
"rev": "b0d36bd0a420ecee3bc916c91886caca87c894e9",
"type": "github"
},
"original": {
@@ -36,11 +54,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1718428119,
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
"lastModified": 1681358109,
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
"type": "github"
},
"original": {
@@ -59,14 +77,15 @@
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1727749966,
"narHash": "sha256-DUS8ehzqB1DQzfZ4bRXVSollJhu+y7cvh1DJ9mbWebE=",
"lastModified": 1704075545,
"narHash": "sha256-L3zgOuVKhPjKsVLc3yTm2YJ6+BATyZBury7wnhyc8QU=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "00decf1b4f9886d25030b9ee4aed7bfddccb5f66",
"rev": "a0df72e106322b67e9c6e591fe870380bd0da0d5",
"type": "github"
},
"original": {
@@ -89,6 +108,21 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View File

@@ -1,6 +1,6 @@
[package]
name = "hydration_context"
version = "0.2.1"
version = "0.2.0-beta6"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -12,12 +12,12 @@ edition.workspace = true
[dependencies]
throw_error = { workspace = true }
or_poisoned = { workspace = true }
futures = "0.3.31"
futures = "0.3.30"
serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = { version = "0.2.97", optional = true }
js-sys = { version = "0.3.74", optional = true }
once_cell = "1.20"
pin-project-lite = "0.2.15"
wasm-bindgen = { version = "0.2.93", optional = true }
js-sys = { version = "0.3.69", optional = true }
once_cell = "1.19"
pin-project-lite = "0.2.14"
[features]
browser = ["dep:wasm-bindgen", "dep:js-sys"]
@@ -25,6 +25,3 @@ browser = ["dep:wasm-bindgen", "dep:js-sys"]
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(leptos_debuginfo)'] }

View File

@@ -1,8 +1,3 @@
// #[wasm_bindgen(thread_local)] is deprecated in wasm-bindgen 0.2.96
// but the replacement is also only shipped in that version
// as a result, we'll just allow deprecated for now
#![allow(deprecated)]
use super::{SerializedDataId, SharedContext};
use crate::{PinnedFuture, PinnedStream};
use core::fmt::Debug;

View File

@@ -44,18 +44,6 @@ 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()
@@ -83,14 +71,15 @@ pub trait SharedContext: Debug {
/// Reads the current value of some data from the shared context, if it has been
/// sent from the server. This returns the serialized data as a `String` that should
/// be deserialized.
/// be deserialized using [`Serializable::de`].
///
/// On the server and in client-side rendered implementations, this should
/// always return [`None`].
fn read_data(&self, id: &SerializedDataId) -> Option<String>;
/// Returns a [`Future`] that resolves with a `String` that should
/// be deserialized once the given piece of server data has resolved.
/// be deserialized using [`Serializable::de`] once the given piece of server
/// data has resolved.
///
/// On the server and in client-side rendered implementations, this should
/// return a [`Future`] that is immediately ready with [`None`].
@@ -147,8 +136,8 @@ pub trait SharedContext: Debug {
/// Adds a `Future` to the set of “blocking resources” that should prevent the servers
/// response stream from beginning until all are resolved. The `Future` returned by
/// blocking resources will not resolve until every `Future` added by this method
/// has resolved.
/// [`blocking_resources`](Self::blocking_resources) will not resolve until every `Future`
/// added by this method has resolved.
///
/// In browser implementations, this should be a no-op.
fn defer_stream(&self, wait_for: PinnedFuture<()>);

View File

@@ -58,27 +58,6 @@ 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

@@ -9,10 +9,10 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
actix-http = "3.9"
actix-http = "3.8"
actix-files = "0.6"
actix-web = "4.9"
futures = "0.3.31"
actix-web = "4.8"
futures = "0.3.30"
any_spawner = { workspace = true, features = ["tokio"] }
hydration_context = { workspace = true }
leptos = { workspace = true, features = ["nonce", "ssr"] }
@@ -24,7 +24,7 @@ server_fn = { workspace = true, features = ["actix"] }
serde_json = "1.0"
parking_lot = "0.12.3"
tracing = { version = "0.1", optional = true }
tokio = { version = "1.41", features = ["rt", "fs"] }
tokio = { version = "1.39", features = ["rt", "fs"] }
send_wrapper = "0.6.0"
dashmap = "6"
once_cell = "1"
@@ -33,7 +33,7 @@ once_cell = "1"
rustdoc-args = ["--generate-link-to-definition"]
[features]
dont-use-islands-router = []
islands-router = []
tracing = ["dep:tracing"]
[package.metadata.cargo-all-features]

View File

@@ -1,5 +1,4 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]
//! Provides functions to easily integrate Leptos with Actix.
//!
@@ -10,6 +9,7 @@
use actix_files::NamedFile;
use actix_http::header::{HeaderName, HeaderValue, ACCEPT, LOCATION, REFERER};
use actix_web::{
body::BoxBody,
dev::{ServiceFactory, ServiceRequest},
http::header,
test,
@@ -24,7 +24,7 @@ use leptos::{
config::LeptosOptions,
context::{provide_context, use_context},
prelude::expect_context,
reactive::{computed::ScopedFuture, owner::Owner},
reactive_graph::{computed::ScopedFuture, owner::Owner},
IntoView,
};
use leptos_integration_utils::{
@@ -35,7 +35,7 @@ use leptos_router::{
components::provide_server_redirect,
location::RequestUrl,
static_routes::{RegenerationFn, ResolvedStaticPath},
ExpandOptionals, Method, PathSegment, RouteList, RouteListing, SsrMode,
Method, PathSegment, RouteList, RouteListing, SsrMode,
};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
@@ -44,7 +44,6 @@ use server_fn::{
redirect::REDIRECT_HEADER, request::actix::ActixRequest, ServerFnError,
};
use std::{
collections::HashSet,
fmt::{Debug, Display},
future::Future,
ops::{Deref, DerefMut},
@@ -56,10 +55,8 @@ use std::{
/// Typically contained inside of a ResponseOptions. Setting this is useful for cookies and custom responses.
#[derive(Debug, Clone, Default)]
pub struct ResponseParts {
/// If provided, this will overwrite any other status code for this response.
pub status: Option<StatusCode>,
/// The map of headers that should be added to the response.
pub headers: header::HeaderMap,
pub status: Option<StatusCode>,
}
impl ResponseParts {
@@ -82,18 +79,16 @@ impl ResponseParts {
}
}
/// A wrapper for an Actix [`HttpRequest`] that allows it to be used in an
/// A wrapper for an Actix [`HttpRequest`](actix_web::HttpRequest) that allows it to be used in an
/// `Send`/`Sync` setting like Leptos's Context API.
#[derive(Debug, Clone)]
pub struct Request(SendWrapper<HttpRequest>);
impl Request {
/// Wraps an existing Actix request.
pub fn new(req: &HttpRequest) -> Self {
Self(SendWrapper::new(req.clone()))
}
/// Consumes the wrapper and returns the inner Actix request.
pub fn into_inner(self) -> HttpRequest {
self.0.take()
}
@@ -303,7 +298,7 @@ pub fn redirect(path: &str) {
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [Request]
/// - [HttpRequest](actix_web::HttpRequest)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -330,7 +325,7 @@ pub fn handle_server_fns() -> Route {
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [Request]
/// - [HttpRequest](actix_web::HttpRequest)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -375,8 +370,8 @@ pub fn handle_server_fns_with_context(
.take(),
);
// if it accepts text/html (i.e., is a plain form post) and doesn't already have a
// Location set, then redirect to the Referer
// it it accepts text/html (i.e., is a plain form post) and doesn't already have a
// Location set, then redirect to to Referer
if accepts_html {
if let Some(referrer) = referrer {
let has_location =
@@ -390,20 +385,7 @@ pub fn handle_server_fns_with_context(
}
}
// the Location header may have been set to Referer, so any redirection by the
// user must overwrite it
{
let mut res_options = res_options.0.write();
let headers = res.0.headers_mut();
for location in
res_options.headers.remove(header::LOCATION)
{
headers.insert(header::LOCATION, location);
}
}
// apply status code and headers if user changed them
// apply status code and headers if used changed them
res.extend_response(&res_options);
res.0
})
@@ -432,6 +414,12 @@ pub fn handle_server_fns_with_context(
/// will include fallback content for any `<Suspense/>` nodes, and be immediately interactive,
/// but requires some client-side JavaScript.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_stream](leptos::ssr::render_to_stream), and
/// includes everything described in the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
/// use actix_web::{App, HttpServer};
@@ -470,8 +458,9 @@ pub fn handle_server_fns_with_context(
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [Request]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -491,6 +480,13 @@ where
/// This stream will pause at each `<Suspense/>` node and wait for it to resolve before
/// sending down its HTML. The app will become interactive once it has fully loaded.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using
/// [render_to_stream_in_order](leptos::ssr::render_to_stream_in_order),
/// and includes everything described in the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
/// use actix_web::{App, HttpServer};
@@ -532,7 +528,9 @@ where
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [Request]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -549,7 +547,13 @@ where
/// Returns an Actix [struct@Route](actix_web::Route) that listens for a `GET` request and tries
/// to route it using [leptos_router], asynchronously rendering an HTML page after all
/// `async` resources have loaded.
/// `async` [Resource](leptos::Resource)s have loaded.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to the apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_string_async](leptos::ssr::render_to_string_async), and
/// includes everything described in the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
@@ -589,7 +593,9 @@ where
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [Request]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -613,7 +619,9 @@ where
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [Request]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -648,7 +656,9 @@ where
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [Request]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -680,8 +690,9 @@ where
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [Request]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -704,7 +715,7 @@ where
/// Returns an Actix [struct@Route](actix_web::Route) that listens for a `GET` request and tries
/// to route it using [leptos_router], asynchronously serving the page once all `async`
/// resources have loaded.
/// [Resource](leptos::Resource)s have loaded.
///
/// This function allows you to provide additional information to Leptos for your route.
/// It could be used to pass in Path Info, Connection Info, or anything your heart desires.
@@ -712,7 +723,9 @@ where
/// ## Provided Context Types
/// This function always provides context values including the following types:
/// - [ResponseOptions]
/// - [Request]
/// - [HttpRequest](actix_web::HttpRequest)
/// - [MetaContext](leptos_meta::MetaContext)
/// - [RouterIntegrationContext](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -736,7 +749,7 @@ where
IV: IntoView + 'static,
{
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -887,7 +900,7 @@ trait ActixPath {
fn to_actix_path(&self) -> String;
}
impl ActixPath for Vec<PathSegment> {
impl ActixPath for &[PathSegment] {
fn to_actix_path(&self) -> String {
let mut path = String::new();
for segment in self.iter() {
@@ -909,14 +922,6 @@ impl ActixPath for Vec<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
@@ -930,38 +935,25 @@ pub struct ActixRouteListing {
mode: SsrMode,
methods: Vec<leptos_router::Method>,
regenerate: Vec<RegenerationFn>,
exclude: bool,
}
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()
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,
}
}
}
@@ -978,7 +970,6 @@ impl ActixRouteListing {
mode,
methods: methods.into_iter().collect(),
regenerate: regenerate.into(),
exclude: false,
}
}
@@ -1036,37 +1027,27 @@ where
let mut routes = routes
.into_inner()
.into_iter()
.flat_map(IntoRouteListing::into_route_listing)
.map(ActixRouteListing::from)
.collect::<Vec<_>>();
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)
(
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,
)
}
/// Allows generating any prerendered routes.
@@ -1305,12 +1286,14 @@ where
web::get().to(handler)
}
pub enum DataResponse<T> {
Data(T),
Response(actix_web::dev::Response<BoxBody>),
}
/// This trait allows one to pass a list of routes and a render function to Actix's router, letting us avoid
/// having to use wildcards or manually define all routes in multiple places.
pub trait LeptosRoutes {
/// Adds routes to the Axum router that have either
/// 1) been generated by `leptos_router`, or
/// 2) handle a server function.
fn leptos_routes<IV>(
self,
paths: Vec<ActixRouteListing>,
@@ -1319,12 +1302,6 @@ pub trait LeptosRoutes {
where
IV: IntoView + 'static;
/// Adds routes to the Axum router that have either
/// 1) been generated by `leptos_router`, or
/// 2) handle a server function.
///
/// Runs `additional_context` to provide additional data to the reactive system via context,
/// when handling a route.
fn leptos_routes_with_context<IV>(
self,
paths: Vec<ActixRouteListing>,
@@ -1376,24 +1353,15 @@ 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() {
if !excluded.contains(path) {
let additional_context = additional_context.clone();
let handler =
handle_server_fns_with_context(additional_context);
router = router.route(path, handler);
}
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().filter(|p| !p.exclude) {
for listing in paths.iter() {
let path = listing.path();
let mode = listing.mode();
@@ -1413,41 +1381,39 @@ where
),
)
} else {
router
.route(path, web::head().to(HttpResponse::Ok))
.route(
path,
match mode {
SsrMode::OutOfOrder => {
render_app_to_stream_with_context(
additional_context_and_method.clone(),
app_fn.clone(),
method,
)
}
SsrMode::PartiallyBlocked => {
render_app_to_stream_with_context_and_replace_blocks(
additional_context_and_method.clone(),
app_fn.clone(),
method,
true,
)
}
SsrMode::InOrder => {
render_app_to_stream_in_order_with_context(
additional_context_and_method.clone(),
app_fn.clone(),
method,
)
}
SsrMode::Async => render_app_async_with_context(
additional_context_and_method.clone(),
app_fn.clone(),
method,
),
_ => unreachable!()
},
)
router.route(
path,
match mode {
SsrMode::OutOfOrder => {
render_app_to_stream_with_context(
additional_context_and_method.clone(),
app_fn.clone(),
method,
)
}
SsrMode::PartiallyBlocked => {
render_app_to_stream_with_context_and_replace_blocks(
additional_context_and_method.clone(),
app_fn.clone(),
method,
true,
)
}
SsrMode::InOrder => {
render_app_to_stream_in_order_with_context(
additional_context_and_method.clone(),
app_fn.clone(),
method,
)
}
SsrMode::Async => render_app_async_with_context(
additional_context_and_method.clone(),
app_fn.clone(),
method,
),
_ => unreachable!()
},
)
};
}
}
@@ -1489,24 +1455,15 @@ 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() {
if !excluded.contains(path) {
let additional_context = additional_context.clone();
let handler =
handle_server_fns_with_context(additional_context);
router = router.route(path, handler);
}
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().filter(|p| !p.exclude) {
for listing in paths.iter() {
let path = listing.path();
let mode = listing.mode();
@@ -1595,10 +1552,7 @@ where
ServerFnError::new("HttpRequest should have been provided via context")
})?;
SendWrapper::new(async move {
T::extract(&req)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))
})
.await
T::extract(&req)
.await
.map_err(|e| ServerFnError::ServerError(e.to_string()))
}

View File

@@ -11,11 +11,13 @@ edition.workspace = true
[dependencies]
any_spawner = { workspace = true, features = ["tokio"] }
hydration_context = { workspace = true }
axum = { version = "0.7.9", default-features = false, features = [
axum = { version = "0.7.5", default-features = false, features = [
"matched-path",
] }
dashmap = "6"
futures = "0.3.31"
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"] }
@@ -24,19 +26,20 @@ leptos_router = { workspace = true, features = ["ssr"] }
leptos_integration_utils = { workspace = true }
once_cell = "1"
parking_lot = "0.12.3"
tokio = { version = "1.41", default-features = false }
tower = { version = "0.5.1", features = ["util"] }
tower-http = "0.6.2"
tracing = { version = "0.1.41", optional = true }
serde_json = "1.0"
tokio = { version = "1.39", default-features = false }
tower = "0.4.13"
tower-http = "0.5.2"
tracing = { version = "0.1.40", optional = true }
[dev-dependencies]
axum = "0.7.9"
tokio = { version = "1.41", features = ["net", "rt-multi-thread"] }
axum = "0.7.5"
tokio = { version = "1.39", features = ["net", "rt-multi-thread"] }
[features]
wasm = []
default = ["tokio/fs", "tokio/sync", "tower-http/fs", "tower/util"]
dont-use-islands-router = []
islands-router = []
tracing = ["dep:tracing"]
[package.metadata.docs.rs]

View File

@@ -1,6 +1,4 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]
//! Provides functions to easily integrate Leptos with Axum.
//!
//! ## JS Fetch Integration
@@ -16,7 +14,7 @@
//! - `default`: supports running in a typical native Tokio/Axum environment
//! - `wasm`: with `default-features = false`, supports running in a JS Fetch-based
//! environment
//! - `islands`: activates Leptos [islands mode](https://leptos-rs.github.io/leptos/islands.html)
//! - `experimental-islands`: activates Leptos [islands mode](https://leptos-rs.github.io/leptos/islands.html)
//!
//! ### Important Note
//! Prior to 0.5, using `default-features = false` on `leptos_axum` simply did nothing. Now, it actively
@@ -55,7 +53,7 @@ use leptos::{
config::LeptosOptions,
context::{provide_context, use_context},
prelude::*,
reactive::{computed::ScopedFuture, owner::Owner},
reactive_graph::{computed::ScopedFuture, owner::Owner},
IntoView,
};
use leptos_integration_utils::{
@@ -65,9 +63,10 @@ use leptos_meta::ServerMetaContext;
#[cfg(feature = "default")]
use leptos_router::static_routes::ResolvedStaticPath;
use leptos_router::{
components::provide_server_redirect, location::RequestUrl,
static_routes::RegenerationFn, ExpandOptionals, PathSegment, RouteList,
RouteListing, SsrMode,
components::provide_server_redirect,
location::RequestUrl,
static_routes::{RegenerationFn, StaticParamsMap},
PathSegment, RouteList, RouteListing, SsrMode,
};
#[cfg(feature = "default")]
use once_cell::sync::Lazy;
@@ -75,9 +74,9 @@ use parking_lot::RwLock;
use server_fn::{redirect::REDIRECT_HEADER, ServerFnError};
#[cfg(feature = "default")]
use std::path::Path;
use std::{collections::HashSet, fmt::Debug, io, pin::Pin, sync::Arc};
use std::{fmt::Debug, io, pin::Pin, sync::Arc};
#[cfg(feature = "default")]
use tower::util::ServiceExt;
use tower::ServiceExt;
#[cfg(feature = "default")]
use tower_http::services::ServeDir;
// use tracing::Instrument; // TODO check tracing span -- was this used in 0.6 for a missing link?
@@ -86,9 +85,7 @@ use tower_http::services::ServeDir;
/// Typically contained inside of a ResponseOptions. Setting this is useful for cookies and custom responses.
#[derive(Debug, Clone, Default)]
pub struct ResponseParts {
/// If provided, this will overwrite any other status code for this response.
pub status: Option<StatusCode>,
/// The map of headers that should be added to the response.
pub headers: HeaderMap,
}
@@ -197,9 +194,9 @@ impl ExtendResponse for AxumResponse {
/// 2. A server function that is called from WASM running in the client (e.g., a dispatched action
/// or a spawned `Future`).
/// 3. A `<form>` submitted to the server function endpoint using default browser APIs (often due
/// to using [`ActionForm`] without JS/WASM present.)
/// to using [`ActionForm`](leptos::form::ActionForm) without JS/WASM present.)
///
/// Using it with a non-blocking [`Resource`] will not work if you are using streaming rendering,
/// Using it with a non-blocking [`Resource`](leptos::server::Resource) will not work if you are using streaming rendering,
/// as the response's headers will already have been sent by the time the server function calls `redirect()`.
///
/// ### Implementation
@@ -399,8 +396,8 @@ async fn handle_server_fns_inner(
// actually run the server fn
let mut res = AxumResponse(service.run(req).await);
// if it accepts text/html (i.e., is a plain form post) and doesn't already have a
// Location set, then redirect to the Referer
// it it accepts text/html (i.e., is a plain form post) and doesn't already have a
// Location set, then redirect to to Referer
if accepts_html {
if let Some(referrer) = referrer {
let has_location =
@@ -412,7 +409,7 @@ async fn handle_server_fns_inner(
}
}
// apply status code and headers if user changed them
// apply status code and headers if used changed them
res.extend_response(&res_options);
Ok(res.0)
})
@@ -435,13 +432,18 @@ async fn handle_server_fns_inner(
.expect("could not build Response")
}
/// A stream of bytes of HTML.
pub type PinnedHtmlStream =
Pin<Box<dyn Stream<Item = io::Result<Bytes>> + Send>>;
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
/// to route it using [leptos_router], serving an HTML stream of your application.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_stream](leptos::ssr::render_to_stream), and
/// includes everything described in the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
/// use axum::{handler::Handler, Router};
@@ -479,7 +481,8 @@ pub type PinnedHtmlStream =
/// This function always provides context values including the following types:
/// - [`Parts`]
/// - [`ResponseOptions`]
/// - [`ServerMetaContext`]
/// - [`ServerMetaContext`](leptos_meta::ServerMetaContext)
/// - [`RouterIntegrationContext`](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -530,6 +533,12 @@ where
/// This stream will pause at each `<Suspense/>` node and wait for it to resolve before
/// sending down its HTML. The app will become interactive once it has fully loaded.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_stream_in_order], and includes everything described in
/// the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
/// use axum::{handler::Handler, Router};
@@ -567,7 +576,8 @@ where
/// This function always provides context values including the following types:
/// - [`Parts`]
/// - [`ResponseOptions`]
/// - [`ServerMetaContext`]
/// - [`ServerMetaContext`](leptos_meta::ServerMetaContext)
/// - [`RouterIntegrationContext`](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -596,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(
@@ -620,7 +630,8 @@ where
/// This function always provides context values including the following types:
/// - [`Parts`]
/// - [`ResponseOptions`]
/// - [`ServerMetaContext`]
/// - [`ServerMetaContext`](leptos_meta::ServerMetaContext)
/// - [`RouterIntegrationContext`](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -751,7 +762,8 @@ where
/// This function always provides context values including the following types:
/// - [`Parts`]
/// - [`ResponseOptions`]
/// - [`ServerMetaContext`]
/// - [`ServerMetaContext`](leptos_meta::ServerMetaContext)
/// - [`RouterIntegrationContext`](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -772,7 +784,7 @@ where
_ = replace_blocks; // TODO
handle_response(additional_context, app_fn, |app, chunks| {
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_out_of_order_branching()
} else {
app.to_html_stream_out_of_order()
@@ -794,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(
@@ -818,7 +830,8 @@ where
/// This function always provides context values including the following types:
/// - [`Parts`]
/// - [`ResponseOptions`]
/// - [`ServerMetaContext`]
/// - [`ServerMetaContext`](leptos_meta::ServerMetaContext)
/// - [`RouterIntegrationContext`](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -836,7 +849,7 @@ where
IV: IntoView + 'static,
{
handle_response(additional_context, app_fn, |app, chunks| {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -935,7 +948,13 @@ fn provide_contexts(
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
/// to route it using [leptos_router], asynchronously rendering an HTML page after all
/// `async` resources have loaded.
/// `async` [Resource](leptos::Resource)s have loaded.
///
/// The provides a [MetaContext] and a [RouterIntegrationContext] to apps context before
/// rendering it, and includes any meta tags injected using [leptos_meta].
///
/// The HTML stream is rendered using [render_to_string_async], and includes everything described in
/// the documentation for that function.
///
/// This can then be set up at an appropriate route in your application:
/// ```
@@ -975,7 +994,8 @@ fn provide_contexts(
/// This function always provides context values including the following types:
/// - [`Parts`]
/// - [`ResponseOptions`]
/// - [`ServerMetaContext`]
/// - [`ServerMetaContext`](leptos_meta::ServerMetaContext)
/// - [`RouterIntegrationContext`](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -996,7 +1016,7 @@ where
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
/// to route it using [leptos_router], asynchronously rendering an HTML page after all
/// `async` resources have loaded.
/// `async` [Resource](leptos::Resource)s have loaded.
///
/// This version allows us to pass Axum State/Extension/Extractor or other infro from Axum or network
/// layers above Leptos itself. To use it, you'll need to write your own handler function that provides
@@ -1005,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(
@@ -1029,7 +1049,8 @@ where
/// This function always provides context values including the following types:
/// - [`Parts`]
/// - [`ResponseOptions`]
/// - [`ServerMetaContext`]
/// - [`ServerMetaContext`](leptos_meta::ServerMetaContext)
/// - [`RouterIntegrationContext`](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -1048,7 +1069,7 @@ where
{
handle_response(additional_context, app_fn, |app, chunks| {
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -1063,7 +1084,7 @@ where
/// Returns an Axum [Handler](axum::handler::Handler) that listens for a `GET` request and tries
/// to route it using [leptos_router], asynchronously rendering an HTML page after all
/// `async` resources have loaded.
/// `async` [Resource](leptos::Resource)s have loaded.
///
/// This version allows us to pass Axum State/Extension/Extractor or other infro from Axum or network
/// layers above Leptos itself. To use it, you'll need to write your own handler function that provides
@@ -1072,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(
@@ -1096,7 +1117,8 @@ where
/// This function always provides context values including the following types:
/// - [`Parts`]
/// - [`ResponseOptions`]
/// - [`ServerMetaContext`]
/// - [`ServerMetaContext`](leptos_meta::ServerMetaContext)
/// - [`RouterIntegrationContext`](leptos_router::RouterIntegrationContext)
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
@@ -1124,7 +1146,7 @@ where
IV: IntoView + 'static,
{
Box::pin(async move {
let app = if cfg!(feature = "dont-use-islands-router") {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
@@ -1185,6 +1207,32 @@ where
generate_route_list_with_exclusions_and_ssg(app_fn, excluded_routes).0
}
/// Builds all routes that have been defined using [`StaticRoute`].
#[allow(unused)]
pub async fn build_static_routes<IV>(
options: &LeptosOptions,
app_fn: impl Fn() -> IV + 'static + Send + Clone,
routes: &[RouteListing],
static_data_map: StaticParamsMap,
) where
IV: IntoView + 'static,
{
todo!()
/*
let options = options.clone();
let routes = routes.to_owned();
spawn_task!(async move {
leptos_router::build_static_routes(
&options,
app_fn,
&routes,
&static_data_map,
)
.await
.expect("could not build static routes")
});*/
}
/// Generates a list of all routes defined in Leptos's Router in your app. We can then use this to automatically
/// create routes in Axum's Router without having to use wildcard matching or fallbacks. Takes in your root app Element
/// as an argument so it can walk you app tree. This version is tailored to generate Axum compatible paths. Adding excluded_routes
@@ -1215,38 +1263,25 @@ pub struct AxumRouteListing {
methods: Vec<leptos_router::Method>,
#[allow(unused)]
regenerate: Vec<RegenerationFn>,
exclude: bool,
}
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()
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,
}
}
}
@@ -1263,7 +1298,6 @@ impl AxumRouteListing {
mode,
methods: methods.into_iter().collect(),
regenerate: regenerate.into(),
exclude: false,
}
}
@@ -1308,7 +1342,8 @@ where
.with(|| {
// stub out a path for now
provide_context(RequestUrl::new(""));
let (mock_parts, _) = Request::new(Body::from("")).into_parts();
let (mock_parts, _) =
http::Request::new(Body::from("")).into_parts();
let (mock_meta, _) = ServerMetaContext::new();
provide_contexts("", &mock_meta, mock_parts, Default::default());
additional_context();
@@ -1326,36 +1361,27 @@ where
let mut routes = routes
.into_inner()
.into_iter()
.flat_map(IntoRouteListing::into_route_listing)
.map(AxumRouteListing::from)
.collect::<Vec<_>>();
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)
(
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,
)
}
/// Allows generating any prerendered routes.
@@ -1376,8 +1402,8 @@ impl StaticRouteGenerator {
let add_context = additional_context.clone();
move || {
let full_path = format!("http://leptos.dev{path}");
let mock_req = Request::builder()
.method(Method::GET)
let mock_req = http::Request::builder()
.method(http::Method::GET)
.header("Accept", "text/html")
.body(Body::empty())
.unwrap();
@@ -1469,12 +1495,10 @@ impl StaticRouteGenerator {
_ = routes;
_ = app_fn;
_ = additional_context;
Self(Box::new(|_| {
panic!(
"Static routes are not currently supported on WASM32 \
server targets."
);
}))
panic!(
"Static routes are not currently supported on WASM32 server \
targets."
);
}
}
@@ -1635,9 +1659,6 @@ where
S: Clone + Send + Sync + 'static,
LeptosOptions: FromRef<S>,
{
/// Adds routes to the Axum router that have either
/// 1) been generated by `leptos_router`, or
/// 2) handle a server function.
fn leptos_routes<IV>(
self,
options: &S,
@@ -1647,12 +1668,6 @@ where
where
IV: IntoView + 'static;
/// Adds routes to the Axum router that have either
/// 1) been generated by `leptos_router`, or
/// 2) handle a server function.
///
/// Runs `additional_context` to provide additional data to the reactive system via context,
/// when handling a route.
fn leptos_routes_with_context<IV>(
self,
options: &S,
@@ -1663,8 +1678,6 @@ where
where
IV: IntoView + 'static;
/// Extends the Axum router with the given paths, and handles the requests with the given
/// handler.
fn leptos_routes_with_handler<H, T>(
self,
paths: Vec<AxumRouteListing>,
@@ -1679,7 +1692,7 @@ trait AxumPath {
fn to_axum_path(&self) -> String;
}
impl AxumPath for Vec<PathSegment> {
impl AxumPath for &[PathSegment] {
fn to_axum_path(&self) -> String {
let mut path = String::new();
for segment in self.iter() {
@@ -1699,14 +1712,6 @@ impl AxumPath for Vec<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
@@ -1762,41 +1767,32 @@ 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
};
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:?}"
);
}
},
);
}
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().filter(|p| !p.exclude) {
for listing in paths.iter() {
let path = listing.path();
for method in listing.methods() {
@@ -1905,7 +1901,7 @@ where
T: 'static,
{
let mut router = self;
for listing in paths.iter().filter(|p| !p.exclude) {
for listing in paths.iter() {
for method in listing.methods() {
router = router.route(
listing.path(),
@@ -1937,7 +1933,7 @@ where
///
/// #[server]
/// pub async fn request_method() -> Result<String, ServerFnError> {
/// use axum::http::Method;
/// use http::Method;
/// use leptos_axum::extract;
///
/// // you can extract anything that a regular Axum extractor can extract
@@ -1977,10 +1973,6 @@ where
.map_err(|e| ServerFnError::ServerError(format!("{e:?}")))
}
/// A reasonable handler for serving static files (like JS/WASM/CSS) and 404 errors.
///
/// This is provided as a convenience, but is a fairly simple function. If you need to adapt it,
/// simply reuse the source code of this function in your own application.
#[cfg(feature = "default")]
pub fn file_and_error_handler<S, IV>(
shell: fn(LeptosOptions) -> IV,
@@ -2000,7 +1992,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, req.headers());
let res = get_static_file(uri, &options.site_root);
let res = res.await.unwrap();
if res.status() == StatusCode::OK {
@@ -2034,26 +2026,14 @@ where
async fn get_static_file(
uri: Uri,
root: &str,
headers: &HeaderMap<HeaderValue>,
) -> Result<Response<Body>, (StatusCode, String)> {
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();
let req = Request::builder()
.uri(uri.clone())
.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)
.precompressed_gzip()
.precompressed_br()
.oneshot(req)
.await
{
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,

View File

@@ -9,7 +9,7 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
futures = "0.3.31"
futures = "0.3.30"
hydration_context = { workspace = true }
leptos = { workspace = true, features = ["nonce"] }
leptos_meta = { workspace = true, features = ["ssr"] }

View File

@@ -2,7 +2,7 @@ use futures::{stream::once, Stream, StreamExt};
use hydration_context::{SharedContext, SsrSharedContext};
use leptos::{
nonce::use_nonce,
reactive::owner::{Owner, Sandboxed},
reactive_graph::owner::{Owner, Sandboxed},
IntoView,
};
use leptos_config::LeptosOptions;
@@ -40,28 +40,15 @@ pub trait ExtendResponse: Sized {
let (owner, stream) =
build_response(app_fn, additional_context, stream_builder);
let sc = owner.shared_context().unwrap();
let stream = stream.await.ready_chunks(32).map(|n| n.join(""));
let sc = owner.shared_context().unwrap();
while let Some(pending) = sc.await_deferred() {
pending.await;
}
let mut stream = Box::pin(
meta_context.inject_meta_context(stream).await.then({
let sc = Arc::clone(&sc);
move |chunk| {
let sc = Arc::clone(&sc);
async move {
while let Some(pending) = sc.await_deferred() {
pending.await;
}
chunk
}
}
}),
);
let mut stream =
Box::pin(meta_context.inject_meta_context(stream).await);
// wait for the first chunk of the stream, then set the status and headers
let first_chunk = stream.next().await.unwrap_or_default();

View File

@@ -11,7 +11,7 @@ edition.workspace = true
[dependencies]
throw_error = { workspace = true }
any_spawner = { workspace = true, features = ["wasm-bindgen", "futures-executor"] }
any_spawner = { workspace = true, features = ["wasm-bindgen"] }
base64 = { version = "0.22.1", optional = true }
cfg-if = "1.0"
hydration_context = { workspace = true }
@@ -28,11 +28,11 @@ 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", "reactive_stores", "oco"] }
thiserror = "2.0"
tracing = { version = "0.1.41", optional = true }
typed-builder = "0.20.0"
typed-builder-macro = "0.20.0"
tachys = { workspace = true, features = ["reactive_graph", "oco"] }
thiserror = "1.0"
tracing = { version = "0.1.40", optional = true }
typed-builder = "0.19.1"
typed-builder-macro = "0.19.1"
serde = "1.0"
serde_json = { version = "1.0", optional = true }
server_fn = { workspace = true, features = [
@@ -40,15 +40,15 @@ server_fn = { workspace = true, features = [
"browser",
"url",
] }
web-sys = { version = "0.3.72", features = [
web-sys = { version = "0.3.70", features = [
"ShadowRoot",
"ShadowRootInit",
"ShadowRootMode",
] }
wasm-bindgen = "0.2.97"
wasm-bindgen = "0.2.93"
serde_qs = "0.13.0"
slotmap = "1.0"
futures = "0.3.31"
futures = "0.3.30"
send_wrapper = "0.6.0"
[features]
@@ -86,7 +86,7 @@ tracing = [
]
nonce = ["base64", "rand"]
spin = ["leptos-spin-macro"]
islands = ["leptos_macro/islands", "dep:serde_json"]
experimental-islands = ["leptos_macro/experimental-islands", "dep:serde_json"]
trace-component-props = [
"leptos_macro/trace-component-props",
"leptos_dom/trace-component-props"
@@ -104,7 +104,7 @@ denylist = [
"rkyv", # was causing clippy issues on nightly
"trace-component-props",
"spin",
"islands",
"experimental-islands",
]
skip_feature_sets = [
["csr", "ssr"],
@@ -121,6 +121,3 @@ skip_feature_sets = [
[package.metadata.docs.rs]
rustdoc-args = ["--generate-link-to-definition"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(leptos_debuginfo)'] }

View File

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

View File

@@ -1,150 +0,0 @@
use crate::attr::{
any_attribute::{AnyAttribute, IntoAnyAttribute},
Attribute, NextAttribute,
};
use leptos::prelude::*;
/// Function stored to build/rebuild the wrapped children when attributes are added.
type ChildBuilder<T> = dyn Fn(AnyAttribute) -> T + Send + Sync + 'static;
/// Intercepts attributes passed to your component, allowing passing them to any element.
///
/// By default, Leptos passes any attributes passed to your component (e.g. `<MyComponent
/// attr:class="some-class"/>`) to the top-level element in the view returned by your component.
/// [`AttributeInterceptor`] allows you to intercept this behavior and pass it onto any element in
/// your component instead.
///
/// Must be the top level element in your component's view.
///
/// ## Example
///
/// Any attributes passed to MyComponent will be passed to the #inner element.
///
/// ```
/// # use leptos::prelude::*;
/// use leptos::attribute_interceptor::AttributeInterceptor;
///
/// #[component]
/// pub fn MyComponent() -> impl IntoView {
/// view! {
/// <AttributeInterceptor let:attrs>
/// <div id="wrapper">
/// <div id="inner" {..attrs} />
/// </div>
/// </AttributeInterceptor>
/// }
/// }
/// ```
#[component]
pub fn AttributeInterceptor<Chil, T>(
/// The elements that will be rendered, with the attributes this component received as a
/// parameter.
children: Chil,
) -> impl IntoView
where
Chil: Fn(AnyAttribute) -> T + Send + Sync + 'static,
T: IntoView,
{
AttributeInterceptorInner::new(children)
}
/// Wrapper to intercept attributes passed to a component so you can apply them to a different
/// element.
struct AttributeInterceptorInner<T: IntoView, A> {
children_builder: Box<ChildBuilder<T>>,
children: T,
attributes: A,
}
impl<T: IntoView> AttributeInterceptorInner<T, ()> {
/// Use this as the returned view from your component to collect the attributes that are passed
/// to your component so you can manually handle them.
pub fn new<F>(children: F) -> Self
where
F: Fn(AnyAttribute) -> T + Send + Sync + 'static,
{
let children_builder = Box::new(children);
let children = children_builder(().into_any_attr());
Self {
children_builder,
children,
attributes: (),
}
}
}
impl<T: IntoView, A: Attribute> Render for AttributeInterceptorInner<T, A> {
type State = <T as Render>::State;
fn build(self) -> Self::State {
self.children.build()
}
fn rebuild(self, state: &mut Self::State) {
self.children.rebuild(state);
}
}
impl<T: IntoView, A> AddAnyAttr for AttributeInterceptorInner<T, A>
where
A: Attribute,
{
type Output<SomeNewAttr: leptos::attr::Attribute> =
AttributeInterceptorInner<T, <<A as NextAttribute>::Output<SomeNewAttr> as Attribute>::CloneableOwned>;
fn add_any_attr<NewAttr: leptos::attr::Attribute>(
self,
attr: NewAttr,
) -> Self::Output<NewAttr>
where
Self::Output<NewAttr>: RenderHtml,
{
let attributes =
self.attributes.add_any_attr(attr).into_cloneable_owned();
let children =
(self.children_builder)(attributes.clone().into_any_attr());
AttributeInterceptorInner {
children_builder: self.children_builder,
children,
attributes,
}
}
}
impl<T: IntoView, A: Attribute> RenderHtml for AttributeInterceptorInner<T, A> {
type AsyncOutput = T::AsyncOutput;
const MIN_LENGTH: usize = T::MIN_LENGTH;
fn dry_resolve(&mut self) {
self.children.dry_resolve()
}
fn resolve(
self,
) -> impl std::future::Future<Output = Self::AsyncOutput> + Send {
self.children.resolve()
}
fn to_html_with_buf(
self,
buf: &mut String,
position: &mut leptos::tachys::view::Position,
escape: bool,
mark_branches: bool,
) {
self.children
.to_html_with_buf(buf, position, escape, mark_branches)
}
fn hydrate<const FROM_SERVER: bool>(
self,
cursor: &leptos::tachys::hydration::Cursor,
position: &leptos::tachys::view::PositionState,
) -> Self::State {
self.children.hydrate::<FROM_SERVER>(cursor, position)
}
}

View File

@@ -1,8 +1,10 @@
use crate::{prelude::Suspend, suspense_component::Suspense, IntoView};
use crate::Suspense;
use leptos_dom::IntoView;
use leptos_macro::{component, view};
use leptos_server::ArcOnceResource;
use reactive_graph::prelude::ReadUntracked;
use serde::{de::DeserializeOwned, Serialize};
use leptos_reactive::{
create_blocking_resource, create_local_resource, create_resource,
store_value, Serializable,
};
#[component]
/// Allows you to inline the data loading for an `async` block or
@@ -13,8 +15,11 @@ use serde::{de::DeserializeOwned, Serialize};
/// Adding `let:{variable name}` to the props makes the data available in the children
/// that variable name, when resolved.
/// ```
/// # use leptos::prelude::*;
/// # use leptos_reactive::*;
/// # use leptos_macro::*;
/// # use leptos_dom::*; use leptos::*;
/// # if false {
/// # let runtime = create_runtime();
/// async fn fetch_monkeys(monkey: i32) -> i32 {
/// // do some expensive work
/// 3
@@ -22,23 +27,29 @@ use serde::{de::DeserializeOwned, Serialize};
///
/// view! {
/// <Await
/// future=fetch_monkeys(3)
/// future=|| fetch_monkeys(3)
/// let:data
/// >
/// <p>{*data} " little monkeys, jumping on the bed."</p>
/// </Await>
/// }
/// # ;
/// # runtime.dispose();
/// # }
/// ```
pub fn Await<T, Fut, Chil, V>(
/// A [`Future`](std::future::Future) that will the component will `.await`
/// before rendering.
future: Fut,
/// If `true`, the component will create a blocking resource, preventing
pub fn Await<T, Fut, FF, VF, V>(
/// A function that returns the [`Future`](std::future::Future) that
/// will the component will `.await` before rendering.
future: FF,
/// If `true`, the component will use [`create_blocking_resource`], preventing
/// the HTML stream from returning anything before `future` has resolved.
#[prop(optional)]
blocking: bool,
/// If `true`, the component will use [`create_local_resource`], this will
/// always run on the local system and therefore its result type does not
/// need to be `Serializable`.
#[prop(optional)]
local: bool,
/// A function that takes a reference to the resolved data from the `future`
/// renders a view.
///
@@ -47,58 +58,65 @@ pub fn Await<T, Fut, Chil, V>(
/// `let:` syntax to specify the name for the data variable.
///
/// ```rust
/// # use leptos::prelude::*;
/// # use leptos::*;
/// # if false {
/// # let runtime = create_runtime();
/// # async fn fetch_monkeys(monkey: i32) -> i32 {
/// # 3
/// # }
/// view! {
/// <Await
/// future=fetch_monkeys(3)
/// future=|| fetch_monkeys(3)
/// let:data
/// >
/// <p>{*data} " little monkeys, jumping on the bed."</p>
/// </Await>
/// }
/// # ;
/// # runtime.dispose();
/// # }
/// ```
/// is the same as
/// ```rust
/// # use leptos::prelude::*;
/// # use leptos::*;
/// # if false {
/// # let runtime = create_runtime();
/// # async fn fetch_monkeys(monkey: i32) -> i32 {
/// # 3
/// # }
/// view! {
/// <Await
/// future=fetch_monkeys(3)
/// future=|| fetch_monkeys(3)
/// children=|data| view! {
/// <p>{*data} " little monkeys, jumping on the bed."</p>
/// }
/// />
/// }
/// # ;
/// # runtime.dispose();
/// # }
/// ```
children: Chil,
children: VF,
) -> impl IntoView
where
T: Send + Sync + Serialize + DeserializeOwned + 'static,
Fut: std::future::Future<Output = T> + Send + 'static,
Chil: FnOnce(&T) -> V + Send + 'static,
V: IntoView + 'static,
Fut: std::future::Future<Output = T> + 'static,
FF: Fn() -> Fut + 'static,
V: IntoView,
VF: Fn(&T) -> V + 'static,
T: Serializable + 'static,
{
let res = ArcOnceResource::<T>::new_with_options(future, blocking);
let ready = res.ready();
let res = if blocking {
create_blocking_resource(|| (), move |_| future())
} else if local {
create_local_resource(|| (), move |_| future())
} else {
create_resource(|| (), move |_| future())
};
let view = store_value(children);
view! {
<Suspense fallback=|| ()>
{Suspend::new(async move {
ready.await;
children(res.read_untracked().as_ref().unwrap())
})}
{move || res.map(|data| view.with_value(|view| view(data)))}
</Suspense>
}
}

View File

@@ -31,20 +31,17 @@
//! *Notes*:
//! - The `render_number` prop can receive any type that implements `Fn(i32) -> String`.
//! - Callbacks are most useful when you want optional generic props.
//! - All callbacks implement the [`Callable`](leptos::callback::Callable) trait, and can be invoked with `my_callback.run(input)`.
//! - All callbacks implement the [`Callable`] trait, and can be invoked with `my_callback.run(input)`.
//! - The callback types implement [`Copy`], so they can easily be moved into and out of other closures, just like signals.
//!
//! # Types
//! This modules implements 2 callback types:
//! - [`Callback`](leptos::callback::Callback)
//! - [`UnsyncCallback`](leptos::callback::UnsyncCallback)
//! - [`Callback`]
//! - [`UnsyncCallback`]
//!
//! Use `SyncCallback` if the function is not `Sync` and `Send`.
use reactive_graph::{
owner::{LocalStorage, StoredValue},
traits::WithValue,
};
use reactive_graph::owner::{LocalStorage, StoredValue};
use std::{fmt, rc::Rc, sync::Arc};
/// A wrapper trait for calling callbacks.
@@ -223,14 +220,14 @@ mod tests {
#[test]
fn clone_callback() {
let callback = Callback::new(move |_no_clone: NoClone| NoClone {});
let _cloned = callback;
let _cloned = callback.clone();
}
#[test]
fn clone_unsync_callback() {
let callback =
UnsyncCallback::new(move |_no_clone: NoClone| NoClone {});
let _cloned = callback;
let _cloned = callback.clone();
}
#[test]

View File

@@ -85,7 +85,7 @@ type BoxedChildrenFn = Box<dyn Fn() -> AnyView + Send>;
/// )
/// }
pub trait ToChildren<F> {
/// Convert the provided type (generally a closure) to Self (generally a "children" type,
/// Convert the provided type to (generally a closure) to Self (generally a "children" type,
/// e.g., [Children]). See the implementations to see exactly which input types are supported
/// and which "children" type they are converted to.
fn to_children(f: F) -> Self;
@@ -228,7 +228,6 @@ impl ViewFnOnce {
pub struct TypedChildren<T>(Box<dyn FnOnce() -> View<T> + Send>);
impl<T> TypedChildren<T> {
/// Extracts the inner `children` function.
pub fn into_inner(self) -> impl FnOnce() -> View<T> + Send {
self.0
}
@@ -246,7 +245,7 @@ where
}
}
/// A typed equivalent to [`ChildrenFnMut`], which takes a generic but preserves type information to
/// A typed equivalent to [`ChildrenMut`], which takes a generic but preserves type information to
/// allow the compiler to optimize the view more effectively.
pub struct TypedChildrenMut<T>(Box<dyn FnMut() -> View<T> + Send>);
@@ -257,7 +256,6 @@ impl<T> Debug for TypedChildrenMut<T> {
}
impl<T> TypedChildrenMut<T> {
/// Extracts the inner `children` function.
pub fn into_inner(self) -> impl FnMut() -> View<T> + Send {
self.0
}
@@ -285,15 +283,7 @@ impl<T> Debug for TypedChildrenFn<T> {
}
}
impl<T> Clone for TypedChildrenFn<T> {
// Manual implementation to avoid the `T: Clone` bound.
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<T> TypedChildrenFn<T> {
/// Extracts the inner `children` function.
pub fn into_inner(self) -> Arc<dyn Fn() -> View<T> + Send + Sync> {
self.0
}

View File

@@ -72,7 +72,9 @@ use web_sys::{
#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip_all))]
#[component]
pub fn ActionForm<ServFn>(
/// The action from which to build the form.
/// The action from which to build the form. This should include a URL, which can be generated
/// by default using [`create_server_action`](leptos_server::create_server_action) or added
/// manually using [`using_server_fn`](leptos_server::Action::using_server_fn).
action: ServerAction<ServFn>,
/// A [`NodeRef`] in which the `<form>` element should be stored.
#[prop(optional)]
@@ -147,7 +149,9 @@ where
/// progressively enhanced to use client-side routing.
#[component]
pub fn MultiActionForm<ServFn>(
/// The action from which to build the form.
/// The action from which to build the form. This should include a URL, which can be generated
/// by default using [create_server_action](leptos_server::create_server_action) or added
/// manually using [leptos_server::Action::using_server_fn].
action: ServerMultiAction<ServFn>,
/// A [`NodeRef`] in which the `<form>` element should be stored.
#[prop(optional)]
@@ -247,16 +251,12 @@ where
) -> Result<Self, serde_qs::Error>;
}
/// Errors that can arise when coverting from an HTML event or form into a Rust data type.
#[derive(Error, Debug)]
pub enum FromFormDataError {
/// Could not find a `<form>` connected to the event.
#[error("Could not find <form> connected to event.")]
MissingForm(Event),
/// Could not create `FormData` from the form.
#[error("Could not create FormData from <form>: {0:?}")]
FormData(JsValue),
/// Failed to deserialize this Rust type from the form data.
#[error("Deserialization error: {0:?}")]
Deserialization(serde_qs::Error),
}

View File

@@ -1,7 +1,7 @@
(function (root, pkg_path, output_name, wasm_output_name) {
import(`${root}/${pkg_path}/${output_name}.js`)
.then(mod => {
mod.default({module_or_path: `${root}/${pkg_path}/${wasm_output_name}.wasm`}).then(() => {
mod.default(`${root}/${pkg_path}/${wasm_output_name}.wasm`).then(() => {
mod.hydrate();
});
})

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