Compare commits

..

9 Commits

Author SHA1 Message Date
Greg Johnston
3782a8ad70 chore: update for non-generic rendering 2024-09-30 20:32:24 -04:00
Greg Johnston
cb11ce0a5f feat: use new bind: syntax 2024-09-30 20:32:09 -04:00
Greg Johnston
37c794b283 chore: move Group into the reactive module, as that's the only place it has meaning 2024-09-30 20:20:41 -04:00
Greg Johnston
f5ff45863d chore: use ::leptos absolute path in macro 2024-09-30 20:15:48 -04:00
Greg Johnston
ca6f934039 Merge remote-tracking branch 'origin' into two-way-data-binding 2024-09-30 20:10:43 -04:00
Maccesch
6ea942f3c5 Merge branch 'main' into two-way-data-binding
# Conflicts:
#	leptos_macro/src/view/component_builder.rs
#	leptos_macro/src/view/slot_helper.rs
2024-09-20 12:43:23 +02:00
Maccesch
3a079ace46 moved bind into reactive_graph mod 2024-09-16 05:13:32 +02:00
Maccesch
dbf654aa86 added two-way data binding to radio groups 2024-09-16 04:47:14 +02:00
Maccesch
d16231aa3a added two-way data binding to dom elements 2024-09-15 20:50:17 +02:00
421 changed files with 9795 additions and 38006 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,54 +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
DEBIAN_FRONTEND: noninteractive
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 Glib
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev
- 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

@@ -4,12 +4,10 @@ on:
branches:
- main
- leptos_0.6
- leptos_0.8
pull_request:
branches:
- main
- leptos_0.6
- leptos_0.8
jobs:
get-example-changed:
uses: ./.github/workflows/get-example-changed.yml
@@ -28,6 +26,5 @@ jobs:
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
erased_mode: ${{ matrix.erased_mode }}
cargo_make_task: "ci"
toolchain: stable

View File

@@ -4,12 +4,10 @@ on:
branches:
- main
- leptos_0.6
- leptos_0.8
pull_request:
branches:
- main
- leptos_0.6
- leptos_0.8
jobs:
get-leptos-changed:
uses: ./.github/workflows/get-leptos-changed.yml
@@ -25,6 +23,5 @@ jobs:
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
erased_mode: ${{ matrix.erased_mode }}
cargo_make_task: "ci"
toolchain: stable

View File

@@ -4,30 +4,22 @@ on:
branches:
- main
- leptos_0.6
- leptos_0.8
pull_request:
branches:
- main
- leptos_0.6
- leptos_0.8
env:
DEBIAN_FRONTEND: noninteractive
jobs:
get-leptos-changed:
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'
name: Run semver check (nightly-2025-03-05)
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:
- name: Install Glib
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev
- name: Checkout
uses: actions/checkout@v4
- name: Semver Checks
uses: obi1kenobi/cargo-semver-checks-action@v2
with:
rust-toolchain: nightly-2025-03-05
rust-toolchain: nightly-2024-08-01

View File

@@ -4,12 +4,10 @@ on:
branches:
- main
- leptos_0.6
- leptos_0.8
pull_request:
branches:
- main
- leptos_0.6
- leptos_0.8
jobs:
get-leptos-changed:
uses: ./.github/workflows/get-leptos-changed.yml
@@ -25,6 +23,5 @@ jobs:
uses: ./.github/workflows/run-cargo-make-task.yml
with:
directory: ${{ matrix.directory }}
erased_mode: ${{ matrix.erased_mode }}
cargo_make_task: "ci"
toolchain: nightly-2025-03-05
toolchain: nightly-2024-08-01

View File

@@ -50,5 +50,5 @@ jobs:
echo "matrix={\"directory\":${{ steps.changed-dirs.outputs.all_changed_files }}}" >> "$GITHUB_OUTPUT"
else
# Create matrix with one item to prevent an empty vector error
echo "matrix={\"directory\":[\"NO_CHANGE\"], \"erased_mode\": [false, true]}" >> "$GITHUB_OUTPUT"
echo "matrix={\"directory\":[\"NO_CHANGE\"]}" >> "$GITHUB_OUTPUT"
fi

View File

@@ -28,7 +28,7 @@ jobs:
sed 's/\/$//' |
jq -R -s -c 'split("\n")[:-1]')
echo "Example Directories: $examples"
echo "matrix={\"directory\":$examples, \"erased_mode\": [false, true]}" >> "$GITHUB_OUTPUT"
echo "matrix={\"directory\":$examples}" >> "$GITHUB_OUTPUT"
- name: Print Location Info
run: |
echo "Workspace: ${{ github.workspace }}"

View File

@@ -24,7 +24,7 @@ jobs:
sed "s|$(pwd)/||" |
jq -R -s -c 'split("\n")[:-1]')
echo "Leptos Directories: $crates"
echo "matrix={\"directory\":$crates, \"erased_mode\": [false, true]}" >> "$GITHUB_OUTPUT"
echo "matrix={\"directory\":$crates}" >> "$GITHUB_OUTPUT"
- name: Print Location Info
run: |
echo "Workspace: ${{ github.workspace }}"

View File

@@ -5,9 +5,6 @@ on:
directory:
required: true
type: string
erased_mode:
required: true
type: boolean
cargo_make_task:
required: true
type: string
@@ -17,31 +14,12 @@ on:
env:
CARGO_TERM_COLOR: always
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
DEBIAN_FRONTEND: noninteractive
RUSTFLAGS: ${{ inputs.erased_mode && '--cfg erase_components' || '' }}
jobs:
test:
name: "Run ${{ inputs.cargo_make_task }} (${{ inputs.toolchain }}) (erased_mode: ${{ inputs.erased_mode }})"
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
- name: Install Glib
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@master
@@ -59,7 +37,7 @@ jobs:
- name: Install wasm-bindgen
run: cargo binstall wasm-bindgen-cli --no-confirm
- name: Install cargo-leptos
run: cargo binstall cargo-leptos --locked --no-confirm
run: cargo binstall cargo-leptos --no-confirm
- name: Install Trunk
uses: jetli/trunk-action@v0.5.0
with:
@@ -116,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

4563
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,39 +40,36 @@ members = [
exclude = ["benchmarks", "examples", "projects"]
[workspace.package]
version = "0.8.0-beta"
version = "0.7.0-gamma"
edition = "2021"
rust-version = "1.76"
[workspace.dependencies]
throw_error = { path = "./any_error/", version = "0.3.0" }
any_spawner = { path = "./any_spawner/", version = "0.2.0" }
const_str_slice_concat = { path = "./const_str_slice_concat", version = "0.1" }
either_of = { path = "./either_of/", version = "0.1.5" }
hydration_context = { path = "./hydration_context", version = "0.3.0" }
itertools = "0.14.0"
leptos = { path = "./leptos", version = "0.8.0-beta" }
leptos_config = { path = "./leptos_config", version = "0.8.0-beta" }
leptos_dom = { path = "./leptos_dom", version = "0.8.0-beta" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.8.0-beta" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.8.0-beta" }
leptos_macro = { path = "./leptos_macro", version = "0.8.0-beta" }
leptos_router = { path = "./router", version = "0.8.0-beta" }
leptos_router_macro = { path = "./router_macro", version = "0.8.0-beta" }
leptos_server = { path = "./leptos_server", version = "0.8.0-beta" }
leptos_meta = { path = "./meta", version = "0.8.0-beta" }
next_tuple = { path = "./next_tuple", version = "0.1.0" }
throw_error = { path = "./any_error/", version = "0.2.0-gamma" }
any_spawner = { path = "./any_spawner/", version = "0.1.0" }
const_str_slice_concat = { path = "./const_str_slice_concat", version = "0.1.0" }
either_of = { path = "./either_of/", version = "0.1.0" }
hydration_context = { path = "./hydration_context", version = "0.2.0-gamma" }
leptos = { path = "./leptos", version = "0.7.0-gamma" }
leptos_config = { path = "./leptos_config", version = "0.7.0-gamma" }
leptos_dom = { path = "./leptos_dom", version = "0.7.0-gamma" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.0-gamma" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.0-gamma" }
leptos_macro = { path = "./leptos_macro", version = "0.7.0-gamma" }
leptos_router = { path = "./router", version = "0.7.0-gamma" }
leptos_router_macro = { path = "./router_macro", version = "0.7.0-gamma" }
leptos_server = { path = "./leptos_server", version = "0.7.0-gamma" }
leptos_meta = { path = "./meta", version = "0.7.0-gamma" }
next_tuple = { path = "./next_tuple", version = "0.1.0-gamma" }
oco_ref = { path = "./oco", version = "0.2.0" }
or_poisoned = { path = "./or_poisoned", version = "0.1.0" }
reactive_graph = { path = "./reactive_graph", version = "0.2.0-beta" }
reactive_stores = { path = "./reactive_stores", version = "0.2.0-beta" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.2.0-beta" }
serde_json = "1.0.0"
server_fn = { path = "./server_fn", version = "0.8.0-beta" }
server_fn_macro = { path = "./server_fn_macro", version = "0.8.0-beta" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.8.0-beta" }
tachys = { path = "./tachys", version = "0.2.0-beta" }
wasm-bindgen = { version = "0.2.100" }
reactive_graph = { path = "./reactive_graph", version = "0.1.0-gamma" }
reactive_stores = { path = "./reactive_stores", version = "0.1.0-gamma" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0-gamma" }
server_fn = { path = "./server_fn", version = "0.7.0-gamma" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.0-gamma" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.0-gamma" }
tachys = { path = "./tachys", version = "0.1.0-gamma" }
[profile.release]
codegen-units = 1
@@ -81,9 +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

@@ -5,7 +5,6 @@
[![crates.io](https://img.shields.io/crates/v/leptos.svg)](https://crates.io/crates/leptos)
[![docs.rs](https://docs.rs/leptos/badge.svg)](https://docs.rs/leptos)
![Crates.io MSRV](https://img.shields.io/crates/msrv/leptos)
[![Discord](https://img.shields.io/discord/1031524867910148188?color=%237289DA&label=discord)](https://discord.gg/YdRAhS7eQB)
[![Matrix](https://img.shields.io/badge/Matrix-leptos-grey?logo=matrix&labelColor=white&logoColor=black)](https://matrix.to/#/#leptos:matrix.org)
@@ -13,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
@@ -21,7 +22,7 @@ use leptos::*;
#[component]
pub fn SimpleCounter(initial_value: i32) -> impl IntoView {
// create a reactive signal with the initial value
let (value, set_value) = signal(initial_value);
let (value, set_value) = create_signal(initial_value);
// create event handlers for our buttons
// note that `value` and `set_value` are `Copy`, so it's super easy to move them into closures
@@ -46,7 +47,7 @@ pub fn SimpleCounter(initial_value: i32) -> impl IntoView {
pub fn SimpleCounterWithBuilder(initial_value: i32) -> impl IntoView {
use leptos::html::*;
let (value, set_value) = signal(initial_value);
let (value, set_value) = create_signal(initial_value);
let clear = move |_| set_value(0);
let decrement = move |_| set_value.update(|value| *value -= 1);
let increment = move |_| set_value.update(|value| *value += 1);
@@ -158,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?
@@ -168,14 +171,14 @@ Yew is the most-used library for Rust web UI development, but there are several
- **Performance:** This has huge performance implications: Leptos is simply much faster at both creating and updating the UI than Yew is.
- **Server integration:** Yew was created in an era in which browser-rendered single-page apps (SPAs) were the dominant paradigm. While Leptos supports client-side rendering, it also focuses on integrating with the server side of your application via server functions and multiple modes of serving HTML, including out-of-order streaming.
### How is this different from Dioxus?
- ### How is this different from Dioxus?
Like Leptos, Dioxus is a framework for building UIs using web technologies. However, there are significant differences in approach and features.
- **VDOM vs. fine-grained:** While Dioxus has a performant virtual DOM (VDOM), it still uses coarse-grained/component-scoped reactivity: changing a stateful value reruns the component function and diffs the old UI against the new one. Leptos components use a different mental model, creating (and returning) actual DOM nodes and setting up a reactive system to update those DOM nodes.
- **Web vs. desktop priorities:** Dioxus uses Leptos server functions in its fullstack mode, but does not have the same `<Suspense>`-based support for things like streaming HTML rendering, or share the same focus on holistic web performance. Leptos tends to prioritize holistic web performance (streaming HTML rendering, smaller WASM binary sizes, etc.), whereas Dioxus has an unparalleled experience when building desktop apps, because your application logic runs as a native Rust binary.
### How is this different from Sycamore?
- ### How is this different from Sycamore?
Sycamore and Leptos are both heavily influenced by SolidJS. At this point, Leptos has a larger community and ecosystem and is more actively developed. Other differences:

View File

@@ -1,6 +1,6 @@
[package]
name = "throw_error"
version = "0.3.0"
version = "0.2.0-gamma"
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

@@ -9,7 +9,7 @@ use std::{
error,
fmt::{self, Display},
future::Future,
ops,
mem, ops,
pin::Pin,
sync::Arc,
task::{Context, Poll},
@@ -17,6 +17,11 @@ use std::{
/* Wrapper Types */
/// This is a result type into which any error can be converted.
///
/// Results are stored as [`Error`].
pub type Result<T, E = Error> = core::result::Result<T, E>;
/// A generic wrapper for any error.
#[derive(Debug, Clone)]
#[repr(transparent)]
@@ -104,7 +109,7 @@ pub fn get_error_hook() -> Option<Arc<dyn ErrorHook>> {
/// Sets the current thread-local error hook, which will be invoked when [`throw`] is called.
pub fn set_error_hook(hook: Arc<dyn ErrorHook>) -> ResetErrorHookOnDrop {
ResetErrorHookOnDrop(
ERROR_HOOK.with_borrow_mut(|this| Option::replace(this, hook)),
ERROR_HOOK.with_borrow_mut(|this| mem::replace(this, Some(hook))),
)
}

View File

@@ -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.50", 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

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

View File

@@ -1,6 +1,6 @@
[package]
name = "either_of"
version = "0.1.5"
version = "0.1.0"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
@@ -10,9 +10,4 @@ rust-version.workspace = true
edition.workspace = true
[dependencies]
pin-project-lite = "0.2.16"
paste = "1.0.15"
[features]
default = ["no_std"]
no_std = []
pin-project-lite = "0.2.14"

View File

@@ -1,758 +1,140 @@
#![cfg_attr(feature = "no_std", no_std)]
#![no_std]
#![forbid(unsafe_code)]
//! Utilities for working with enumerated types that contain one of `2..n` other types.
use core::{
cmp::Ordering,
fmt::Display,
future::Future,
iter::{Product, Sum},
pin::Pin,
task::{Context, Poll},
};
use paste::paste;
use pin_project_lite::pin_project;
#[cfg(not(feature = "no_std"))]
use std::error::Error; // TODO: replace with core::error::Error once MSRV is >= 1.81.0
#[derive(Debug, Clone, Copy)]
pub enum Either<A, B> {
Left(A),
Right(B),
}
impl<Item, A, B> Iterator for Either<A, B>
where
A: Iterator<Item = Item>,
B: Iterator<Item = Item>,
{
type Item = Item;
fn next(&mut self) -> Option<Self::Item> {
match self {
Either::Left(i) => i.next(),
Either::Right(i) => i.next(),
}
}
}
pin_project! {
#[project = EitherFutureProj]
pub enum EitherFuture<A, B> {
Left { #[pin] inner: A },
Right { #[pin] inner: B },
}
}
impl<A, B> Future for EitherFuture<A, B>
where
A: Future,
B: Future,
{
type Output = Either<A::Output, B::Output>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
match this {
EitherFutureProj::Left { inner } => match inner.poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(inner) => Poll::Ready(Either::Left(inner)),
},
EitherFutureProj::Right { inner } => match inner.poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(inner) => Poll::Ready(Either::Right(inner)),
},
}
}
}
macro_rules! tuples {
($name:ident + $fut_name:ident + $fut_proj:ident {
$($ty:ident => ($($rest_variant:ident),*) + <$($mapped_ty:ident),+>),+$(,)?
}) => {
tuples!($name + $fut_name + $fut_proj {
$($ty($ty) => ($($rest_variant),*) + <$($mapped_ty),+>),+
});
};
($name:ident + $fut_name:ident + $fut_proj:ident {
$($variant:ident($ty:ident) => ($($rest_variant:ident),*) + <$($mapped_ty:ident),+>),+$(,)?
}) => {
($name:ident + $fut_name:ident + $fut_proj:ident => $($ty:ident),*) => {
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
pub enum $name<$($ty),+> {
$($variant ($ty),)+
pub enum $name<$($ty,)*> {
$($ty ($ty),)*
}
impl<$($ty),+> $name<$($ty),+> {
paste! {
#[allow(clippy::too_many_arguments)]
pub fn map<$([<F $ty>]),+, $([<$ty 1>]),+>(self, $([<$variant:lower>]: [<F $ty>]),+) -> $name<$([<$ty 1>]),+>
where
$([<F $ty>]: FnOnce($ty) -> [<$ty 1>],)+
{
match self {
$($name::$variant(inner) => $name::$variant([<$variant:lower>](inner)),)+
}
}
$(
pub fn [<map_ $variant:lower>]<Fun, [<$ty 1>]>(self, f: Fun) -> $name<$($mapped_ty),+>
where
Fun: FnOnce($ty) -> [<$ty 1>],
{
match self {
$name::$variant(inner) => $name::$variant(f(inner)),
$($name::$rest_variant(inner) => $name::$rest_variant(inner),)*
}
}
pub fn [<inspect_ $variant:lower>]<Fun, [<$ty 1>]>(self, f: Fun) -> Self
where
Fun: FnOnce(&$ty),
{
if let $name::$variant(inner) = &self {
f(inner);
}
self
}
pub fn [<is_ $variant:lower>](&self) -> bool {
matches!(self, $name::$variant(_))
}
pub fn [<as_ $variant:lower>](&self) -> Option<&$ty> {
match self {
$name::$variant(inner) => Some(inner),
_ => None,
}
}
pub fn [<as_ $variant:lower _mut>](&mut self) -> Option<&mut $ty> {
match self {
$name::$variant(inner) => Some(inner),
_ => None,
}
}
pub fn [<unwrap_ $variant:lower>](self) -> $ty {
match self {
$name::$variant(inner) => inner,
_ => panic!(concat!(
"called `unwrap_", stringify!([<$variant:lower>]), "()` on a non-`", stringify!($variant), "` variant of `", stringify!($name), "`"
)),
}
}
pub fn [<into_ $variant:lower>](self) -> Result<$ty, Self> {
match self {
$name::$variant(inner) => Ok(inner),
_ => Err(self),
}
}
)+
}
}
impl<$($ty),+> Display for $name<$($ty),+>
impl<$($ty,)*> Display for $name<$($ty,)*>
where
$($ty: Display,)+
$($ty: Display,)*
{
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
$($name::$variant(this) => this.fmt(f),)+
$($name::$ty(this) => this.fmt(f),)*
}
}
}
#[cfg(not(feature = "no_std"))]
impl<$($ty),+> Error for $name<$($ty),+>
impl<Item, $($ty,)*> Iterator for $name<$($ty,)*>
where
$($ty: Error,)+
{
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
$($name::$variant(this) => this.source(),)+
}
}
}
impl<Item, $($ty),+> Iterator for $name<$($ty),+>
where
$($ty: Iterator<Item = Item>,)+
$($ty: Iterator<Item = Item>,)*
{
type Item = Item;
fn next(&mut self) -> Option<Self::Item> {
match self {
$($name::$variant(i) => i.next(),)+
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
match self {
$($name::$variant(i) => i.size_hint(),)+
}
}
fn count(self) -> usize
where
Self: Sized,
{
match self {
$($name::$variant(i) => i.count(),)+
}
}
fn last(self) -> Option<Self::Item>
where
Self: Sized,
{
match self {
$($name::$variant(i) => i.last(),)+
}
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
match self {
$($name::$variant(i) => i.nth(n),)+
}
}
fn for_each<Fun>(self, f: Fun)
where
Self: Sized,
Fun: FnMut(Self::Item),
{
match self {
$($name::$variant(i) => i.for_each(f),)+
}
}
fn collect<Col: FromIterator<Self::Item>>(self) -> Col
where
Self: Sized,
{
match self {
$($name::$variant(i) => i.collect(),)+
}
}
fn partition<Col, Fun>(self, f: Fun) -> (Col, Col)
where
Self: Sized,
Col: Default + Extend<Self::Item>,
Fun: FnMut(&Self::Item) -> bool,
{
match self {
$($name::$variant(i) => i.partition(f),)+
}
}
fn fold<Acc, Fun>(self, init: Acc, f: Fun) -> Acc
where
Self: Sized,
Fun: FnMut(Acc, Self::Item) -> Acc,
{
match self {
$($name::$variant(i) => i.fold(init, f),)+
}
}
fn reduce<Fun>(self, f: Fun) -> Option<Self::Item>
where
Self: Sized,
Fun: FnMut(Self::Item, Self::Item) -> Self::Item,
{
match self {
$($name::$variant(i) => i.reduce(f),)+
}
}
fn all<Fun>(&mut self, f: Fun) -> bool
where
Self: Sized,
Fun: FnMut(Self::Item) -> bool,
{
match self {
$($name::$variant(i) => i.all(f),)+
}
}
fn any<Fun>(&mut self, f: Fun) -> bool
where
Self: Sized,
Fun: FnMut(Self::Item) -> bool,
{
match self {
$($name::$variant(i) => i.any(f),)+
}
}
fn find<Pre>(&mut self, predicate: Pre) -> Option<Self::Item>
where
Self: Sized,
Pre: FnMut(&Self::Item) -> bool,
{
match self {
$($name::$variant(i) => i.find(predicate),)+
}
}
fn find_map<Out, Fun>(&mut self, f: Fun) -> Option<Out>
where
Self: Sized,
Fun: FnMut(Self::Item) -> Option<Out>,
{
match self {
$($name::$variant(i) => i.find_map(f),)+
}
}
fn position<Pre>(&mut self, predicate: Pre) -> Option<usize>
where
Self: Sized,
Pre: FnMut(Self::Item) -> bool,
{
match self {
$($name::$variant(i) => i.position(predicate),)+
}
}
fn max(self) -> Option<Self::Item>
where
Self: Sized,
Self::Item: Ord,
{
match self {
$($name::$variant(i) => i.max(),)+
}
}
fn min(self) -> Option<Self::Item>
where
Self: Sized,
Self::Item: Ord,
{
match self {
$($name::$variant(i) => i.min(),)+
}
}
fn max_by_key<Key: Ord, Fun>(self, f: Fun) -> Option<Self::Item>
where
Self: Sized,
Fun: FnMut(&Self::Item) -> Key,
{
match self {
$($name::$variant(i) => i.max_by_key(f),)+
}
}
fn max_by<Cmp>(self, compare: Cmp) -> Option<Self::Item>
where
Self: Sized,
Cmp: FnMut(&Self::Item, &Self::Item) -> Ordering,
{
match self {
$($name::$variant(i) => i.max_by(compare),)+
}
}
fn min_by_key<Key: Ord, Fun>(self, f: Fun) -> Option<Self::Item>
where
Self: Sized,
Fun: FnMut(&Self::Item) -> Key,
{
match self {
$($name::$variant(i) => i.min_by_key(f),)+
}
}
fn min_by<Cmp>(self, compare: Cmp) -> Option<Self::Item>
where
Self: Sized,
Cmp: FnMut(&Self::Item, &Self::Item) -> Ordering,
{
match self {
$($name::$variant(i) => i.min_by(compare),)+
}
}
fn sum<Out>(self) -> Out
where
Self: Sized,
Out: Sum<Self::Item>,
{
match self {
$($name::$variant(i) => i.sum(),)+
}
}
fn product<Out>(self) -> Out
where
Self: Sized,
Out: Product<Self::Item>,
{
match self {
$($name::$variant(i) => i.product(),)+
}
}
fn cmp<Other>(self, other: Other) -> Ordering
where
Other: IntoIterator<Item = Self::Item>,
Self::Item: Ord,
Self: Sized,
{
match self {
$($name::$variant(i) => i.cmp(other),)+
}
}
fn partial_cmp<Other>(self, other: Other) -> Option<Ordering>
where
Other: IntoIterator,
Self::Item: PartialOrd<Other::Item>,
Self: Sized,
{
match self {
$($name::$variant(i) => i.partial_cmp(other),)+
}
}
// TODO: uncomment once MSRV is >= 1.82.0
// fn is_sorted(self) -> bool
// where
// Self: Sized,
// Self::Item: PartialOrd,
// {
// match self {
// $($name::$variant(i) => i.is_sorted(),)+
// }
// }
//
// fn is_sorted_by<Cmp>(self, compare: Cmp) -> bool
// where
// Self: Sized,
// Cmp: FnMut(&Self::Item, &Self::Item) -> bool,
// {
// match self {
// $($name::$variant(i) => i.is_sorted_by(compare),)+
// }
// }
//
// fn is_sorted_by_key<Fun, Key>(self, f: Fun) -> bool
// where
// Self: Sized,
// Fun: FnMut(Self::Item) -> Key,
// Key: PartialOrd,
// {
// match self {
// $($name::$variant(i) => i.is_sorted_by_key(f),)+
// }
// }
}
impl<Item, $($ty),+> ExactSizeIterator for $name<$($ty),+>
where
$($ty: ExactSizeIterator<Item = Item>,)+
{
fn len(&self) -> usize {
match self {
$($name::$variant(i) => i.len(),)+
}
}
}
impl<Item, $($ty),+> DoubleEndedIterator for $name<$($ty),+>
where
$($ty: DoubleEndedIterator<Item = Item>,)+
{
fn next_back(&mut self) -> Option<Self::Item> {
match self {
$($name::$variant(i) => i.next_back(),)+
}
}
fn nth_back(&mut self, n: usize) -> Option<Self::Item> {
match self {
$($name::$variant(i) => i.nth_back(n),)+
}
}
fn rfind<Pre>(&mut self, predicate: Pre) -> Option<Self::Item>
where
Pre: FnMut(&Self::Item) -> bool,
{
match self {
$($name::$variant(i) => i.rfind(predicate),)+
$($name::$ty(i) => i.next(),)*
}
}
}
pin_project! {
#[project = $fut_proj]
pub enum $fut_name<$($ty),+> {
$($variant { #[pin] inner: $ty },)+
pub enum $fut_name<$($ty,)*> {
$($ty { #[pin] inner: $ty },)*
}
}
impl<$($ty),+> Future for $fut_name<$($ty),+>
impl<$($ty,)*> Future for $fut_name<$($ty,)*>
where
$($ty: Future,)+
$($ty: Future,)*
{
type Output = $name<$($ty::Output),+>;
type Output = $name<$($ty::Output,)*>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
match this {
$($fut_proj::$variant { inner } => match inner.poll(cx) {
$($fut_proj::$ty { inner } => match inner.poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(inner) => Poll::Ready($name::$variant(inner)),
},)+
Poll::Ready(inner) => Poll::Ready($name::$ty(inner)),
},)*
}
}
}
}
}
tuples!(Either + EitherFuture + EitherFutureProj {
Left(A) => (Right) + <A1, B>,
Right(B) => (Left) + <A, B1>,
});
tuples!(EitherOf3 + EitherOf3Future + EitherOf3FutureProj => A, B, C);
tuples!(EitherOf4 + EitherOf4Future + EitherOf4FutureProj => A, B, C, D);
tuples!(EitherOf5 + EitherOf5Future + EitherOf5FutureProj => A, B, C, D, E);
tuples!(EitherOf6 + EitherOf6Future + EitherOf6FutureProj => A, B, C, D, E, F);
tuples!(EitherOf7 + EitherOf7Future + EitherOf7FutureProj => A, B, C, D, E, F, G);
tuples!(EitherOf8 + EitherOf8Future + EitherOf8FutureProj => A, B, C, D, E, F, G, H);
tuples!(EitherOf9 + EitherOf9Future + EitherOf9FutureProj => A, B, C, D, E, F, G, H, I);
tuples!(EitherOf10 + EitherOf10Future + EitherOf10FutureProj => A, B, C, D, E, F, G, H, I, J);
tuples!(EitherOf11 + EitherOf11Future + EitherOf11FutureProj => A, B, C, D, E, F, G, H, I, J, K);
tuples!(EitherOf12 + EitherOf12Future + EitherOf12FutureProj => A, B, C, D, E, F, G, H, I, J, K, L);
tuples!(EitherOf13 + EitherOf13Future + EitherOf13FutureProj => A, B, C, D, E, F, G, H, I, J, K, L, M);
tuples!(EitherOf14 + EitherOf14Future + EitherOf14FutureProj => A, B, C, D, E, F, G, H, I, J, K, L, M, N);
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);
impl<A, B> Either<A, B> {
pub fn swap(self) -> Either<B, A> {
match self {
Either::Left(a) => Either::Right(a),
Either::Right(b) => Either::Left(b),
}
}
}
impl<A, B> From<Result<A, B>> for Either<A, B> {
fn from(value: Result<A, B>) -> Self {
match value {
Ok(left) => Either::Left(left),
Err(right) => Either::Right(right),
}
}
}
pub trait EitherOr {
type Left;
type Right;
fn either_or<FA, A, FB, B>(self, a: FA, b: FB) -> Either<A, B>
where
FA: FnOnce(Self::Left) -> A,
FB: FnOnce(Self::Right) -> B;
}
impl EitherOr for bool {
type Left = ();
type Right = ();
fn either_or<FA, A, FB, B>(self, a: FA, b: FB) -> Either<A, B>
where
FA: FnOnce(Self::Left) -> A,
FB: FnOnce(Self::Right) -> B,
{
if self {
Either::Left(a(()))
} else {
Either::Right(b(()))
}
}
}
impl<T> EitherOr for Option<T> {
type Left = T;
type Right = ();
fn either_or<FA, A, FB, B>(self, a: FA, b: FB) -> Either<A, B>
where
FA: FnOnce(Self::Left) -> A,
FB: FnOnce(Self::Right) -> B,
{
match self {
Some(t) => Either::Left(a(t)),
None => Either::Right(b(())),
}
}
}
impl<T, E> EitherOr for Result<T, E> {
type Left = T;
type Right = E;
fn either_or<FA, A, FB, B>(self, a: FA, b: FB) -> Either<A, B>
where
FA: FnOnce(Self::Left) -> A,
FB: FnOnce(Self::Right) -> B,
{
match self {
Ok(t) => Either::Left(a(t)),
Err(err) => Either::Right(b(err)),
}
}
}
impl<A, B> EitherOr for Either<A, B> {
type Left = A;
type Right = B;
#[inline]
fn either_or<FA, A1, FB, B1>(self, a: FA, b: FB) -> Either<A1, B1>
where
FA: FnOnce(<Self as EitherOr>::Left) -> A1,
FB: FnOnce(<Self as EitherOr>::Right) -> B1,
{
self.map(a, b)
}
}
#[test]
fn test_either_or() {
let right = false.either_or(|_| 'a', |_| 12);
assert!(matches!(right, Either::Right(12)));
let left = true.either_or(|_| 'a', |_| 12);
assert!(matches!(left, Either::Left('a')));
let left = Some(12).either_or(|a| a, |_| 'a');
assert!(matches!(left, Either::Left(12)));
let right = None.either_or(|a: i32| a, |_| 'a');
assert!(matches!(right, Either::Right('a')));
let result: Result<_, ()> = Ok(1.2f32);
let left = result.either_or(|a| a * 2f32, |b| b);
assert!(matches!(left, Either::Left(2.4f32)));
let result: Result<i32, _> = Err("12");
let right = result.either_or(|a| a, |b| b.chars().next());
assert!(matches!(right, Either::Right(Some('1'))));
let either = Either::<i32, char>::Left(12);
let left = either.either_or(|a| a, |b| b);
assert!(matches!(left, Either::Left(12)));
let either = Either::<i32, char>::Right('a');
let right = either.either_or(|a| a, |b| b);
assert!(matches!(right, Either::Right('a')));
}
tuples!(EitherOf3 + EitherOf3Future + EitherOf3FutureProj {
A => (B, C) + <A1, B, C>,
B => (A, C) + <A, B1, C>,
C => (A, B) + <A, B, C1>,
});
tuples!(EitherOf4 + EitherOf4Future + EitherOf4FutureProj {
A => (B, C, D) + <A1, B, C, D>,
B => (A, C, D) + <A, B1, C, D>,
C => (A, B, D) + <A, B, C1, D>,
D => (A, B, C) + <A, B, C, D1>,
});
tuples!(EitherOf5 + EitherOf5Future + EitherOf5FutureProj {
A => (B, C, D, E) + <A1, B, C, D, E>,
B => (A, C, D, E) + <A, B1, C, D, E>,
C => (A, B, D, E) + <A, B, C1, D, E>,
D => (A, B, C, E) + <A, B, C, D1, E>,
E => (A, B, C, D) + <A, B, C, D, E1>,
});
tuples!(EitherOf6 + EitherOf6Future + EitherOf6FutureProj {
A => (B, C, D, E, F) + <A1, B, C, D, E, F>,
B => (A, C, D, E, F) + <A, B1, C, D, E, F>,
C => (A, B, D, E, F) + <A, B, C1, D, E, F>,
D => (A, B, C, E, F) + <A, B, C, D1, E, F>,
E => (A, B, C, D, F) + <A, B, C, D, E1, F>,
F => (A, B, C, D, E) + <A, B, C, D, E, F1>,
});
tuples!(EitherOf7 + EitherOf7Future + EitherOf7FutureProj {
A => (B, C, D, E, F, G) + <A1, B, C, D, E, F, G>,
B => (A, C, D, E, F, G) + <A, B1, C, D, E, F, G>,
C => (A, B, D, E, F, G) + <A, B, C1, D, E, F, G>,
D => (A, B, C, E, F, G) + <A, B, C, D1, E, F, G>,
E => (A, B, C, D, F, G) + <A, B, C, D, E1, F, G>,
F => (A, B, C, D, E, G) + <A, B, C, D, E, F1, G>,
G => (A, B, C, D, E, F) + <A, B, C, D, E, F, G1>,
});
tuples!(EitherOf8 + EitherOf8Future + EitherOf8FutureProj {
A => (B, C, D, E, F, G, H) + <A1, B, C, D, E, F, G, H>,
B => (A, C, D, E, F, G, H) + <A, B1, C, D, E, F, G, H>,
C => (A, B, D, E, F, G, H) + <A, B, C1, D, E, F, G, H>,
D => (A, B, C, E, F, G, H) + <A, B, C, D1, E, F, G, H>,
E => (A, B, C, D, F, G, H) + <A, B, C, D, E1, F, G, H>,
F => (A, B, C, D, E, G, H) + <A, B, C, D, E, F1, G, H>,
G => (A, B, C, D, E, F, H) + <A, B, C, D, E, F, G1, H>,
H => (A, B, C, D, E, F, G) + <A, B, C, D, E, F, G, H1>,
});
tuples!(EitherOf9 + EitherOf9Future + EitherOf9FutureProj {
A => (B, C, D, E, F, G, H, I) + <A1, B, C, D, E, F, G, H, I>,
B => (A, C, D, E, F, G, H, I) + <A, B1, C, D, E, F, G, H, I>,
C => (A, B, D, E, F, G, H, I) + <A, B, C1, D, E, F, G, H, I>,
D => (A, B, C, E, F, G, H, I) + <A, B, C, D1, E, F, G, H, I>,
E => (A, B, C, D, F, G, H, I) + <A, B, C, D, E1, F, G, H, I>,
F => (A, B, C, D, E, G, H, I) + <A, B, C, D, E, F1, G, H, I>,
G => (A, B, C, D, E, F, H, I) + <A, B, C, D, E, F, G1, H, I>,
H => (A, B, C, D, E, F, G, I) + <A, B, C, D, E, F, G, H1, I>,
I => (A, B, C, D, E, F, G, H) + <A, B, C, D, E, F, G, H, I1>,
});
tuples!(EitherOf10 + EitherOf10Future + EitherOf10FutureProj {
A => (B, C, D, E, F, G, H, I, J) + <A1, B, C, D, E, F, G, H, I, J>,
B => (A, C, D, E, F, G, H, I, J) + <A, B1, C, D, E, F, G, H, I, J>,
C => (A, B, D, E, F, G, H, I, J) + <A, B, C1, D, E, F, G, H, I, J>,
D => (A, B, C, E, F, G, H, I, J) + <A, B, C, D1, E, F, G, H, I, J>,
E => (A, B, C, D, F, G, H, I, J) + <A, B, C, D, E1, F, G, H, I, J>,
F => (A, B, C, D, E, G, H, I, J) + <A, B, C, D, E, F1, G, H, I, J>,
G => (A, B, C, D, E, F, H, I, J) + <A, B, C, D, E, F, G1, H, I, J>,
H => (A, B, C, D, E, F, G, I, J) + <A, B, C, D, E, F, G, H1, I, J>,
I => (A, B, C, D, E, F, G, H, J) + <A, B, C, D, E, F, G, H, I1, J>,
J => (A, B, C, D, E, F, G, H, I) + <A, B, C, D, E, F, G, H, I, J1>,
});
tuples!(EitherOf11 + EitherOf11Future + EitherOf11FutureProj {
A => (B, C, D, E, F, G, H, I, J, K) + <A1, B, C, D, E, F, G, H, I, J, K>,
B => (A, C, D, E, F, G, H, I, J, K) + <A, B1, C, D, E, F, G, H, I, J, K>,
C => (A, B, D, E, F, G, H, I, J, K) + <A, B, C1, D, E, F, G, H, I, J, K>,
D => (A, B, C, E, F, G, H, I, J, K) + <A, B, C, D1, E, F, G, H, I, J, K>,
E => (A, B, C, D, F, G, H, I, J, K) + <A, B, C, D, E1, F, G, H, I, J, K>,
F => (A, B, C, D, E, G, H, I, J, K) + <A, B, C, D, E, F1, G, H, I, J, K>,
G => (A, B, C, D, E, F, H, I, J, K) + <A, B, C, D, E, F, G1, H, I, J, K>,
H => (A, B, C, D, E, F, G, I, J, K) + <A, B, C, D, E, F, G, H1, I, J, K>,
I => (A, B, C, D, E, F, G, H, J, K) + <A, B, C, D, E, F, G, H, I1, J, K>,
J => (A, B, C, D, E, F, G, H, I, K) + <A, B, C, D, E, F, G, H, I, J1, K>,
K => (A, B, C, D, E, F, G, H, I, J) + <A, B, C, D, E, F, G, H, I, J, K1>,
});
tuples!(EitherOf12 + EitherOf12Future + EitherOf12FutureProj {
A => (B, C, D, E, F, G, H, I, J, K, L) + <A1, B, C, D, E, F, G, H, I, J, K, L>,
B => (A, C, D, E, F, G, H, I, J, K, L) + <A, B1, C, D, E, F, G, H, I, J, K, L>,
C => (A, B, D, E, F, G, H, I, J, K, L) + <A, B, C1, D, E, F, G, H, I, J, K, L>,
D => (A, B, C, E, F, G, H, I, J, K, L) + <A, B, C, D1, E, F, G, H, I, J, K, L>,
E => (A, B, C, D, F, G, H, I, J, K, L) + <A, B, C, D, E1, F, G, H, I, J, K, L>,
F => (A, B, C, D, E, G, H, I, J, K, L) + <A, B, C, D, E, F1, G, H, I, J, K, L>,
G => (A, B, C, D, E, F, H, I, J, K, L) + <A, B, C, D, E, F, G1, H, I, J, K, L>,
H => (A, B, C, D, E, F, G, I, J, K, L) + <A, B, C, D, E, F, G, H1, I, J, K, L>,
I => (A, B, C, D, E, F, G, H, J, K, L) + <A, B, C, D, E, F, G, H, I1, J, K, L>,
J => (A, B, C, D, E, F, G, H, I, K, L) + <A, B, C, D, E, F, G, H, I, J1, K, L>,
K => (A, B, C, D, E, F, G, H, I, J, L) + <A, B, C, D, E, F, G, H, I, J, K1, L>,
L => (A, B, C, D, E, F, G, H, I, J, K) + <A, B, C, D, E, F, G, H, I, J, K, L1>,
});
tuples!(EitherOf13 + EitherOf13Future + EitherOf13FutureProj {
A => (B, C, D, E, F, G, H, I, J, K, L, M) + <A1, B, C, D, E, F, G, H, I, J, K, L, M>,
B => (A, C, D, E, F, G, H, I, J, K, L, M) + <A, B1, C, D, E, F, G, H, I, J, K, L, M>,
C => (A, B, D, E, F, G, H, I, J, K, L, M) + <A, B, C1, D, E, F, G, H, I, J, K, L, M>,
D => (A, B, C, E, F, G, H, I, J, K, L, M) + <A, B, C, D1, E, F, G, H, I, J, K, L, M>,
E => (A, B, C, D, F, G, H, I, J, K, L, M) + <A, B, C, D, E1, F, G, H, I, J, K, L, M>,
F => (A, B, C, D, E, G, H, I, J, K, L, M) + <A, B, C, D, E, F1, G, H, I, J, K, L, M>,
G => (A, B, C, D, E, F, H, I, J, K, L, M) + <A, B, C, D, E, F, G1, H, I, J, K, L, M>,
H => (A, B, C, D, E, F, G, I, J, K, L, M) + <A, B, C, D, E, F, G, H1, I, J, K, L, M>,
I => (A, B, C, D, E, F, G, H, J, K, L, M) + <A, B, C, D, E, F, G, H, I1, J, K, L, M>,
J => (A, B, C, D, E, F, G, H, I, K, L, M) + <A, B, C, D, E, F, G, H, I, J1, K, L, M>,
K => (A, B, C, D, E, F, G, H, I, J, L, M) + <A, B, C, D, E, F, G, H, I, J, K1, L, M>,
L => (A, B, C, D, E, F, G, H, I, J, K, M) + <A, B, C, D, E, F, G, H, I, J, K, L1, M>,
M => (A, B, C, D, E, F, G, H, I, J, K, L) + <A, B, C, D, E, F, G, H, I, J, K, L, M1>,
});
tuples!(EitherOf14 + EitherOf14Future + EitherOf14FutureProj {
A => (B, C, D, E, F, G, H, I, J, K, L, M, N) + <A1, B, C, D, E, F, G, H, I, J, K, L, M, N>,
B => (A, C, D, E, F, G, H, I, J, K, L, M, N) + <A, B1, C, D, E, F, G, H, I, J, K, L, M, N>,
C => (A, B, D, E, F, G, H, I, J, K, L, M, N) + <A, B, C1, D, E, F, G, H, I, J, K, L, M, N>,
D => (A, B, C, E, F, G, H, I, J, K, L, M, N) + <A, B, C, D1, E, F, G, H, I, J, K, L, M, N>,
E => (A, B, C, D, F, G, H, I, J, K, L, M, N) + <A, B, C, D, E1, F, G, H, I, J, K, L, M, N>,
F => (A, B, C, D, E, G, H, I, J, K, L, M, N) + <A, B, C, D, E, F1, G, H, I, J, K, L, M, N>,
G => (A, B, C, D, E, F, H, I, J, K, L, M, N) + <A, B, C, D, E, F, G1, H, I, J, K, L, M, N>,
H => (A, B, C, D, E, F, G, I, J, K, L, M, N) + <A, B, C, D, E, F, G, H1, I, J, K, L, M, N>,
I => (A, B, C, D, E, F, G, H, J, K, L, M, N) + <A, B, C, D, E, F, G, H, I1, J, K, L, M, N>,
J => (A, B, C, D, E, F, G, H, I, K, L, M, N) + <A, B, C, D, E, F, G, H, I, J1, K, L, M, N>,
K => (A, B, C, D, E, F, G, H, I, J, L, M, N) + <A, B, C, D, E, F, G, H, I, J, K1, L, M, N>,
L => (A, B, C, D, E, F, G, H, I, J, K, M, N) + <A, B, C, D, E, F, G, H, I, J, K, L1, M, N>,
M => (A, B, C, D, E, F, G, H, I, J, K, L, N) + <A, B, C, D, E, F, G, H, I, J, K, L, M1, N>,
N => (A, B, C, D, E, F, G, H, I, J, K, L, M) + <A, B, C, D, E, F, G, H, I, J, K, L, M, N1>,
});
tuples!(EitherOf15 + EitherOf15Future + EitherOf15FutureProj {
A => (B, C, D, E, F, G, H, I, J, K, L, M, N, O) + <A1, B, C, D, E, F, G, H, I, J, K, L, M, N, O>,
B => (A, C, D, E, F, G, H, I, J, K, L, M, N, O) + <A, B1, C, D, E, F, G, H, I, J, K, L, M, N, O>,
C => (A, B, D, E, F, G, H, I, J, K, L, M, N, O) + <A, B, C1, D, E, F, G, H, I, J, K, L, M, N, O>,
D => (A, B, C, E, F, G, H, I, J, K, L, M, N, O) + <A, B, C, D1, E, F, G, H, I, J, K, L, M, N, O>,
E => (A, B, C, D, F, G, H, I, J, K, L, M, N, O) + <A, B, C, D, E1, F, G, H, I, J, K, L, M, N, O>,
F => (A, B, C, D, E, G, H, I, J, K, L, M, N, O) + <A, B, C, D, E, F1, G, H, I, J, K, L, M, N, O>,
G => (A, B, C, D, E, F, H, I, J, K, L, M, N, O) + <A, B, C, D, E, F, G1, H, I, J, K, L, M, N, O>,
H => (A, B, C, D, E, F, G, I, J, K, L, M, N, O) + <A, B, C, D, E, F, G, H1, I, J, K, L, M, N, O>,
I => (A, B, C, D, E, F, G, H, J, K, L, M, N, O) + <A, B, C, D, E, F, G, H, I1, J, K, L, M, N, O>,
J => (A, B, C, D, E, F, G, H, I, K, L, M, N, O) + <A, B, C, D, E, F, G, H, I, J1, K, L, M, N, O>,
K => (A, B, C, D, E, F, G, H, I, J, L, M, N, O) + <A, B, C, D, E, F, G, H, I, J, K1, L, M, N, O>,
L => (A, B, C, D, E, F, G, H, I, J, K, M, N, O) + <A, B, C, D, E, F, G, H, I, J, K, L1, M, N, O>,
M => (A, B, C, D, E, F, G, H, I, J, K, L, N, O) + <A, B, C, D, E, F, G, H, I, J, K, L, M1, N, O>,
N => (A, B, C, D, E, F, G, H, I, J, K, L, M, O) + <A, B, C, D, E, F, G, H, I, J, K, L, M, N1, O>,
O => (A, B, C, D, E, F, G, H, I, J, K, L, M, N) + <A, B, C, D, E, F, G, H, I, J, K, L, M, N, O1>,
});
tuples!(EitherOf16 + EitherOf16Future + EitherOf16FutureProj {
A => (B, C, D, E, F, G, H, I, J, K, L, M, N, O, P) + <A1, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P>,
B => (A, C, D, E, F, G, H, I, J, K, L, M, N, O, P) + <A, B1, C, D, E, F, G, H, I, J, K, L, M, N, O, P>,
C => (A, B, D, E, F, G, H, I, J, K, L, M, N, O, P) + <A, B, C1, D, E, F, G, H, I, J, K, L, M, N, O, P>,
D => (A, B, C, E, F, G, H, I, J, K, L, M, N, O, P) + <A, B, C, D1, E, F, G, H, I, J, K, L, M, N, O, P>,
E => (A, B, C, D, F, G, H, I, J, K, L, M, N, O, P) + <A, B, C, D, E1, F, G, H, I, J, K, L, M, N, O, P>,
F => (A, B, C, D, E, G, H, I, J, K, L, M, N, O, P) + <A, B, C, D, E, F1, G, H, I, J, K, L, M, N, O, P>,
G => (A, B, C, D, E, F, H, I, J, K, L, M, N, O, P) + <A, B, C, D, E, F, G1, H, I, J, K, L, M, N, O, P>,
H => (A, B, C, D, E, F, G, I, J, K, L, M, N, O, P) + <A, B, C, D, E, F, G, H1, I, J, K, L, M, N, O, P>,
I => (A, B, C, D, E, F, G, H, J, K, L, M, N, O, P) + <A, B, C, D, E, F, G, H, I1, J, K, L, M, N, O, P>,
J => (A, B, C, D, E, F, G, H, I, K, L, M, N, O, P) + <A, B, C, D, E, F, G, H, I, J1, K, L, M, N, O, P>,
K => (A, B, C, D, E, F, G, H, I, J, L, M, N, O, P) + <A, B, C, D, E, F, G, H, I, J, K1, L, M, N, O, P>,
L => (A, B, C, D, E, F, G, H, I, J, K, M, N, O, P) + <A, B, C, D, E, F, G, H, I, J, K, L1, M, N, O, P>,
M => (A, B, C, D, E, F, G, H, I, J, K, L, N, O, P) + <A, B, C, D, E, F, G, H, I, J, K, L, M1, N, O, P>,
N => (A, B, C, D, E, F, G, H, I, J, K, L, M, O, P) + <A, B, C, D, E, F, G, H, I, J, K, L, M, N1, O, P>,
O => (A, B, C, D, E, F, G, H, I, J, K, L, M, N, P) + <A, B, C, D, E, F, G, H, I, J, K, L, M, N, O1, P>,
P => (A, B, C, D, E, F, G, H, I, J, K, L, M, N, O) + <A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P1>,
});
/// 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.
@@ -815,93 +197,40 @@ 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 ;-)
}
#[cfg(test)]
mod tests {
use super::*;
// compile time test
#[test]
fn either_macro() {
let _: Either<&str, f64> = either!(12,
12 => "12",
_ => 0.0,
);
let _: EitherOf3<&str, f64, i32> = either!(12,
12 => "12",
13 => 0.0,
_ => 12,
);
let _: EitherOf4<&str, f64, char, i32> = either!(12,
12 => "12",
13 => 0.0,
14 => ' ',
_ => 12,
);
let _: EitherOf5<&str, f64, char, f32, i32> = either!(12,
12 => "12",
13 => 0.0,
14 => ' ',
15 => 0.0f32,
_ => 12,
);
let _: EitherOf6<&str, f64, char, f32, u8, i32> = either!(12,
12 => "12",
13 => 0.0,
14 => ' ',
15 => 0.0f32,
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,
);
}
#[test]
#[should_panic]
fn unwrap_wrong_either() {
Either::<i32, &str>::Left(0).unwrap_right();
}
// compile time test
#[test]
fn either_macro() {
let _: Either<&str, f64> = either!(12,
12 => "12",
_ => 0.0,
);
let _: EitherOf3<&str, f64, i32> = either!(12,
12 => "12",
13 => 0.0,
_ => 12,
);
let _: EitherOf4<&str, f64, char, i32> = either!(12,
12 => "12",
13 => 0.0,
14 => ' ',
_ => 12,
);
let _: EitherOf5<&str, f64, char, f32, i32> = either!(12,
12 => "12",
13 => 0.0,
14 => ' ',
15 => 0.0f32,
_ => 12,
);
let _: EitherOf6<&str, f64, char, f32, u8, i32> = either!(12,
12 => "12",
13 => 0.0,
14 => ' ',
15 => 0.0f32,
16 => 24u8,
_ => 12,
);
}

View File

@@ -7,7 +7,7 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
axum = { version = "0.8.1", optional = true }
axum = { version = "0.7.5", optional = true }
console_error_panic_hook = "0.1.7"
console_log = "1.0"
gloo-utils = "0.2.0"
@@ -20,27 +20,18 @@ leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_router = { path = "../../router" }
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
tokio = { version = "1.39", features = [
"rt-multi-thread",
"macros",
"time",
], optional = true }
tokio = { version = "1.39", features = [ "rt-multi-thread", "macros", "time" ], optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
wasm-bindgen = "0.2.92"
web-sys = { version = "0.3.69", features = [
"AddEventListenerOptions",
"Document",
"Element",
"Event",
"EventListener",
"EventTarget",
"Performance",
"Window",
], optional = true }
web-sys = { version = "0.3.69", features = [ "AddEventListenerOptions", "Document", "Element", "Event", "EventListener", "EventTarget", "Performance", "Window" ], optional = true }
[features]
hydrate = ["leptos/hydrate", "dep:js-sys", "dep:web-sys"]
hydrate = [
"leptos/hydrate",
"dep:js-sys",
"dep:web-sys",
]
ssr = [
"dep:axum",
"dep:http-body-util",

View File

@@ -1227,4 +1227,4 @@ begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0
;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0,
aliases:["yml"],contains:l}}});const Ke=te;for(const e of Object.keys(Pe)){
const n=e.replace("grmr_","").replace("_","-");Ke.registerLanguage(n,Pe[e])}
export{Ke as defaultMod};
export{Ke as default};

View File

@@ -13,13 +13,13 @@ mod csr {
extern "C" {
type HighlightOptions;
#[wasm_bindgen(catch, js_namespace = defaultMod, js_name = highlight)]
#[wasm_bindgen(catch, js_namespace = default, js_name = highlight)]
fn highlight_lang(
code: String,
options: Object,
) -> Result<Object, JsValue>;
#[wasm_bindgen(js_namespace = defaultMod, js_name = highlightAll)]
#[wasm_bindgen(js_namespace = default, js_name = highlightAll)]
pub fn highlight_all();
}

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,6 +1,5 @@
[tasks.install-cargo-leptos]
install_crate = { crate_name = "cargo-leptos", binary = "cargo-leptos", test_arg = "--help" }
args = ["--locked"]
[tasks.cargo-leptos-e2e]
command = "cargo"

View File

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

@@ -13,7 +13,7 @@ leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.8.1", optional = true }
axum = { version = "0.7.5", 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 }

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

@@ -45,7 +45,7 @@ async fn main() {
// build our application with a route
let app = Router::new()
.route("/special/{id}", get(custom_handler))
.route("/special/:id", get(custom_handler))
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())

View File

@@ -1,4 +1,5 @@
use leptos::prelude::*;
use leptos::tachys::html::style::style;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -15,7 +16,7 @@ pub enum CatError {
type CatCount = usize;
async fn fetch_cats(count: CatCount) -> Result<Vec<String>, Error> {
async fn fetch_cats(count: CatCount) -> Result<Vec<String>> {
if count > 0 {
gloo_timers::future::TimeoutFuture::new(1000).await;
// make the request
@@ -41,7 +42,11 @@ async fn fetch_cats(count: CatCount) -> Result<Vec<String>, Error> {
pub fn fetch_example() -> impl IntoView {
let (cat_count, set_cat_count) = signal::<CatCount>(1);
let cats = LocalResource::new(move || fetch_cats(cat_count.get()));
// we use new_unsync here because the reqwasm request type isn't Send
// if we were doing SSR, then
// 1) we'd want to use a Resource, so the data would be serialized to the client
// 2) we'd need to make sure there was a thread-local spawner set up
let cats = AsyncDerived::new_unsync(move || fetch_cats(cat_count.get()));
let fallback = move |errors: ArcRwSignal<Errors>| {
let error_list = move || {
@@ -61,6 +66,8 @@ pub fn fetch_example() -> impl IntoView {
}
};
let spreadable = style(("background-color", "AliceBlue"));
view! {
<div>
<label>
@@ -75,7 +82,7 @@ pub fn fetch_example() -> impl IntoView {
/>
</label>
<Transition fallback=|| view! { <div>"Loading..."</div> }>
<Transition fallback=|| view! { <div>"Loading..."</div> } {..spreadable}>
<ErrorBoundary fallback>
<ul>
{move || Suspend::new(async move {
@@ -85,7 +92,7 @@ pub fn fetch_example() -> impl IntoView {
.map(|s| {
view! {
<li>
<img src=s.clone() />
<img src=s.clone()/>
</li>
}
})

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

@@ -20,7 +20,7 @@ serde = { version = "1.0", features = ["derive"] }
tracing = "0.1.40"
gloo-net = { version = "0.6.0", features = ["http"] }
reqwest = { version = "0.12.5", features = ["json"] }
axum = { version = "0.8.1", optional = true }
axum = { version = "0.7.5", 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 }

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" }
@@ -20,7 +20,7 @@ serde = { version = "1.0", features = ["derive"] }
tracing = "0.1.40"
gloo-net = { version = "0.6.0", features = ["http"] }
reqwest = { version = "0.12.5", features = ["json"] }
axum = { version = "0.8.1", optional = true, features = ["http2"] }
axum = { version = "0.7.5", optional = true, features = ["http2"] }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = [
"fs",

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

@@ -23,7 +23,7 @@ serde = { version = "1.0", features = ["derive"] }
tracing = "0.1.40"
gloo-net = { version = "0.6.0", features = ["http"] }
reqwest = { version = "0.12.5", features = ["json"] }
axum = { version = "0.8.1", default-features = false, optional = true }
axum = { version = "0.7.5", default-features = false, optional = true }
tower = { version = "0.4.13", optional = true }
http = { version = "1.1", optional = true }
web-sys = { version = "0.3.70", features = [

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

@@ -10,12 +10,15 @@ crate-type = ["cdylib", "rlib"]
console_error_panic_hook = "0.1.7"
futures = "0.3.30"
http = "1.1"
leptos = { path = "../../leptos", features = ["tracing", "islands"] }
leptos = { path = "../../leptos", features = [
"tracing",
"experimental-islands",
] }
server_fn = { path = "../../server_fn", features = ["serde-lite"] }
leptos_axum = { path = "../../integrations/axum", optional = true }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.8.1", optional = true }
axum = { version = "0.7.5", 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 }

View File

@@ -10,7 +10,10 @@ crate-type = ["cdylib", "rlib"]
console_error_panic_hook = "0.1.7"
futures = "0.3.30"
http = "1.1"
leptos = { path = "../../leptos", features = ["tracing", "islands"] }
leptos = { path = "../../leptos", features = [
"tracing",
"experimental-islands",
] }
leptos_router = { path = "../../router" }
server_fn = { path = "../../server_fn", features = ["serde-lite"] }
leptos_axum = { path = "../../integrations/axum", features = [
@@ -18,12 +21,11 @@ leptos_axum = { path = "../../integrations/axum", features = [
], optional = true }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.8.1", optional = true }
axum = { version = "0.7.5", 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 }
wasm-bindgen = "0.2.100"
serde_json = "1.0.133"
wasm-bindgen = "0.2.93"
[features]
hydrate = ["leptos/hydrate"]
@@ -56,11 +58,11 @@ site-root = "target/site"
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style.css"
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3009"
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# The browserlist query used for optimizing the CSS.

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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
window.addEventListener("click", async (ev) => {
// confirm that this is an <a> that meets our requirements
if (
ev.defaultPrevented ||
ev.button !== 0 ||
ev.metaKey ||
ev.altKey ||
ev.ctrlKey ||
ev.shiftKey
)
return;
/** @type HTMLAnchorElement | undefined;*/
const a = ev
.composedPath()
.find(el => el instanceof Node && el.nodeName.toUpperCase() === "A");
if (!a) return;
const svg = a.namespaceURI === "http://www.w3.org/2000/svg";
const href = svg ? a.href.baseVal : a.href;
const target = svg ? a.target.baseVal : a.target;
if (target || (!href && !a.hasAttribute("state"))) return;
const rel = (a.getAttribute("rel") || "").split(/\s+/);
if (a.hasAttribute("download") || (rel && rel.includes("external"))) return;
const url = svg ? new URL(href, document.baseURI) : new URL(href);
if (
url.origin !== window.location.origin // ||
// TODO base
//(basePath && url.pathname && !url.pathname.toLowerCase().startsWith(basePath.toLowerCase()))
)
return;
ev.preventDefault();
// fetch the new page
const resp = await fetch(url);
const htmlString = await resp.text();
// Use DOMParser to parse the HTML string
const parser = new DOMParser();
// TODO parse from the request stream instead?
const doc = parser.parseFromString(htmlString, 'text/html');
// 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;
let newNode = newDocWalker.currentNode;
while(oldDocWalker.nextNode() && newDocWalker.nextNode()) {
oldNode = oldDocWalker.currentNode;
newNode = newDocWalker.currentNode;
// if the nodes are different, we need to replace the old with the new
// because of the typed view tree, this should never actually happen
if (oldNode.nodeType !== newNode.nodeType) {
oldNode.replaceWith(newNode);
}
// if it's a text node, just update the text with the new text
else if (oldNode.nodeType === Node.TEXT_NODE) {
oldNode.textContent = newNode.textContent;
}
// if it's an element, replace if it's a different tag, or update attributes
else if (oldNode.nodeType === Node.ELEMENT_NODE) {
/** @type Element */
const oldEl = oldNode;
/** @type Element */
const newEl = newNode;
if (oldEl.tagName !== newEl.tagName) {
oldEl.replaceWith(newEl);
}
else {
for(const attr of newEl.attributes) {
oldEl.setAttribute(attr.name, attr.value);
}
}
}
// we use comment "branch marker" nodes to distinguish between different branches in the statically-typed view tree
// if one of these marker is hit, then there are two options
// 1) it's the same branch, and we just keep walking until the end
// 2) it's a different branch, in which case the old can be replaced with the new wholesale
else if (oldNode.nodeType === Node.COMMENT_NODE) {
const oldText = oldNode.textContent;
const newText = newNode.textContent;
if(oldText.startsWith("bo") && newText !== oldText) {
oldDocWalker.nextNode();
newDocWalker.nextNode();
const oldRange = new Range();
const newRange = new Range();
let oldBranches = 1;
let newBranches = 1;
while(oldBranches > 0 && newBranches > 0) {
if(oldDocWalker.nextNode() && newDocWalker.nextNode()) {
console.log(oldDocWalker.currentNode, newDocWalker.currentNode);
if(oldDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(oldDocWalker.currentNode.textContent.startsWith("bo")) {
oldBranches += 1;
} else if(oldDocWalker.currentNode.textContent.startsWith("bc")) {
oldBranches -= 1;
}
}
if(newDocWalker.currentNode.nodeType === Node.COMMENT_NODE) {
if(newDocWalker.currentNode.textContent.startsWith("bo")) {
newBranches += 1;
} else if(newDocWalker.currentNode.textContent.startsWith("bc")) {
newBranches -= 1;
}
}
}
}
try {
oldRange.setStartAfter(oldNode);
oldRange.setEndBefore(oldDocWalker.currentNode);
newRange.setStartAfter(newNode);
newRange.setEndBefore(newDocWalker.currentNode);
const newContents = newRange.extractContents();
oldRange.deleteContents();
oldRange.insertNode(newContents);
oldNode.replaceWith(newNode);
oldDocWalker.currentNode.replaceWith(newDocWalker.currentNode);
} catch (e) {
console.error(e);
}
} }
}
});
await transition;
window.history.pushState(undefined, null, url);
});

View File

@@ -1,13 +1,8 @@
use leptos::{
either::{Either, EitherOf3},
prelude::*,
};
use leptos::prelude::*;
use leptos_router::{
components::{Route, Router, Routes},
hooks::{use_params_map, use_query_map},
path,
components::{FlatRoutes, Route, Router},
StaticSegment,
};
use serde::{Deserialize, Serialize};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
@@ -17,7 +12,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone()/>
<HydrationScripts options=options islands=true islands_router=true/>
<HydrationScripts options=options islands=true/>
<link rel="stylesheet" id="leptos" href="/pkg/islands.css"/>
<link rel="shortcut icon" type="image/ico" href="/favicon.ico"/>
</head>
@@ -31,180 +26,34 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
#[component]
pub fn App() -> impl IntoView {
view! {
<script src="/routing.js"></script>
<Router>
<header>
<h1>"My Contacts"</h1>
<h1>"My Application"</h1>
</header>
<nav>
<a href="/">"Home"</a>
<a href="/about">"About"</a>
<a href="/">"Page A"</a>
<a href="/b">"Page B"</a>
</nav>
<main>
<Routes fallback=|| "Not found.">
<Route path=path!("") view=Home/>
<Route path=path!("user/:id") view=Details/>
<Route path=path!("about") view=About/>
</Routes>
<p>
<label>"Home Checkbox" <input type="checkbox"/></label>
</p>
<FlatRoutes fallback=|| "Not found.">
<Route path=StaticSegment("") view=PageA/>
<Route path=StaticSegment("b") view=PageB/>
</FlatRoutes>
</main>
</Router>
}
}
#[server]
pub async fn search(query: String) -> Result<Vec<User>, ServerFnError> {
let users = tokio::fs::read_to_string("./mock_data.json").await?;
let data: Vec<User> = serde_json::from_str(&users)?;
let query = query.to_ascii_lowercase();
Ok(data
.into_iter()
.filter(|user| {
user.first_name.to_ascii_lowercase().contains(&query)
|| user.last_name.to_ascii_lowercase().contains(&query)
|| user.email.to_ascii_lowercase().contains(&query)
})
.collect())
}
#[server]
pub async fn delete_user(id: u32) -> Result<(), ServerFnError> {
let users = tokio::fs::read_to_string("./mock_data.json").await?;
let mut data: Vec<User> = serde_json::from_str(&users)?;
data.retain(|user| user.id != id);
let new_json = serde_json::to_string(&data)?;
tokio::fs::write("./mock_data.json", &new_json).await?;
Ok(())
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct User {
id: u32,
first_name: String,
last_name: String,
email: String,
#[component]
pub fn PageA() -> impl IntoView {
view! { <label>"Page A" <input type="checkbox"/></label> }
}
#[component]
pub fn Home() -> impl IntoView {
let q = use_query_map();
let q = move || q.read().get("q");
let data = Resource::new(q, |q| async move {
if let Some(q) = q {
search(q).await
} else {
Ok(vec![])
}
});
let delete_user_action = ServerAction::<DeleteUser>::new();
let view = move || {
Suspend::new(async move {
let users = data.await.unwrap();
if q().is_none() {
EitherOf3::A(view! {
<p class="note">"Enter a search to begin viewing contacts."</p>
})
} else if users.is_empty() {
EitherOf3::B(view! {
<p class="note">"No users found matching that search."</p>
})
} else {
EitherOf3::C(view! {
<table>
<tbody>
<For
each=move || users.clone()
key=|user| user.id
let:user
>
<tr>
<td>{user.first_name}</td>
<td>{user.last_name}</td>
<td>{user.email}</td>
<td>
<a href=format!("/user/{}", user.id)>"Details"</a>
<input type="checkbox"/>
<ActionForm action=delete_user_action>
<input type="hidden" name="id" value=user.id/>
<input type="submit" value="Delete"/>
</ActionForm>
</td>
</tr>
</For>
</tbody>
</table>
})
}
})
};
view! {
<section class="page">
<form method="GET" class="search">
<input type="search" name="q" value=q autofocus oninput="this.form.requestSubmit()"/>
<input type="submit"/>
</form>
<Suspense fallback=|| view! { <p>"Loading..."</p> }>{view}</Suspense>
</section>
}
}
#[component]
pub fn Details() -> impl IntoView {
#[server]
pub async fn get_user(id: u32) -> Result<Option<User>, ServerFnError> {
let users = tokio::fs::read_to_string("./mock_data.json").await?;
let data: Vec<User> = serde_json::from_str(&users)?;
Ok(data.iter().find(|user| user.id == id).cloned())
}
let params = use_params_map();
let id = move || {
params
.read()
.get("id")
.and_then(|id| id.parse::<u32>().ok())
};
let user = Resource::new(id, |id| async move {
match id {
None => Ok(None),
Some(id) => get_user(id).await,
}
});
move || {
Suspend::new(async move {
user.await.map(|user| match user {
None => Either::Left(view! {
<section class="page">
<h2>"Not found."</h2>
<p>"Sorry — we couldnt find that user."</p>
</section>
}),
Some(user) => Either::Right(view! {
<section class="page">
<h2>{user.first_name} " " { user.last_name}</h2>
<p class="email">{user.email}</p>
</section>
}),
})
})
}
}
#[component]
pub fn About() -> impl IntoView {
view! {
<section class="page">
<h2>"About"</h2>
<p>"This demo is intended to show off an experimental “islands router” feature, which mimics the smooth transitions and user experience of client-side routing while minimizing the amount of code that actually runs in the browser."</p>
<p>"By default, all the content in this application is only rendered on the server. But you can add client-side interactivity via islands like this one:"</p>
<Counter/>
</section>
}
}
#[island]
pub fn Counter() -> impl IntoView {
let count = RwSignal::new(0);
view! {
<button class="counter" on:click=move |_| *count.write() += 1>{count}</button>
}
pub fn PageB() -> impl IntoView {
view! { <label>"Page B" <input type="checkbox"/></label> }
}

View File

@@ -1,52 +1,3 @@
body {
font-family: system-ui, sans-serif;
background-color: #f6f6fa;
}
h1, h2, h3, h4, h5, h6 {
font-family: ui-rounded, 'Hiragino Maru Gothic ProN', Quicksand, Comfortaa, Manjari, 'Arial Rounded MT', 'Arial Rounded MT Bold', Calibri, source-sans-pro, sans-serif;
text-align: center;
}
nav {
padding: 1rem;
text-align: center;
}
nav a {
margin: 1rem;
}
form.search {
display: flex;
margin: 2rem auto;
justify-content: center;
}
td {
min-width: 10rem;
width: 10rem;
}
table {
min-width: 100%;
}
.page {
width: 80%;
margin: auto;
}
td:last-child > * {
display: inline-block;
}
.note, .note {
text-align: center;
}
button.counter {
display: block;
font-size: 2rem;
margin: auto;
.pending {
color: purple;
}

View File

@@ -149,12 +149,12 @@ pub fn App() -> impl IntoView {
</div>
<div class="col-md-6">
<div class="row">
<Button id="run" text="Create 1,000 rows" on:click=run />
<Button id="runlots" text="Create 10,000 rows" on:click=run_lots />
<Button id="add" text="Append 1,000 rows" on:click=add />
<Button id="update" text="Update every 10th row" on:click=update />
<Button id="clear" text="Clear" on:click=clear />
<Button id="swaprows" text="Swap Rows" on:click=swap_rows />
<Button id="run" text="Create 1,000 rows" on:click=run/>
<Button id="runlots" text="Create 10,000 rows" on:click=run_lots/>
<Button id="add" text="Append 1,000 rows" on:click=add/>
<Button id="update" text="Update every 10th row" on:click=update/>
<Button id="clear" text="Clear" on:click=clear/>
<Button id="swaprows" text="Swap Rows" on:click=swap_rows/>
</div>
</div>
</div>

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,13 +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)]
@@ -24,16 +24,11 @@ pub fn RouterExample() -> impl IntoView {
// contexts are passed down through the route tree
provide_context(ExampleContext(0));
// this signal will be used to set whether we are allowed to access a protected route
// this signal will be ued to set whether we are allowed to access a protected route
let (logged_in, set_logged_in) = signal(true);
let (is_routing, set_is_routing) = signal(false);
view! {
<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:
@@ -49,18 +44,18 @@ 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 />
<Route path=path!("about") view=About/>
<ProtectedRoute
path=path!("settings")
condition=move || Some(logged_in.get())
redirect_path=|| "/"
view=Settings
/>
<Route path=path!("redirect-home") view=|| view! { <Redirect path="/" /> } />
<ContactRoutes />
<Route path=path!("redirect-home") view=|| view! { <Redirect path="/"/> }/>
<ContactRoutes/>
</Routes>
</main>
</Router>
@@ -69,12 +64,12 @@ pub fn RouterExample() -> impl IntoView {
// You can define other routes in their own component.
// Routes implement the MatchNestedRoutes
#[component(transparent)]
pub fn ContactRoutes() -> impl leptos_router::MatchNestedRoutes + Clone {
#[component]
pub fn ContactRoutes() -> impl MatchNestedRoutes + Clone {
view! {
<ParentRoute path=path!("") view=ContactList>
<Route path=path!("/") view=|| "Select a contact." />
<Route path=path!("/:id") view=Contact />
<Route path=path!("/") view=|| "Select a contact."/>
<Route path=path!("/:id") view=Contact/>
</ParentRoute>
}
.into_inner()
@@ -121,7 +116,7 @@ pub fn ContactList() -> impl IntoView {
<Suspense fallback=move || view! { <p>"Loading contacts..."</p> }>
<ul>{contacts}</ul>
</Suspense>
<Outlet />
<Outlet/>
</div>
}
}
@@ -165,7 +160,7 @@ pub fn Contact() -> impl IntoView {
Some(contact) => Either::Right(view! {
<section class="card">
<h1>{contact.first_name} " " {contact.last_name}</h1>
<p>{contact.address_1} <br /> {contact.address_2}</p>
<p>{contact.address_1} <br/> {contact.address_2}</p>
</section>
}),
}
@@ -223,10 +218,10 @@ pub fn Settings() -> impl IntoView {
<Form action="">
<fieldset>
<legend>"Name"</legend>
<input type="text" name="first_name" placeholder="First" />
<input type="text" name="last_name" placeholder="Last" />
<input type="text" name="first_name" placeholder="First"/>
<input type="text" name="last_name" placeholder="Last"/>
</fieldset>
<input type="submit" />
<input type="submit"/>
<p>
"This uses the " <code>"<Form/>"</code>
" component, which enhances forms by using client-side navigation for "

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

@@ -21,27 +21,25 @@ server_fn = { path = "../../server_fn", features = [
log = "0.4.22"
simple_logger = "5.0"
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.8.1", optional = true }
tower = { version = "0.5.2", optional = true }
tower-http = { version = "0.6.2", features = [
axum = { version = "0.7.5", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = [
"fs",
"tracing",
"trace",
], optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }
thiserror = "2.0.11"
thiserror = "1.0"
wasm-bindgen = "0.2.93"
serde_toml = "0.0.1"
toml = "0.8.19"
web-sys = { version = "0.3.70", features = ["FileList", "File"] }
strum = { version = "0.27.1", features = ["strum_macros", "derive"] }
notify = { version = "8.0", optional = true }
strum = { version = "0.26.3", features = ["strum_macros", "derive"] }
notify = { version = "6.1", optional = true }
pin-project-lite = "0.2.14"
dashmap = { version = "6.0", optional = true }
once_cell = { version = "1.19", optional = true }
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::{Sink, Stream, StreamExt};
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},
@@ -9,10 +9,8 @@ use server_fn::{
MultipartFormData, Postcard, Rkyv, SerdeLite, StreamingText,
TextStream,
},
error::{FromServerFnError, IntoAppError, ServerFnErrorErr},
request::{browser::BrowserRequest, ClientReq, Req},
response::{browser::BrowserResponse, ClientRes, TryRes},
ContentType,
response::{browser::BrowserResponse, ClientRes, Res},
};
use std::future::Future;
#[cfg(feature = "ssr")]
@@ -419,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};
@@ -654,72 +653,32 @@ pub fn FileWatcher() -> impl IntoView {
/// implementations if you'd like. However, it's much lighter weight to use something like `strum`
/// simply to generate those trait implementations.
#[server]
pub async fn ascii_uppercase(text: String) -> Result<String, MyErrors> {
other_error()?;
Ok(ascii_uppercase_inner(text)?)
}
pub fn other_error() -> Result<(), String> {
Ok(())
}
pub fn ascii_uppercase_inner(text: String) -> Result<String, InvalidArgument> {
pub async fn ascii_uppercase(
text: String,
) -> Result<String, ServerFnError<InvalidArgument>> {
if text.len() < 5 {
Err(InvalidArgument::TooShort)
Err(InvalidArgument::TooShort.into())
} else if text.len() > 15 {
Err(InvalidArgument::TooLong)
Err(InvalidArgument::TooLong.into())
} else if text.is_ascii() {
Ok(text.to_ascii_uppercase())
} else {
Err(InvalidArgument::NotAscii)
Err(InvalidArgument::NotAscii.into())
}
}
#[server]
pub async fn ascii_uppercase_classic(
text: String,
) -> Result<String, ServerFnError<InvalidArgument>> {
Ok(ascii_uppercase_inner(text)?)
}
// The EnumString and Display derive macros are provided by strum
#[derive(Debug, Clone, Display, EnumString, Serialize, Deserialize)]
#[derive(Debug, Clone, EnumString, Display)]
pub enum InvalidArgument {
TooShort,
TooLong,
NotAscii,
}
#[derive(Debug, Clone, Display, Serialize, Deserialize)]
pub enum MyErrors {
InvalidArgument(InvalidArgument),
ServerFnError(ServerFnErrorErr),
Other(String),
}
impl From<InvalidArgument> for MyErrors {
fn from(value: InvalidArgument) -> Self {
MyErrors::InvalidArgument(value)
}
}
impl From<String> for MyErrors {
fn from(value: String) -> Self {
MyErrors::Other(value)
}
}
impl FromServerFnError for MyErrors {
fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
MyErrors::ServerFnError(value)
}
}
#[component]
pub fn CustomErrorTypes() -> impl IntoView {
let input_ref = NodeRef::<Input>::new();
let (result, set_result) = signal(None);
let (result_classic, set_result_classic) = signal(None);
view! {
<h3>Using custom error types</h3>
@@ -734,17 +693,14 @@ pub fn CustomErrorTypes() -> impl IntoView {
<button on:click=move |_| {
let value = input_ref.get().unwrap().value();
spawn_local(async move {
let data = ascii_uppercase(value.clone()).await;
let data_classic = ascii_uppercase_classic(value).await;
let data = ascii_uppercase(value).await;
set_result.set(Some(data));
set_result_classic.set(Some(data_classic));
});
}>
"Submit"
</button>
<p>{move || format!("{:?}", result.get())}</p>
<p>{move || format!("{:?}", result_classic.get())}</p>
}
}
@@ -762,11 +718,8 @@ pub struct Toml;
#[derive(Serialize, Deserialize)]
pub struct TomlEncoded<T>(T);
impl ContentType for Toml {
const CONTENT_TYPE: &'static str = "application/toml";
}
impl Encoding for Toml {
const CONTENT_TYPE: &'static str = "application/toml";
const METHOD: Method = Method::POST;
}
@@ -774,12 +727,14 @@ impl<T, Request, Err> IntoReq<Toml, Request, Err> for TomlEncoded<T>
where
Request: ClientReq<Err>,
T: Serialize,
Err: FromServerFnError,
{
fn into_req(self, path: &str, accepts: &str) -> Result<Request, Err> {
let data = toml::to_string(&self.0).map_err(|e| {
ServerFnErrorErr::Serialization(e.to_string()).into_app_error()
})?;
fn into_req(
self,
path: &str,
accepts: &str,
) -> Result<Request, ServerFnError<Err>> {
let data = toml::to_string(&self.0)
.map_err(|e| ServerFnError::Serialization(e.to_string()))?;
Request::try_new_post(path, Toml::CONTENT_TYPE, accepts, data)
}
}
@@ -788,26 +743,23 @@ impl<T, Request, Err> FromReq<Toml, Request, Err> for TomlEncoded<T>
where
Request: Req<Err> + Send,
T: DeserializeOwned,
Err: FromServerFnError,
{
async fn from_req(req: Request) -> Result<Self, Err> {
async fn from_req(req: Request) -> Result<Self, ServerFnError<Err>> {
let string_data = req.try_into_string().await?;
toml::from_str::<T>(&string_data)
.map(TomlEncoded)
.map_err(|e| ServerFnErrorErr::Args(e.to_string()).into_app_error())
.map_err(|e| ServerFnError::Args(e.to_string()))
}
}
impl<T, Response, Err> IntoRes<Toml, Response, Err> for TomlEncoded<T>
where
Response: TryRes<Err>,
Response: Res<Err>,
T: Serialize + Send,
Err: FromServerFnError,
{
async fn into_res(self) -> Result<Response, Err> {
let data = toml::to_string(&self.0).map_err(|e| {
ServerFnErrorErr::Serialization(e.to_string()).into_app_error()
})?;
async fn into_res(self) -> Result<Response, ServerFnError<Err>> {
let data = toml::to_string(&self.0)
.map_err(|e| ServerFnError::Serialization(e.to_string()))?;
Response::try_from_string(Toml::CONTENT_TYPE, data)
}
}
@@ -816,13 +768,12 @@ impl<T, Response, Err> FromRes<Toml, Response, Err> for TomlEncoded<T>
where
Response: ClientRes<Err> + Send,
T: DeserializeOwned,
Err: FromServerFnError,
{
async fn from_res(res: Response) -> Result<Self, Err> {
async fn from_res(res: Response) -> Result<Self, ServerFnError<Err>> {
let data = res.try_into_string().await?;
toml::from_str(&data).map(TomlEncoded).map_err(|e| {
ServerFnErrorErr::Deserialization(e.to_string()).into_app_error()
})
toml::from_str(&data)
.map(TomlEncoded)
.map_err(|e| ServerFnError::Deserialization(e.to_string()))
}
}
@@ -885,10 +836,7 @@ pub fn CustomClientExample() -> impl IntoView {
pub struct CustomClient;
// Implement the `Client` trait for it.
impl<E> Client<E> for CustomClient
where
E: FromServerFnError,
{
impl<CustErr> Client<CustErr> for CustomClient {
// BrowserRequest and BrowserResponse are the defaults used by other server functions.
// They are wrappers for the underlying Web Fetch API types.
type Request = BrowserRequest;
@@ -897,7 +845,8 @@ pub fn CustomClientExample() -> impl IntoView {
// Our custom `send()` implementation does all the work.
fn send(
req: Self::Request,
) -> impl Future<Output = Result<Self::Response, E>> + Send {
) -> impl Future<Output = Result<Self::Response, ServerFnError<CustErr>>>
+ Send {
// BrowserRequest derefs to the underlying Request type from gloo-net,
// so we can get access to the headers here
let headers = req.headers();
@@ -906,24 +855,6 @@ pub fn CustomClientExample() -> impl IntoView {
// delegate back out to BrowserClient to send the modified request
BrowserClient::send(req)
}
fn open_websocket(
path: &str,
) -> impl Future<
Output = Result<
(
impl Stream<Item = Result<server_fn::Bytes, E>> + Send + 'static,
impl Sink<Result<server_fn::Bytes, E>> + Send + 'static,
),
E,
>,
> + Send {
BrowserClient::open_websocket(path)
}
fn spawn(future: impl Future<Output = ()> + Send + 'static) {
<BrowserClient as Client<E>>::spawn(future)
}
}
// Specify our custom client with `client = `

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

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

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

@@ -20,7 +20,7 @@ leptos_router = { path = "../../router" }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
axum = { version = "0.8.1", optional = true }
axum = { version = "0.7.5", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = [

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

@@ -18,7 +18,7 @@ leptos_router = { path = "../../router" }
log = "0.4.22"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
axum = { version = "0.8.1", optional = true }
axum = { version = "0.7.5", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.2", features = ["fs"], optional = true }
tokio = { version = "1.39", features = [
@@ -45,7 +45,7 @@ ssr = [
"dep:leptos_axum",
"leptos_router/ssr",
"dep:notify",
"dep:http",
"dep:http"
]
[profile.release]

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

View File

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

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

View File

@@ -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,692 +0,0 @@
use leptos::prelude::*;
use leptos_router::{
components::{ParentRoute, Route, A},
hooks::use_params,
nested_router::Outlet,
params::Params,
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 {
#[allow(dead_code)]
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 leptos_router::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| {
let id = item.id;
// FIXME seems like relative link isn't working, it is currently
// adding an extra `/` in artix; manually construct `a` instead.
// <li><A href=format!("./{name}/")>{format!("Inspect {name}")}</A></li>
view! {
<li>
<a href=format!(
"/instrumented/item/{id}/{name}/",
)>"Inspect "{name.clone()}</a>
</li>
}
})
.collect_view()}
</ul>
}
});
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
},
);
let ws = use_context::<WriteSignal<Option<FieldNavCtx>>>();
on_cleanup(move || {
if let Some(c) = ws {
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

@@ -7,7 +7,7 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
axum = { version = "0.8.1", optional = true }
axum = { version = "0.7.5", optional = true }
console_error_panic_hook = "0.1.7"
leptos = { path = "../../leptos" }
leptos_meta = { path = "../../meta" }

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

@@ -1,22 +0,0 @@
{
"name": "leptos-tailwind",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "leptos-tailwind",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"tailwindcss": "^4.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0.tgz",
"integrity": "sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ==",
"dev": true
}
}
}

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,69 +0,0 @@
/*! tailwindcss v4.0.0 | MIT License | https://tailwindcss.com */
@tailwind base;
@tailwind components;
.relative {
position: relative;
}
.m-auto {
margin: auto;
}
.flex {
display: flex;
}
.min-h-screen {
min-height: 100vh;
}
.transform {
transform: var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y);
}
.flex-col {
flex-direction: column;
}
.flex-row-reverse {
flex-direction: row-reverse;
}
.flex-wrap {
flex-wrap: wrap;
}
.border-b-4 {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 4px;
}
.border-l-2 {
border-left-style: var(--tw-border-style);
border-left-width: 2px;
}
.bg-gradient-to-tl {
--tw-gradient-position: to top left in oklab,;
background-image: linear-gradient(var(--tw-gradient-stops));
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
initial-value: rotateX(0);
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
initial-value: rotateY(0);
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
initial-value: rotateZ(0);
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
initial-value: skewX(0);
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
initial-value: skewY(0);
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}

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: [],
}

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