Compare commits

..

2 Commits

Author SHA1 Message Date
Greg Johnston
4e4872b7db typo 2024-05-01 07:06:43 -04:00
Greg Johnston
6eb68a8794 docs: add caveats for ProtectedRoute 2024-04-24 19:47:23 -04:00
691 changed files with 39144 additions and 59687 deletions

View File

@@ -14,7 +14,7 @@ jobs:
test:
needs: [get-leptos-changed]
if: needs.get-leptos-changed.outputs.leptos_changed == 'true' && github.event.pull_request.labels[0].name != 'breaking'
if: needs.get-leptos-changed.outputs.leptos_changed == 'true'
name: Run semver check (nightly-2024-04-14)
runs-on: ubuntu-latest

View File

@@ -20,11 +20,6 @@ jobs:
matrix:
directory:
[
any_error,
any_spawner,
const_str_slice_concat,
either_of,
hydration_context,
integrations/actix,
integrations/axum,
integrations/utils,
@@ -33,14 +28,10 @@ jobs:
leptos_dom,
leptos_hot_reload,
leptos_macro,
leptos_reactive,
leptos_server,
meta,
next_tuple,
oco,
or_poisoned,
reactive_graph,
router,
router_macro,
server_fn,
server_fn/server_fn_macro_default,
server_fn_macro,

View File

@@ -24,29 +24,17 @@ jobs:
uses: tj-actions/changed-files@v43
with:
files: |
any_error/**
any_spawner/**
const_str_slice_concat/**
either_of/**
hydration_context/**
integrations/actix/**
integrations/axum/**
integrations/utils/**
integrations/**
leptos/**
leptos_config/**
leptos_dom/**
leptos_hot_reload/**
leptos_macro/**
leptos_reactive/**
leptos_server/**
meta/**
next_tuple/**
oco/**
or_poisoned/**
reactive_graph/**
router/**
router_macro/**
server_fn/**
server_fn/server_fn_macro_default/**
server_fn_macro/**
- name: List source files that changed

View File

@@ -48,6 +48,9 @@ jobs:
- name: Install wasm-bindgen
run: cargo binstall wasm-bindgen-cli --no-confirm
- name: Install wasm-pack
run: cargo binstall wasm-pack --no-confirm
- name: Install cargo-leptos
run: cargo binstall cargo-leptos --no-confirm

View File

@@ -1,30 +1,20 @@
[workspace]
resolver = "2"
members = [
# utilities
# utilities
"oco",
"any_spawner",
"const_str_slice_concat",
"either_of",
"next_tuple",
"oco",
"or_poisoned",
# core
"hydration_context",
"leptos",
"leptos_dom",
"leptos_config",
"leptos_hot_reload",
"leptos_macro",
"leptos_reactive",
"leptos_server",
"reactive_graph",
"reactive_stores",
"reactive_stores_macro",
"server_fn",
"server_fn_macro",
"server_fn/server_fn_macro_default",
"tachys",
# integrations
"integrations/actix",
@@ -34,40 +24,28 @@ members = [
# libraries
"meta",
"router",
"router_macro",
"any_error",
]
exclude = ["benchmarks", "examples"]
[workspace.package]
version = "0.7.0-alpha"
version = "0.6.11"
rust-version = "1.75"
[workspace.dependencies]
throw_error = { path = "./any_error/", version = "0.1" }
any_spawner = { path = "./any_spawner/", version = "0.1" }
const_str_slice_concat = { path = "./const_str_slice_concat", version = "0.1" }
either_of = { path = "./either_of/", version = "0.1" }
hydration_context = { path = "./hydration_context", version = "0.2.0-alpha" }
leptos = { path = "./leptos", version = "0.7.0-alpha" }
leptos_config = { path = "./leptos_config", version = "0.7.0-alpha" }
leptos_dom = { path = "./leptos_dom", version = "0.7.0-alpha" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.7.0-alpha" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.7.0-alpha" }
leptos_macro = { path = "./leptos_macro", version = "0.7.0-alpha" }
leptos_router = { path = "./router", version = "0.7.0-alpha" }
leptos_server = { path = "./leptos_server", version = "0.7.0-alpha" }
leptos_meta = { path = "./meta", version = "0.7.0-alpha" }
next_tuple = { path = "./next_tuple", version = "0.1.0-alpha" }
oco_ref = { path = "./oco", version = "0.2" }
or_poisoned = { path = "./or_poisoned", version = "0.1" }
reactive_graph = { path = "./reactive_graph", version = "0.1.0-alpha" }
reactive_stores = { path = "./reactive_stores", version = "0.1.0-alpha" }
reactive_stores_macro = { path = "./reactive_stores_macro", version = "0.1.0-alpha" }
server_fn = { path = "./server_fn", version = "0.7.0-alpha" }
server_fn_macro = { path = "./server_fn_macro", version = "0.7.0-alpha" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.7.0-alpha" }
tachys = { path = "./tachys", version = "0.1.0-alpha" }
oco_ref = { path = "./oco", version = "0.1.0" }
leptos = { path = "./leptos", version = "0.6.11" }
leptos_dom = { path = "./leptos_dom", version = "0.6.11" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.6.11" }
leptos_macro = { path = "./leptos_macro", version = "0.6.11" }
leptos_reactive = { path = "./leptos_reactive", version = "0.6.11" }
leptos_server = { path = "./leptos_server", version = "0.6.11" }
server_fn = { path = "./server_fn", version = "0.6.11" }
server_fn_macro = { path = "./server_fn_macro", version = "0.6.11" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.6" }
leptos_config = { path = "./leptos_config", version = "0.6.11" }
leptos_router = { path = "./router", version = "0.6.11" }
leptos_meta = { path = "./meta", version = "0.6.11" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.6.11" }
[profile.release]
codegen-units = 1

40
TODO.md
View File

@@ -1,40 +0,0 @@
- core examples
- [x] counter
- [x] counters
- [x] fetch
- [x] todomvc
- [x] error_boundary
- [x] parent\_child
- [x] on: on components
- [ ] router
- [ ] slots
- [ ] hackernews
- [ ] counter\_isomorphic
- [ ] todo\_app\_sqlite
- other ssr examples
- [ ] error boundary SSR
- reactivity
- Signal wrappers
- SignalDispose implementations on all Copy types
- untracked access warnings
- ErrorBoundary
- [ ] RenderHtml implementation
- [ ] Separate component?
- Suspense/Transition components?
- callbacks
- unsync StoredValue
- SSR
- escaping HTML correctly (attributes + text nodes)
- router
- nested routes
- trailing slashes
- \_meta package (and use in hackernews)
- integrations
- update tests
- hackernews example
- TODOs
- Suspense/Transition/Await components
- nicer routing components
- async routing (waiting for data to load before navigation)
- `<A>` component
- figure out rebuilding issues: list (needs new signal IDs) vs. regular rebuild

View File

@@ -1,13 +0,0 @@
[package]
name = "throw_error"
edition = "2021"
version = "0.1.0"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
repository = "https://github.com/leptos-rs/leptos"
description = "Utilities for wrapping, throwing, and catching errors."
rust-version.workspace = true
[dependencies]
pin-project-lite = "0.2"

View File

@@ -1,2 +0,0 @@
A utility library for wrapping arbitrary errors, and for “throwing” errors in a way
that can be caught by user-defined error hooks.

View File

@@ -1,148 +0,0 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]
//! A utility library for wrapping arbitrary errors, and for “throwing” errors in a way
//! that can be caught by user-defined error hooks.
use std::{
cell::RefCell,
error,
fmt::{self, Display},
future::Future,
ops,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
/* 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)]
pub struct Error(Arc<dyn error::Error + Send + Sync>);
impl Error {
/// Converts the wrapper into the inner reference-counted error.
pub fn into_inner(self) -> Arc<dyn error::Error + Send + Sync> {
Arc::clone(&self.0)
}
}
impl ops::Deref for Error {
type Target = Arc<dyn error::Error + Send + Sync>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl<T> From<T> for Error
where
T: error::Error + Send + Sync + 'static,
{
fn from(value: T) -> Self {
Error(Arc::new(value))
}
}
/// Implements behavior that allows for global or scoped error handling.
///
/// This allows for both "throwing" errors to register them, and "clearing" errors when they are no
/// longer valid. This is useful for something like a user interface, in which an error can be
/// "thrown" on some invalid user input, and later "cleared" if the user corrects the input.
/// Keeping a unique identifier for each error allows the UI to be updated accordingly.
pub trait ErrorHook: Send + Sync {
/// Handles the given error, returning a unique identifier.
fn throw(&self, error: Error) -> ErrorId;
/// Clears the error associated with the given identifier.
fn clear(&self, id: &ErrorId);
}
/// A unique identifier for an error. This is returned when you call [`throw`], which calls a
/// global error handler.
#[derive(Debug, PartialEq, Eq, Hash, Clone, Default)]
pub struct ErrorId(usize);
impl Display for ErrorId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Display::fmt(&self.0, f)
}
}
impl From<usize> for ErrorId {
fn from(value: usize) -> Self {
Self(value)
}
}
thread_local! {
static ERROR_HOOK: RefCell<Option<Arc<dyn ErrorHook>>> = RefCell::new(None);
}
/// Sets the current thread-local error hook, which will be invoked when [`throw`] is called.
pub fn set_error_hook(hook: Arc<dyn ErrorHook>) {
ERROR_HOOK.with_borrow_mut(|this| *this = Some(hook))
}
/// Invokes the error hook set by [`set_error_hook`] with the given error.
pub fn throw(error: impl Into<Error>) -> ErrorId {
ERROR_HOOK
.with_borrow(|hook| hook.as_ref().map(|hook| hook.throw(error.into())))
.unwrap_or_default()
}
/// Clears the given error from the current error hook.
pub fn clear(id: &ErrorId) {
ERROR_HOOK
.with_borrow(|hook| hook.as_ref().map(|hook| hook.clear(id)))
.unwrap_or_default()
}
pin_project_lite::pin_project! {
/// A [`Future`] that reads the error hook that is set when it is created, and sets this as the
/// current error hook whenever it is polled.
pub struct ErrorHookFuture<Fut> {
hook: Option<Arc<dyn ErrorHook>>,
#[pin]
inner: Fut
}
}
impl<Fut> ErrorHookFuture<Fut> {
/// Reads the current hook and wraps the given [`Future`], returning a new `Future` that will
/// set the error hook whenever it is polled.
pub fn new(inner: Fut) -> Self {
Self {
hook: ERROR_HOOK.with_borrow(Clone::clone),
inner,
}
}
}
impl<Fut> Future for ErrorHookFuture<Fut>
where
Fut: Future,
{
type Output = Fut::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
if let Some(hook) = &this.hook {
set_error_hook(Arc::clone(hook))
}
this.inner.poll(cx)
}
}

View File

@@ -1,30 +0,0 @@
[package]
name = "any_spawner"
edition = "2021"
version = "0.1.1"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
repository = "https://github.com/leptos-rs/leptos"
description = "Spawn asynchronous tasks in an executor-independent way."
[dependencies]
futures = "0.3"
glib = { version = "0.19", optional = true }
thiserror = "1"
tokio = { version = "1", optional = true, default-features = false, features = [
"rt",
] }
tracing = { version = "0.1", optional = true }
wasm-bindgen-futures = { version = "0.4", optional = true }
[features]
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

@@ -1,26 +0,0 @@
This crate makes it easier to write asynchronous code that is executor-agnostic, by providing a
utility that can be used to spawn tasks in a variety of executors.
It only supports single executor per program, but that executor can be set at runtime, anywhere
in your crate (or an application that depends on it).
This can be extended to support any executor or runtime that supports spawning [`Future`]s.
This is a least common denominator implementation in many ways. Limitations include:
- setting an executor is a one-time, global action
- no "join handle" or other result is returned from the spawn
- the `Future` must output `()`
```rust
use any_spawner::Executor;
Executor::init_futures_executor()
.expect("executor should only be initialized once");
// spawn a thread-safe Future
Executor::spawn(async { /* ... */ });
// spawn a Future that is !Send
Executor::spawn_local(async { /* ... */ });
```

View File

@@ -1,245 +0,0 @@
//! This crate makes it easier to write asynchronous code that is executor-agnostic, by providing a
//! utility that can be used to spawn tasks in a variety of executors.
//!
//! It only supports single executor per program, but that executor can be set at runtime, anywhere
//! in your crate (or an application that depends on it).
//!
//! This can be extended to support any executor or runtime that supports spawning [`Future`]s.
//!
//! This is a least common denominator implementation in many ways. Limitations include:
//! - setting an executor is a one-time, global action
//! - no "join handle" or other result is returned from the spawn
//! - the `Future` must output `()`
//!
//! ```rust
//! use any_spawner::Executor;
//!
//! // make sure an Executor has been initialized with one of the init_ functions
//!
//! # if false {
//! // spawn a thread-safe Future
//! Executor::spawn(async { /* ... */ });
//!
//! // spawn a Future that is !Send
//! Executor::spawn_local(async { /* ... */ });
//! # }
//! ```
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]
use std::{future::Future, pin::Pin, sync::OnceLock};
use thiserror::Error;
pub(crate) type PinnedFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
pub(crate) type PinnedLocalFuture<T> = Pin<Box<dyn Future<Output = T>>>;
static SPAWN: OnceLock<fn(PinnedFuture<()>)> = OnceLock::new();
static SPAWN_LOCAL: OnceLock<fn(PinnedLocalFuture<()>)> = OnceLock::new();
/// Errors that can occur when using the executor.
#[derive(Error, Debug)]
pub enum ExecutorError {
/// The executor has already been set.
#[error("Executor has already been set.")]
AlreadySet,
}
/// A global async executor that can spawn tasks.
pub struct Executor;
impl Executor {
/// Spawns a thread-safe [`Future`].
/// ```rust
/// use any_spawner::Executor;
/// # if false {
/// // spawn a thread-safe Future
/// Executor::spawn(async { /* ... */ });
/// # }
/// ```
#[track_caller]
pub fn spawn(fut: impl Future<Output = ()> + Send + 'static) {
if let Some(spawner) = SPAWN.get() {
spawner(Box::pin(fut))
} else {
#[cfg(all(debug_assertions, feature = "tracing"))]
tracing::error!(
"At {}, tried to spawn a Future with Executor::spawn() before \
the Executor had been set.",
std::panic::Location::caller()
);
#[cfg(all(debug_assertions, not(feature = "tracing")))]
panic!(
"At {}, tried to spawn a Future with Executor::spawn() before \
the Executor had been set.",
std::panic::Location::caller()
);
}
}
/// Spawns a [`Future`] that cannot be sent across threads.
/// ```rust
/// use any_spawner::Executor;
///
/// # if false {
/// // spawn a thread-safe Future
/// Executor::spawn_local(async { /* ... */ });
/// # }
/// ```
#[track_caller]
pub fn spawn_local(fut: impl Future<Output = ()> + 'static) {
if let Some(spawner) = SPAWN_LOCAL.get() {
spawner(Box::pin(fut))
} else {
#[cfg(all(debug_assertions, feature = "tracing"))]
tracing::error!(
"At {}, tried to spawn a Future with Executor::spawn_local() \
before the Executor had been set.",
std::panic::Location::caller()
);
#[cfg(all(debug_assertions, not(feature = "tracing")))]
panic!(
"At {}, tried to spawn a Future with Executor::spawn_local() \
before the Executor had been set.",
std::panic::Location::caller()
);
}
}
/// Waits until the next "tick" of the current async executor.
pub async fn tick() {
let (tx, rx) = futures::channel::oneshot::channel();
Executor::spawn(async move {
_ = tx.send(());
});
_ = rx.await;
}
}
impl Executor {
/// Globally sets the [`tokio`] runtime as the executor used to spawn tasks.
///
/// Returns `Err(_)` if an executor has already been set.
///
/// Requires the `tokio` feature to be activated on this crate.
#[cfg(feature = "tokio")]
#[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
pub fn init_tokio() -> Result<(), ExecutorError> {
SPAWN
.set(|fut| {
tokio::spawn(fut);
})
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN_LOCAL
.set(|fut| {
tokio::task::spawn_local(fut);
})
.map_err(|_| ExecutorError::AlreadySet)?;
Ok(())
}
/// Globally sets the [`wasm-bindgen-futures`] runtime as the executor used to spawn tasks.
///
/// Returns `Err(_)` if an executor has already been set.
///
/// Requires the `wasm-bindgen` feature to be activated on this crate.
#[cfg(feature = "wasm-bindgen")]
#[cfg_attr(docsrs, doc(cfg(feature = "wasm-bindgen")))]
pub fn init_wasm_bindgen() -> Result<(), ExecutorError> {
SPAWN
.set(|fut| {
wasm_bindgen_futures::spawn_local(fut);
})
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN_LOCAL
.set(|fut| {
wasm_bindgen_futures::spawn_local(fut);
})
.map_err(|_| ExecutorError::AlreadySet)?;
Ok(())
}
/// Globally sets the [`glib`] runtime as the executor used to spawn tasks.
///
/// Returns `Err(_)` if an executor has already been set.
///
/// Requires the `glib` feature to be activated on this crate.
#[cfg(feature = "glib")]
#[cfg_attr(docsrs, doc(cfg(feature = "glib")))]
pub fn init_glib() -> Result<(), ExecutorError> {
SPAWN
.set(|fut| {
let main_context = glib::MainContext::default();
main_context.spawn(fut);
})
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN_LOCAL
.set(|fut| {
let main_context = glib::MainContext::default();
main_context.spawn_local(fut);
})
.map_err(|_| ExecutorError::AlreadySet)?;
Ok(())
}
/// Globally sets the [`futures`] 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 `futures-executor` feature to be activated on this crate.
#[cfg(feature = "futures-executor")]
#[cfg_attr(docsrs, doc(cfg(feature = "futures-executor")))]
pub fn init_futures_executor() -> Result<(), ExecutorError> {
use futures::{
executor::{LocalPool, ThreadPool},
task::{LocalSpawnExt, SpawnExt},
};
static THREAD_POOL: OnceLock<ThreadPool> = OnceLock::new();
thread_local! {
static LOCAL_POOL: LocalPool = LocalPool::new();
}
fn get_thread_pool() -> &'static ThreadPool {
THREAD_POOL.get_or_init(|| {
ThreadPool::new()
.expect("could not create futures executor ThreadPool")
})
}
SPAWN
.set(|fut| {
get_thread_pool()
.spawn(fut)
.expect("failed to spawn future");
})
.map_err(|_| ExecutorError::AlreadySet)?;
SPAWN_LOCAL
.set(|fut| {
LOCAL_POOL.with(|pool| {
let spawner = pool.spawner();
spawner.spawn_local(fut).expect("failed to spawn future");
});
})
.map_err(|_| ExecutorError::AlreadySet)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "futures-executor")]
#[test]
fn can_spawn_local_future() {
use crate::Executor;
use std::rc::Rc;
Executor::init_futures_executor().expect("couldn't set executor");
let rc = Rc::new(());
Executor::spawn_local(async {
_ = rc;
});
Executor::spawn(async {});
}
}

View File

@@ -1,12 +0,0 @@
[package]
name = "const_str_slice_concat"
edition = "2021"
version = "0.1.0"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
repository = "https://github.com/leptos-rs/leptos"
description = "Utilities for const concatenation of string slices."
rust-version.workspace = true
[dependencies]

View File

@@ -1 +0,0 @@
extend = { path = "../cargo-make/main.toml" }

View File

@@ -1,139 +0,0 @@
#![no_std]
#![forbid(unsafe_code)]
#![deny(missing_docs)]
//! Utilities for const concatenation of string slices.
pub(crate) const MAX_TEMPLATE_SIZE: usize = 4096;
/// Converts a zero-terminated buffer of bytes into a UTF-8 string.
pub const fn str_from_buffer(buf: &[u8; MAX_TEMPLATE_SIZE]) -> &str {
match core::ffi::CStr::from_bytes_until_nul(buf) {
Ok(cstr) => match cstr.to_str() {
Ok(str) => str,
Err(_) => panic!("TEMPLATE FAILURE"),
},
Err(_) => panic!("TEMPLATE FAILURE"),
}
}
/// Concatenates any number of static strings into a single array.
// credit to Rainer Stropek, "Constant fun," Rust Linz, June 2022
pub const fn const_concat(
strs: &'static [&'static str],
) -> [u8; MAX_TEMPLATE_SIZE] {
let mut buffer = [0; MAX_TEMPLATE_SIZE];
let mut position = 0;
let mut remaining = strs;
while let [current, tail @ ..] = remaining {
let x = current.as_bytes();
let mut i = 0;
// have it iterate over bytes manually, because, again,
// no mutable refernces in const fns
while i < x.len() {
buffer[position] = x[i];
position += 1;
i += 1;
}
remaining = tail;
}
buffer
}
/// Converts a zero-terminated buffer of bytes into a UTF-8 string with the given prefix.
pub const fn const_concat_with_prefix(
strs: &'static [&'static str],
prefix: &'static str,
suffix: &'static str,
) -> [u8; MAX_TEMPLATE_SIZE] {
let mut buffer = [0; MAX_TEMPLATE_SIZE];
let mut position = 0;
let mut remaining = strs;
while let [current, tail @ ..] = remaining {
let x = current.as_bytes();
let mut i = 0;
// have it iterate over bytes manually, because, again,
// no mutable refernces in const fns
while i < x.len() {
buffer[position] = x[i];
position += 1;
i += 1;
}
remaining = tail;
}
if buffer[0] == 0 {
buffer
} else {
let mut new_buf = [0; MAX_TEMPLATE_SIZE];
let prefix = prefix.as_bytes();
let suffix = suffix.as_bytes();
let mut position = 0;
let mut i = 0;
while i < prefix.len() {
new_buf[position] = prefix[i];
position += 1;
i += 1;
}
i = 0;
while i < buffer.len() {
if buffer[i] == 0 {
break;
}
new_buf[position] = buffer[i];
position += 1;
i += 1;
}
i = 0;
while i < suffix.len() {
new_buf[position] = suffix[i];
position += 1;
i += 1;
}
new_buf
}
}
/// Converts any number of strings into a UTF-8 string, separated by the given string.
pub const fn const_concat_with_separator(
strs: &[&str],
separator: &'static str,
) -> [u8; MAX_TEMPLATE_SIZE] {
let mut buffer = [0; MAX_TEMPLATE_SIZE];
let mut position = 0;
let mut remaining = strs;
while let [current, tail @ ..] = remaining {
let x = current.as_bytes();
let mut i = 0;
// have it iterate over bytes manually, because, again,
// no mutable refernces in const fns
while i < x.len() {
buffer[position] = x[i];
position += 1;
i += 1;
}
if !x.is_empty() {
let mut position = 0;
let separator = separator.as_bytes();
while i < separator.len() {
buffer[position] = separator[i];
position += 1;
i += 1;
}
}
remaining = tail;
}
buffer
}

View File

@@ -1,55 +1,56 @@
# Оглавление
# Summary
- [Вступление](./01_introduction.md)
- [Начало работы](./getting_started/README.md)
- [Introduction](./01_introduction.md)
- [Getting Started](./getting_started/README.md)
- [Leptos DX](./getting_started/leptos_dx.md)
- [Сообщество Leptos и leptos-* Крейты](./getting_started/community_crates.md)
- [Часть 1: Построение UI](./view/README.md)
- [Простой компонент](./view/01_basic_component.md)
- [Динамические атрибуты](./view/02_dynamic_attributes.md)
- [Компоненты и свойства](./view/03_components.md)
- [Итерирование](./view/04_iteration.md)
- [Итерирование более сложных структур через `<For>`](./view/04b_iteration.md)
- [Формы и поля ввода](./view/05_forms.md)
- [Порядок выполнения](./view/06_control_flow.md)
- [Обработка ошибок](./view/07_errors.md)
- [Общение Родитель-Ребёнок в дереве компонентов](./view/08_parent_child.md)
- [Передача Детей другим компонентам](./view/09_component_children.md)
- [Без макросов: синтаксис билдера View](./view/builder.md)
- [Реактивность](./reactivity/README.md)
- [Работа с сигналами](./reactivity/working_with_signals.md)
- [Реагирование на изменения с помощью `create_effect`](./reactivity/14_create_effect.md)
- [Примечание: Реактивность и функции](./reactivity/interlude_functions.md)
- [Тестирование](./testing.md)
- [Асинхронность](./async/README.md)
- [Подгрузка данных с помощью ресурсов (Resource)](./async/10_resources.md)
- [Ожидания (Suspense)](./async/11_suspense.md)
- [Переходы (Transition)](./async/12_transition.md)
- [Действия (Action)](./async/13_actions.md)
- [Примечание: Пробрасывание дочерних элементов](./interlude_projecting_children.md)
- [Управление глобальным состоянием](./15_global_state.md)
- [Маршрутизатор URL](./router/README.md)
- [Определение `<Routes/>`](./router/16_routes.md)
- [Вложенная маршрутизация](./router/17_nested_routing.md)
- [Параметры в пути и в строке запроса](./router/18_params_and_queries.md)
- [The Leptos Community and leptos-* Crates](./getting_started/community_crates.md)
- [Part 1: Building User Interfaces](./view/README.md)
- [A Basic Component](./view/01_basic_component.md)
- [Dynamic Attributes](./view/02_dynamic_attributes.md)
- [Components and Props](./view/03_components.md)
- [Iteration](./view/04_iteration.md)
- [Iterating over More Complex Data](./view/04b_iteration.md)
- [Forms and Inputs](./view/05_forms.md)
- [Control Flow](./view/06_control_flow.md)
- [Error Handling](./view/07_errors.md)
- [Parent-Child Communication](./view/08_parent_child.md)
- [Passing Children to Components](./view/09_component_children.md)
- [No Macros: The View Builder Syntax](./view/builder.md)
- [Reactivity](./reactivity/README.md)
- [Working with Signals](./reactivity/working_with_signals.md)
- [Responding to Changes with `create_effect`](./reactivity/14_create_effect.md)
- [Interlude: Reactivity and Functions](./reactivity/interlude_functions.md)
- [Testing](./testing.md)
- [Async](./async/README.md)
- [Loading Data with Resources](./async/10_resources.md)
- [Suspense](./async/11_suspense.md)
- [Transition](./async/12_transition.md)
- [Actions](./async/13_actions.md)
- [Interlude: Projecting Children](./interlude_projecting_children.md)
- [Global State Management](./15_global_state.md)
- [Router](./router/README.md)
- [Defining `<Routes/>`](./router/16_routes.md)
- [Nested Routing](./router/17_nested_routing.md)
- [Params and Queries](./router/18_params_and_queries.md)
- [`<A/>`](./router/19_a.md)
- [`<Form/>`](./router/20_form.md)
- [Примечание: Стили](./interlude_styling.md)
- [Метаданные](./metadata.md)
- [Рендеринг на стороне клиента (CSR): Заключение](./csr_wrapping_up.md)
- [Часть 2: Рендеринг на стороне сервера (SSR)](./ssr/README.md)
- [Interlude: Styling](./interlude_styling.md)
- [Metadata](./metadata.md)
- [Client-Side Rendering: Wrapping Up](./csr_wrapping_up.md)
- [Part 2: Server Side Rendering](./ssr/README.md)
- [`cargo-leptos`](./ssr/21_cargo_leptos.md)
- [Жизненный цикл загрузки страницы](./ssr/22_life_cycle.md)
- [Асинхронный рендеринг и режимы SSR](./ssr/23_ssr_modes.md)
- [Баги возникающие при гидратации](./ssr/24_hydration_bugs.md)
- [Работа с сервером](./server/README.md)
- [Серверные функции](./server/25_server_functions.md)
- [Экстракторы](./server/26_extractors.md)
- [Ответы и перенаправления](./server/27_response.md)
- [Постепенное улучшение и Изящная деградация](./progressive_enhancement/README.md)
- [`<ActionForm/>`](./progressive_enhancement/action_form.md)
- [Развёртывание](./deployment/README.md)
- [Оптимизация размера бинарника WASM](./deployment/binary_size.md)
- [Руководство: Острова](./islands.md)
- [The Life of a Page Load](./ssr/22_life_cycle.md)
- [Async Rendering and SSR “Modes”](./ssr/23_ssr_modes.md)
- [Hydration Bugs](./ssr/24_hydration_bugs.md)
- [Working with the Server](./server/README.md)
- [Server Functions](./server/25_server_functions.md)
- [Extractors](./server/26_extractors.md)
- [Responses and Redirects](./server/27_response.md)
- [Progressive Enhancement and Graceful Degradation](./progressive_enhancement/README.md)
- [`<ActionForm/>`s](./progressive_enhancement/action_form.md)
- [Deployment](./deployment/README.md)
- [Optimizing WASM Binary Size](./deployment/binary_size.md)
- [Guide: Islands](./islands.md)
- [Appendix: How Does the Reactive System Work?](./appendix_reactive_graph.md)
- [Приложение: Как работает реактивная система?](./appendix_reactive_graph.md)

View File

@@ -1,13 +0,0 @@
[package]
name = "either_of"
edition = "2021"
version = "0.1.0"
authors = ["Greg Johnston"]
license = "MIT"
readme = "../README.md"
repository = "https://github.com/leptos-rs/leptos"
description = "Utilities for working with enumerated types that contain one of 2..n other types."
rust-version.workspace = true
[dependencies]
pin-project-lite = "0.2"

View File

@@ -1 +0,0 @@
extend = { path = "../cargo-make/main.toml" }

View File

@@ -1 +0,0 @@
Utilities for working with enumerated types that contain one of `2..n` other types.

View File

@@ -1,135 +0,0 @@
#![no_std]
#![forbid(unsafe_code)]
//! Utilities for working with enumerated types that contain one of `2..n` other types.
use core::{
fmt::Display,
future::Future,
pin::Pin,
task::{Context, Poll},
};
use pin_project_lite::pin_project;
#[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),*) => {
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
pub enum $name<$($ty,)*> {
$($ty ($ty),)*
}
impl<$($ty,)*> Display for $name<$($ty,)*>
where
$($ty: Display,)*
{
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
$($name::$ty(this) => this.fmt(f),)*
}
}
}
impl<Item, $($ty,)*> Iterator for $name<$($ty,)*>
where
$($ty: Iterator<Item = Item>,)*
{
type Item = Item;
fn next(&mut self) -> Option<Self::Item> {
match self {
$($name::$ty(i) => i.next(),)*
}
}
}
pin_project! {
#[project = $fut_proj]
pub enum $fut_name<$($ty,)*> {
$($ty { #[pin] inner: $ty },)*
}
}
impl<$($ty,)*> Future for $fut_name<$($ty,)*>
where
$($ty: Future,)*
{
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::$ty { inner } => match inner.poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(inner) => Poll::Ready($name::$ty(inner)),
},)*
}
}
}
}
}
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);

View File

@@ -47,11 +47,11 @@ CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = [
workspace = false
description = "Generate the list of workspace members"
script = '''
examples=$(ls |
grep -v .md |
grep -v Makefile.toml |
grep -v cargo-make |
grep -v gtk |
examples=$(ls |
grep -v .md |
grep -v Makefile.toml |
grep -v cargo-make |
grep -v gtk |
jq -R -s -c 'split("\n")[:-1]')
echo "CARGO_MAKE_CRATE_WORKSPACE_MEMBERS = $examples"
'''

View File

@@ -11,6 +11,7 @@ actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["macros"] }
console_error_panic_hook = "0.1"
cfg-if = "1"
http = { version = "0.2", optional = true }
leptos = { path = "../../leptos" }
leptos_meta = { path = "../../meta" }
leptos_actix = { path = "../../integrations/actix", optional = true }
@@ -19,8 +20,8 @@ wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
[features]
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:actix-files",
"dep:actix-web",

View File

@@ -1,18 +1,27 @@
use leptos::{logging, prelude::*};
use leptos_router::{
components::{FlatRoutes, Route, Router},
StaticSegment,
};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
// injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/leptos_start.css"/>
// sets the document title
<Title text="Welcome to Leptos"/>
// content for this welcome page
<Router>
<main id="app">
<FlatRoutes fallback=NotFound>
<Route path=StaticSegment("") view=HomePage/>
</FlatRoutes>
<Routes>
<Route path="" view=HomePage/>
<Route path="/*any" view=NotFound/>
</Routes>
</main>
</Router>
}
@@ -34,7 +43,7 @@ async fn do_something(
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
let do_something_action = ServerAction::<DoSomething>::new();
let do_something_action = Action::<DoSomething, _>::server();
let value = Signal::derive(move || {
do_something_action
.value()
@@ -48,12 +57,17 @@ fn HomePage() -> impl IntoView {
view! {
<h1>"Test the action form!"</h1>
<ErrorBoundary fallback=move |error| {
move || format!("{:#?}", error.get())
}>
<pre>{value}</pre>
<ActionForm action=do_something_action attr:class="form">
<label>"Should error: "<input type="checkbox" name="should_error"/></label>
<ErrorBoundary fallback=move |error| format!("{:#?}", error
.get()
.into_iter()
.next()
.unwrap()
.1.into_inner()
.to_string())
>
{value}
<ActionForm action=do_something_action class="form">
<label>Should error: <input type="checkbox" name="should_error"/></label>
<button type="submit">Submit</button>
</ActionForm>
</ErrorBoundary>
@@ -77,5 +91,7 @@ fn NotFound() -> impl IntoView {
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
}
view! { <h1>"Not Found"</h1> }
view! {
<h1>"Not Found"</h1>
}
}

View File

@@ -1,11 +1,18 @@
pub mod app;
use cfg_if::cfg_if;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use app::*;
cfg_if! {
if #[cfg(feature = "hydrate")] {
console_error_panic_hook::set_once();
use wasm_bindgen::prelude::wasm_bindgen;
leptos::mount::hydrate_body(App);
#[wasm_bindgen]
pub fn hydrate() {
use app::*;
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}
}
}

View File

@@ -4,47 +4,25 @@ async fn main() -> std::io::Result<()> {
use action_form_error_handling::app::*;
use actix_files::Files;
use actix_web::*;
use leptos::prelude::*;
use leptos::*;
use leptos_actix::{generate_route_list, LeptosRoutes};
use leptos_meta::MetaTags;
// Generate the list of routes in your Leptos App
let conf = get_configuration(None).unwrap();
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
println!("listening on http://{}", &addr);
HttpServer::new(move || {
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
let leptos_options = &conf.leptos_options;
let site_root = &leptos_options.site_root;
App::new()
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
// serve JS/WASM/CSS from `pkg`
.service(Files::new("/pkg", format!("{site_root}/pkg")))
.leptos_routes(routes, {
let leptos_options = leptos_options.clone();
move || {
use leptos::prelude::*;
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<AutoReload options=leptos_options.clone()/>
<HydrationScripts options=leptos_options.clone()/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}})
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
.app_data(web::Data::new(leptos_options.to_owned()))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
@@ -66,9 +44,10 @@ pub fn main() {
// prefer using `cargo leptos serve` instead
// to run: `trunk serve --open --features csr`
use action_form_error_handling::app::*;
use leptos::prelude::*;
use leptos::*;
use wasm_bindgen::prelude::wasm_bindgen;
console_error_panic_hook::set_once();
mount_to_body(App);
leptos::mount_to_body(App);
}

View File

@@ -1,8 +1,5 @@
[workspace]
# The empty workspace here is to keep rust-analyzer satisfied
[package]
name = "counter_dwarf_debug"
name = "animated-show"
version = "0.1.0"
edition = "2021"
@@ -10,13 +7,8 @@ edition = "2021"
codegen-units = 1
lto = true
[dependencies]
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
console_log = "1"
log = "0.4"
console_error_panic_hook = "0.1.7"
[dev-dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-test = "0.3.0"
web-sys = "0.3"

View File

@@ -1,3 +1,4 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/trunk_server.toml" },
]

View File

@@ -0,0 +1,14 @@
# Animated Show Example
This is a very simple example of the `<AnimatedShow>` component.
The `<AnimatedShow>` component is an extension for the `<Show>` component and it will not take in a fallback, but it will unmount the component from the DOM after a given duration. This makes it possible to have really easy unmount animations with just
CSS.
## Getting Started
See the [Examples README](../README.md) for setup and run instructions.
## Quick Start
Run `trunk serve --open` to run this example.

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<link data-trunk rel="rust" data-wasm-opt="z"/>
<link data-trunk rel="icon" type="image/ico" href="/public/favicon.ico"/>
<style>
.hover-me {
width: 100px;
margin: 1rem;
padding: 1rem;
text-align: center;
cursor: pointer;
border: 1px solid grey;
}
.here-i-am {
width: 100px;
margin: 1rem;
padding: 1rem;
text-align: center;
color: white;
font-weight: bold;
background: black;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.fade-in-1000 {
animation: 1000ms fade-in forwards;
}
.fade-out-1000 {
animation: 1000ms fade-out forwards;
}
</style>
</head>
<body></body>
</html>

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,34 @@
use core::time::Duration;
use leptos::*;
#[component]
pub fn App() -> impl IntoView {
let show = create_rw_signal(false);
// the CSS classes in this example are just written directly inside the `index.html`
view! {
<div
class="hover-me"
on:mouseenter=move |_| show.set(true)
on:mouseleave=move |_| show.set(false)
>
"Hover Me"
</div>
<AnimatedShow
when=show
// optional CSS class which will be applied if `when == true`
show_class="fade-in-1000"
// optional CSS class which will be applied if `when == false` and before the
// `hide_delay` starts -> makes CSS unmount animations really easy
hide_class="fade-out-1000"
// the given unmount delay which should match your unmount animation duration
hide_delay=Duration::from_millis(1000)
>
// provide any `Children` inside here
<div class="here-i-am">
"Here I Am!"
</div>
</AnimatedShow>
}
}

View File

@@ -0,0 +1,8 @@
use animated_show::App;
use leptos::*;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(App);
}

View File

@@ -1,44 +0,0 @@
extend = [
{ path = "./lint.toml" }
]
[tasks.make-target-site-dir]
command = "mkdir"
args = ["-p", "target/site"]
[tasks.install-cargo-leptos]
install_crate = { crate_name = "cargo-leptos", binary = "cargo-leptos", test_arg = "--help" }
[tasks.cargo-leptos-e2e]
command = "cargo"
args = ["leptos", "end-to-end"]
[tasks.build]
clear = true
command = "cargo"
dependencies = ["make-target-site-dir"]
args = ["leptos", "build", "--release", "-P"]
[tasks.check]
clear = true
dependencies = ["check-debug", "check-release"]
[tasks.check-debug]
toolchain = "stable"
command = "cargo"
args = ["check-all-features"]
install_crate = "cargo-all-features"
[tasks.check-release]
toolchain = "stable"
command = "cargo"
args = ["check-all-features", "--release"]
install_crate = "cargo-all-features"
[tasks.lint]
dependencies = ["make-target-site-dir", "check-style"]
[tasks.start-client]
dependencies = ["install-cargo-leptos"]
command = "cargo"
args = ["leptos", "watch", "--release", "-P"]

View File

@@ -1,11 +1,9 @@
[tasks.build]
install_crate = { crate_name = "wasm-pack", binary = "wasm-pack", test_arg = "--help" }
clear = true
command = "deno"
args = ["task", "build"]
[tasks.start-client]
install_crate = { crate_name = "wasm-pack", binary = "wasm-pack", test_arg = "--help" }
command = "deno"
args = ["task", "start"]

View File

@@ -4,7 +4,6 @@ version = "0.1.0"
edition = "2021"
[profile.release]
opt-level = 'z'
codegen-units = 1
lto = true
@@ -13,7 +12,6 @@ leptos = { path = "../../leptos", features = ["csr"] }
console_log = "1"
log = "0.4"
console_error_panic_hook = "0.1.7"
gloo-timers = { version = "0.3.0", features = ["futures"] }
[dev-dependencies]
wasm-bindgen = "0.2"

View File

@@ -1,4 +1,4 @@
use leptos::prelude::*;
use leptos::*;
/// A simple counter component.
///
@@ -10,12 +10,12 @@ pub fn SimpleCounter(
/// The change that should be applied each time the button is clicked.
step: i32,
) -> impl IntoView {
let (value, set_value) = signal(initial_value);
let (value, set_value) = create_signal(initial_value);
view! {
<div>
<button on:click=move |_| set_value.set(0)>"Clear"</button>
<button on:click=move |_| *set_value.write() -= step>"-1"</button>
<button on:click=move |_| set_value.update(|value| *value -= step)>"-1"</button>
<span>"Value: " {value} "!"</span>
<button on:click=move |_| set_value.update(|value| *value += step)>"+1"</button>
</div>

View File

@@ -1,10 +1,15 @@
use counter::SimpleCounter;
use leptos::prelude::*;
use leptos::*;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(|| {
view! { <SimpleCounter initial_value=0 step=1/> }
view! {
<SimpleCounter
initial_value=0
step=1
/>
}
})
}

View File

@@ -1,21 +1,19 @@
use counter::*;
use leptos::mount::mount_to;
use leptos::prelude::*;
use leptos::spawn::tick;
use leptos::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
async fn clear() {
let document = document();
fn clear() {
let document = leptos::document();
let test_wrapper = document.create_element("section").unwrap();
let _ = document.body().unwrap().append_child(&test_wrapper);
// start by rendering our counter and mounting it to the DOM
// note that we start at the initial value of 10
let _dispose = mount_to(
mount_to(
test_wrapper.clone().unchecked_into(),
|| view! { <SimpleCounter initial_value=10 step=1/> },
);
@@ -32,63 +30,59 @@ async fn clear() {
// now let's click the `clear` button
clear.click();
// the reactive system is built on top of the async system, so changes are not reflected
// synchronously in the DOM
// in order to detect the changes here, we'll just yield for a brief time after each change,
// allowing the effects that update the view to run
tick().await;
// now let's test the <div> against the expected value
// we can do this by testing its `outerHTML`
assert_eq!(div.outer_html(), {
// it's as if we're creating it with a value of 0, right?
let (value, _set_value) = signal(0);
let runtime = create_runtime();
assert_eq!(
div.outer_html(),
// here we spawn a mini reactive system, just to render the
// test case
{
// it's as if we're creating it with a value of 0, right?
let (value, _set_value) = create_signal(0);
// we can remove the event listeners because they're not rendered to HTML
view! {
<div>
<button>"Clear"</button>
<button>"-1"</button>
<span>"Value: " {value} "!"</span>
<button>"+1"</button>
</div>
// we can remove the event listeners because they're not rendered to HTML
view! {
<div>
<button>"Clear"</button>
<button>"-1"</button>
<span>"Value: " {value} "!"</span>
<button>"+1"</button>
</div>
}
// the view returned an HtmlElement<Div>, which is a smart pointer for
// a DOM element. So we can still just call .outer_html()
.outer_html()
}
// Leptos supports multiple backend renderers for HTML elements
// .into_view() here is just a convenient way of specifying "use the regular DOM renderer"
.into_view()
// views are lazy -- they describe a DOM tree but don't create it yet
// calling .build() will actually build the DOM elements
.build()
// .build() returned an ElementState, which is a smart pointer for
// a DOM element. So we can still just call .outer_html(), which access the outerHTML on
// the actual DOM element
.outer_html()
});
);
// There's actually an easier way to do this...
// We can just test against a <SimpleCounter/> with the initial value 0
assert_eq!(test_wrapper.inner_html(), {
let comparison_wrapper = document.create_element("section").unwrap();
let _dispose = mount_to(
leptos::mount_to(
comparison_wrapper.clone().unchecked_into(),
|| view! { <SimpleCounter initial_value=0 step=1/>},
);
comparison_wrapper.inner_html()
});
runtime.dispose();
}
#[wasm_bindgen_test]
async fn inc() {
let document = document();
fn inc() {
let document = leptos::document();
let test_wrapper = document.create_element("section").unwrap();
let _ = document.body().unwrap().append_child(&test_wrapper);
let _dispose = mount_to(
mount_to(
test_wrapper.clone().unchecked_into(),
|| view! { <SimpleCounter initial_value=0 step=1/> },
);
// You can do testing with vanilla DOM operations
let _document = leptos::document();
let div = test_wrapper.query_selector("div").unwrap().unwrap();
let clear = div
.first_child()
@@ -114,8 +108,6 @@ async fn inc() {
inc.click();
inc.click();
tick().await;
assert_eq!(text.text_content(), Some("Value: 2!".to_string()));
dec.click();
@@ -123,21 +115,19 @@ async fn inc() {
dec.click();
dec.click();
tick().await;
assert_eq!(text.text_content(), Some("Value: -2!".to_string()));
clear.click();
tick().await;
assert_eq!(text.text_content(), Some("Value: 0!".to_string()));
let runtime = create_runtime();
// Or you can test against a sample view!
assert_eq!(
div.outer_html(),
{
let (value, _) = signal(0);
let (value, _) = create_signal(0);
view! {
<div>
<button>"Clear"</button>
@@ -147,20 +137,16 @@ async fn inc() {
</div>
}
}
.into_view()
.build()
.outer_html()
);
inc.click();
tick().await;
assert_eq!(
div.outer_html(),
{
// because we've clicked, it's as if the signal is starting at 1
let (value, _) = signal(1);
let (value, _) = create_signal(1);
view! {
<div>
<button>"Clear"</button>
@@ -170,8 +156,8 @@ async fn inc() {
</div>
}
}
.into_view()
.build()
.outer_html()
);
runtime.dispose();
}

View File

@@ -20,6 +20,7 @@ futures = "0.3"
lazy_static = "1"
leptos = { path = "../../leptos" }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
log = "0.4"
once_cell = "1.18"
@@ -28,16 +29,16 @@ wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
simple_logger = "4.3"
tracing = { version = "0.1", optional = true }
send_wrapper = "0.6.0"
[features]
hydrate = ["leptos/hydrate"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:actix-files",
"dep:actix-web",
"dep:tracing",
"leptos/ssr",
"leptos_actix",
"leptos_meta/ssr",
"leptos_router/ssr",
]

View File

@@ -1,14 +1,13 @@
use leptos::{prelude::*, reactive_graph::actions::Action};
use leptos_router::{
components::{FlatRoutes, Route, Router, A},
StaticSegment,
};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[cfg(feature = "ssr")]
use tracing::instrument;
#[cfg(feature = "ssr")]
pub mod ssr_imports {
pub use broadcaster::BroadcastChannel;
pub use once_cell::sync::OnceCell;
pub use std::sync::atomic::{AtomicI32, Ordering};
pub static COUNT: AtomicI32 = AtomicI32::new(0);
@@ -16,6 +15,14 @@ pub mod ssr_imports {
lazy_static::lazy_static! {
pub static ref COUNT_CHANNEL: BroadcastChannel<i32> = BroadcastChannel::new();
}
static LOG_INIT: OnceCell<()> = OnceCell::new();
pub fn init_logging() {
LOG_INIT.get_or_init(|| {
simple_logger::SimpleLogger::new().env().init().unwrap();
});
}
}
#[server]
@@ -52,6 +59,10 @@ pub async fn clear_server_count() -> Result<i32, ServerFnError> {
}
#[component]
pub fn Counters() -> impl IntoView {
#[cfg(feature = "ssr")]
ssr_imports::init_logging();
provide_meta_context();
view! {
<Router>
<header>
@@ -74,12 +85,28 @@ pub fn Counters() -> impl IntoView {
</li>
</ul>
</nav>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<main>
<FlatRoutes fallback=|| "Not found.">
<Route path=StaticSegment("") view=Counter/>
<Route path=StaticSegment("form") view=FormCounter/>
<Route path=StaticSegment("multi") view=MultiuserCounter/>
</FlatRoutes>
<Routes>
<Route
path=""
view=|| {
view! { <Counter/> }
}
/>
<Route
path="form"
view=|| {
view! { <FormCounter/> }
}
/>
<Route
path="multi"
view=|| {
view! { <MultiuserCounter/> }
}
/>
</Routes>
</main>
</Router>
}
@@ -91,10 +118,10 @@ pub fn Counters() -> impl IntoView {
// This is the typical pattern for a CRUD app
#[component]
pub fn Counter() -> impl IntoView {
let dec = Action::new(|_: &()| adjust_server_count(-1, "decing".into()));
let inc = Action::new(|_: &()| adjust_server_count(1, "incing".into()));
let clear = Action::new(|_: &()| clear_server_count());
let counter = Resource::new(
let dec = create_action(|_: &()| adjust_server_count(-1, "decing".into()));
let inc = create_action(|_: &()| adjust_server_count(1, "incing".into()));
let clear = create_action(|_: &()| clear_server_count());
let counter = create_resource(
move || {
(
dec.version().get(),
@@ -111,14 +138,27 @@ pub fn Counter() -> impl IntoView {
<p>
"This counter sets the value on the server and automatically reloads the new value."
</p>
<ErrorBoundary fallback=|errors| move || format!("Error: {:#?}", errors.get())>
<div>
<button on:click=move |_| clear.dispatch(())>"Clear"</button>
<button on:click=move |_| dec.dispatch(())>"-1"</button>
<span>"Value: " <Suspense>{counter} "!"</Suspense></span>
<button on:click=move |_| inc.dispatch(())>"+1"</button>
</div>
</ErrorBoundary>
<div>
<button on:click=move |_| clear.dispatch(())>"Clear"</button>
<button on:click=move |_| dec.dispatch(())>"-1"</button>
<span>
"Value: "
<Suspense>
{move || counter.and_then(|count| *count)} "!"
</Suspense>
</span>
<button on:click=move |_| inc.dispatch(())>"+1"</button>
</div>
<Suspense>
{move || {
counter.get().and_then(|res| match res {
Ok(_) => None,
Err(e) => Some(e),
}).map(|msg| {
view! { <p>"Error: " {msg.to_string()}</p> }
})
}}
</Suspense>
</div>
}
}
@@ -130,10 +170,10 @@ pub fn Counter() -> impl IntoView {
pub fn FormCounter() -> impl IntoView {
// these struct names are auto-generated by #[server]
// they are just the PascalCased versions of the function names
let adjust = ServerAction::<AdjustServerCount>::new();
let clear = ServerAction::<ClearServerCount>::new();
let adjust = create_server_action::<AdjustServerCount>();
let clear = create_server_action::<ClearServerCount>();
let counter = Resource::new(
let counter = create_resource(
move || (adjust.version().get(), clear.version().get()),
|_| {
log::debug!("FormCounter running fetcher");
@@ -164,7 +204,7 @@ pub fn FormCounter() -> impl IntoView {
<input type="hidden" name="msg" value="form value down"/>
<input type="submit" value="-1"/>
</ActionForm>
<span>"Value: " <Suspense>{value} "!"</Suspense></span>
<span>"Value: " <Suspense>{move || value().to_string()} "!"</Suspense></span>
<ActionForm action=adjust>
<input type="hidden" name="delta" value="1"/>
<input type="hidden" name="msg" value="form value up"/>
@@ -182,21 +222,19 @@ pub fn FormCounter() -> impl IntoView {
#[component]
pub fn MultiuserCounter() -> impl IntoView {
let dec =
Action::new(|_: &()| adjust_server_count(-1, "dec dec goose".into()));
create_action(|_: &()| adjust_server_count(-1, "dec dec goose".into()));
let inc =
Action::new(|_: &()| adjust_server_count(1, "inc inc moose".into()));
let clear = Action::new(|_: &()| clear_server_count());
create_action(|_: &()| adjust_server_count(1, "inc inc moose".into()));
let clear = create_action(|_: &()| clear_server_count());
#[cfg(not(feature = "ssr"))]
let multiplayer_value = {
use futures::StreamExt;
use send_wrapper::SendWrapper;
let mut source = SendWrapper::new(
let mut source =
gloo_net::eventsource::futures::EventSource::new("/api/events")
.expect("couldn't connect to SSE stream"),
);
let s = ReadSignal::from_stream_unsync(
.expect("couldn't connect to SSE stream");
let s = create_signal_from_stream(
source
.subscribe("message")
.unwrap()
@@ -210,12 +248,12 @@ pub fn MultiuserCounter() -> impl IntoView {
}),
);
on_cleanup(move || source.take().close());
on_cleanup(move || source.close());
s
};
#[cfg(feature = "ssr")]
let (multiplayer_value, _) = signal(None::<i32>);
let (multiplayer_value, _) = create_signal(None::<i32>);
view! {
<div>

View File

@@ -3,10 +3,11 @@ pub mod counters;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::counters::Counters;
use crate::counters::*;
use leptos::*;
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(Counters);
mount_to_body(Counters);
}

View File

@@ -3,6 +3,7 @@ mod counters;
use crate::counters::*;
use actix_files::Files;
use actix_web::*;
use leptos::*;
use leptos_actix::{generate_route_list, LeptosRoutes};
#[get("/api/events")]
@@ -26,44 +27,26 @@ async fn counter_events() -> impl Responder {
#[actix_web::main]
async fn main() -> std::io::Result<()> {
use leptos::prelude::*;
// Setting this to None means we'll be using cargo-leptos and its env vars.
// when not using cargo-leptos None must be replaced with Some("Cargo.toml")
let conf = get_configuration(None).unwrap();
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
println!("listening on http://{}", &addr);
let routes = generate_route_list(Counters);
HttpServer::new(move || {
// Generate the list of routes in your Leptos App
let routes = generate_route_list(Counters);
let leptos_options = &conf.leptos_options;
let site_root = &leptos_options.site_root;
App::new()
.service(counter_events)
.leptos_routes(routes, {
let leptos_options = leptos_options.clone();
move || {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<AutoReload options=leptos_options.clone()/>
<HydrationScripts options=leptos_options.clone()/>
</head>
<body>
<Counters/>
</body>
</html>
}
}})
.leptos_routes(
leptos_options.to_owned(),
routes.to_owned(),
Counters,
)
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
.run()

View File

@@ -9,7 +9,9 @@ lto = true
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
leptos_router = { path = "../../router", features = [] }
leptos_router = { path = "../../router", features = ["csr"] }
console_log = "1"
log = "0.4"
console_error_panic_hook = "0.1.7"
[dev-dependencies]

View File

@@ -1,17 +1,17 @@
use leptos::prelude::*;
use leptos_router::hooks::query_signal;
use leptos::*;
use leptos_router::*;
/// A simple counter component.
///
/// You can use doc comments like this to document your component.
#[component]
pub fn SimpleQueryCounter() -> impl IntoView {
let (count, set_count) = query_signal::<i32>("count");
let (count, set_count) = create_query_signal::<i32>("count");
let clear = move |_| set_count.set(None);
let decrement = move |_| set_count.set(Some(count.get().unwrap_or(0) - 1));
let increment = move |_| set_count.set(Some(count.get().unwrap_or(0) + 1));
let (msg, set_msg) = query_signal::<String>("message");
let (msg, set_msg) = create_query_signal::<String>("message");
let update_msg = move |ev| {
let new_msg = event_target_value(&ev);
if new_msg.is_empty() {

View File

@@ -1,13 +1,16 @@
use counter_url_query::SimpleQueryCounter;
use leptos::prelude::*;
use leptos_router::components::Router;
use leptos::*;
use leptos_router::*;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount::mount_to_body(|| {
mount_to_body(|| {
view! {
<Router>
<SimpleQueryCounter/>
<Routes>
<Route path="" view=SimpleQueryCounter />
</Routes>
</Router>
}
})

View File

@@ -10,6 +10,8 @@ lto = true
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
console_log = "1"
log = "0.4"
console_error_panic_hook = "0.1.7"
[dev-dependencies]

View File

@@ -1,16 +1,9 @@
use leptos::{
ev,
html::{button, div, span},
prelude::*,
};
use leptos::{html::*, *};
/// A simple counter view.
// A component is really just a function call: it runs once to create the DOM and reactive system
pub fn counter(initial_value: i32, step: u32) -> impl IntoView {
let count = RwSignal::new(Count::new(initial_value, step));
Effect::new(move |_| {
leptos::logging::log!("count = {:?}", count.get());
});
// the function name is the same as the HTML tag name
div()
@@ -51,7 +44,6 @@ impl Count {
}
pub fn value(&self) -> i32 {
leptos::logging::log!("value = {}", self.value);
self.value
}

View File

@@ -1,7 +1,9 @@
use counter_without_macros::counter;
use leptos::*;
/// Show the counter
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount::mount_to_body(|| counter(0, 1))
mount_to_body(|| counter(0, 1))
}

View File

@@ -1,5 +1,5 @@
use counter_without_macros::counter;
use leptos::{prelude::*, spawn::tick};
use leptos::*;
use pretty_assertions::assert_eq;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
@@ -8,32 +8,27 @@ use web_sys::HtmlElement;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
async fn should_increment_counter() {
fn should_increment_counter() {
open_counter();
click_increment();
click_increment();
// reactive changes run asynchronously, so yield briefly before observing the DOM
tick().await;
assert_eq!(see_text(), Some("Value: 2!".to_string()));
}
#[wasm_bindgen_test]
async fn should_decrement_counter() {
fn should_decrement_counter() {
open_counter();
click_decrement();
click_decrement();
tick().await;
assert_eq!(see_text(), Some("Value: -2!".to_string()));
}
#[wasm_bindgen_test]
async fn should_clear_counter() {
fn should_clear_counter() {
open_counter();
click_increment();
@@ -41,18 +36,18 @@ async fn should_clear_counter() {
click_clear();
tick().await;
assert_eq!(see_text(), Some("Value: 0!".to_string()));
}
fn open_counter() {
remove_existing_counter();
leptos::mount::mount_to_body(move || counter(0, 1));
mount_to_body(move || counter(0, 1));
}
fn remove_existing_counter() {
if let Some(counter) = document().query_selector("body div").unwrap() {
if let Some(counter) =
leptos::document().query_selector("body div").unwrap()
{
counter.remove();
}
}
@@ -79,7 +74,7 @@ fn see_text() -> Option<String> {
fn find_by_text(text: &str) -> HtmlElement {
let xpath = format!("//*[text()='{}']", text);
let document = document();
let document = leptos::document();
document
.evaluate(&xpath, &document)
.unwrap()

View File

@@ -5,6 +5,8 @@ edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
log = "0.4"
console_log = "1"
console_error_panic_hook = "0.1.7"
[dev-dependencies]

View File

@@ -1,8 +1,8 @@
use leptos::prelude::{signal::*, *};
use leptos::*;
const MANY_COUNTERS: usize = 1000;
type CounterHolder = Vec<(usize, ArcRwSignal<i32>)>;
type CounterHolder = Vec<(usize, (ReadSignal<i32>, WriteSignal<i32>))>;
#[derive(Copy, Clone)]
struct CounterUpdater {
@@ -11,13 +11,13 @@ struct CounterUpdater {
#[component]
pub fn Counters() -> impl IntoView {
let (next_counter_id, set_next_counter_id) = signal(0);
let (counters, set_counters) = signal::<CounterHolder>(vec![]);
let (next_counter_id, set_next_counter_id) = create_signal(0);
let (counters, set_counters) = create_signal::<CounterHolder>(vec![]);
provide_context(CounterUpdater { set_counters });
let add_counter = move |_| {
let id = next_counter_id.get();
let sig = ArcRwSignal::new(0);
let sig = create_signal(0);
set_counters.update(move |counters| counters.push((id, sig)));
set_next_counter_id.update(|id| *id += 1);
};
@@ -25,7 +25,7 @@ pub fn Counters() -> impl IntoView {
let add_many_counters = move |_| {
let next_id = next_counter_id.get();
let new_counters = (next_id..next_id + MANY_COUNTERS).map(|id| {
let signal = ArcRwSignal::new(0);
let signal = create_signal(0);
(id, signal)
});
@@ -39,55 +39,78 @@ pub fn Counters() -> impl IntoView {
view! {
<div>
<button on:click=add_counter>"Add Counter"</button>
<button on:click=add_many_counters>{format!("Add {MANY_COUNTERS} Counters")}</button>
<button on:click=clear_counters>"Clear Counters"</button>
<button on:click=add_counter>
"Add Counter"
</button>
<button on:click=add_many_counters>
{format!("Add {MANY_COUNTERS} Counters")}
</button>
<button on:click=clear_counters>
"Clear Counters"
</button>
<p>
"Total: "
<span>
{move || {
counters.get().iter().map(|(_, count)| count.get()).sum::<i32>().to_string()
}}
</span> " from " <span>{move || counters.get().len().to_string()}</span>
<span>{move ||
counters.get()
.iter()
.map(|(_, (count, _))| count.get())
.sum::<i32>()
.to_string()
}</span>
" from "
<span>{move || counters.get().len().to_string()}</span>
" counters."
</p>
<ul>
<For
each=move || counters.get()
key=|counter| counter.0
children=move |(id, value)| {
view! { <Counter id value/> }
children=move |(id, (value, set_value)): (usize, (ReadSignal<i32>, WriteSignal<i32>))| {
view! {
<Counter id value set_value/>
}
}
/>
</ul>
</div>
}
}
#[component]
fn Counter(id: usize, value: ArcRwSignal<i32>) -> impl IntoView {
let value = RwSignal::from(value);
fn Counter(
id: usize,
value: ReadSignal<i32>,
set_value: WriteSignal<i32>,
) -> impl IntoView {
let CounterUpdater { set_counters } = use_context().unwrap();
let input = move |ev| {
set_value
.set(event_target_value(&ev).parse::<i32>().unwrap_or_default())
};
// this will run when the scope is disposed, i.e., when this row is deleted
// because the signal was created in the parent scope, it won't be disposed
// of until the parent scope is. but we no longer need it, so we'll dispose of
// it when this row is deleted, instead. if we don't dispose of it here,
// this memory will "leak," i.e., the signal will continue to exist until the
// parent component is removed. in the case of this component, where it's the
// root, that's the lifetime of the program.
on_cleanup(move || {
log::debug!("deleted a row");
value.dispose();
});
view! {
<li>
<button on:click=move |_| value.update(move |value| *value -= 1)>"-1"</button>
<input
type="text"
prop:value=value
on:input:target=move |ev| {
value.set(ev.target().value().parse::<i32>().unwrap_or_default())
}
<button on:click=move |_| set_value.update(move |value| *value -= 1)>"-1"</button>
<input type="text"
prop:value={value}
on:input=input
/>
<span>{value}</span>
<button on:click=move |_| value.update(move |value| *value += 1)>"+1"</button>
<button on:click=move |_| {
set_counters
.update(move |counters| counters.retain(|(counter_id, _)| counter_id != &id))
}>"x"</button>
<button on:click=move |_| set_value.update(move |value| *value += 1)>"+1"</button>
<button on:click=move |_| set_counters.update(move |counters| counters.retain(|(counter_id, _)| counter_id != &id))>"x"</button>
</li>
}
}

View File

@@ -1,6 +1,8 @@
use counters::Counters;
use leptos::*;
fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount::mount_to_body(Counters)
mount_to_body(|| view! { <Counters/> })
}

View File

@@ -3,15 +3,14 @@ use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
use counters::Counters;
use leptos::prelude::*;
use leptos::spawn::tick;
use leptos::*;
use web_sys::HtmlElement;
#[wasm_bindgen_test]
async fn inc() {
mount_to_body(Counters);
fn inc() {
mount_to_body(|| view! { <Counters/> });
let document = document();
let document = leptos::document();
let div = document.query_selector("div").unwrap().unwrap();
let add_counter = div
.first_child()
@@ -19,33 +18,31 @@ async fn inc() {
.dyn_into::<HtmlElement>()
.unwrap();
assert_eq!(
div.inner_html(),
"<button>Add Counter</button><button>Add 1000 \
Counters</button><button>Clear Counters</button><p>Total: \
<span>0</span> from <span>0</span> counters.</p><ul><!----></ul>"
);
// add 3 counters
add_counter.click();
add_counter.click();
add_counter.click();
tick().await;
// check HTML
assert_eq!(
div.inner_html(),
"<button>Add Counter</button><button>Add 1000 \
Counters</button><button>Clear Counters</button><p>Total: \
<span>0</span> from <span>3</span> \
counters.</p><ul><li><button>-1</button><input \
type=\"text\"><span>0</span><button>+1</button><button>x</button></\
li><li><button>-1</button><input \
type=\"text\"><span>0</span><button>+1</button><button>x</button></\
li><li><button>-1</button><input \
type=\"text\"><span>0</span><button>+1</button><button>x</button></\
li><!----></ul>"
Counters</button><button>Clear Counters</button><p>Total: <span><!-- \
<DynChild> -->0<!-- </DynChild> --></span> from <span><!-- \
<DynChild> -->3<!-- </DynChild> --></span> counters.</p><ul><!-- \
<Each> --><!-- <EachItem> --><!-- <Counter> \
--><li><button>-1</button><input type=\"text\"><span><!-- <DynChild> \
-->0<!-- </DynChild> \
--></span><button>+1</button><button>x</button></li><!-- </Counter> \
--><!-- </EachItem> --><!-- <EachItem> --><!-- <Counter> \
--><li><button>-1</button><input type=\"text\"><span><!-- <DynChild> \
-->0<!-- </DynChild> \
--></span><button>+1</button><button>x</button></li><!-- </Counter> \
--><!-- </EachItem> --><!-- <EachItem> --><!-- <Counter> \
--><li><button>-1</button><input type=\"text\"><span><!-- <DynChild> \
-->0<!-- </DynChild> \
--></span><button>+1</button><button>x</button></li><!-- </Counter> \
--><!-- </EachItem> --><!-- </Each> --></ul>"
);
let counters = div
@@ -74,20 +71,25 @@ async fn inc() {
}
}
tick().await;
assert_eq!(
div.inner_html(),
"<button>Add Counter</button><button>Add 1000 \
Counters</button><button>Clear Counters</button><p>Total: \
<span>6</span> from <span>3</span> \
counters.</p><ul><li><button>-1</button><input \
type=\"text\"><span>1</span><button>+1</button><button>x</button></\
li><li><button>-1</button><input \
type=\"text\"><span>2</span><button>+1</button><button>x</button></\
li><li><button>-1</button><input \
type=\"text\"><span>3</span><button>+1</button><button>x</button></\
li><!----></ul>"
Counters</button><button>Clear Counters</button><p>Total: <span><!-- \
<DynChild> -->6<!-- </DynChild> --></span> from <span><!-- \
<DynChild> -->3<!-- </DynChild> --></span> counters.</p><ul><!-- \
<Each> --><!-- <EachItem> --><!-- <Counter> \
--><li><button>-1</button><input type=\"text\"><span><!-- <DynChild> \
-->1<!-- </DynChild> \
--></span><button>+1</button><button>x</button></li><!-- </Counter> \
--><!-- </EachItem> --><!-- <EachItem> --><!-- <Counter> \
--><li><button>-1</button><input type=\"text\"><span><!-- <DynChild> \
-->2<!-- </DynChild> \
--></span><button>+1</button><button>x</button></li><!-- </Counter> \
--><!-- </EachItem> --><!-- <EachItem> --><!-- <Counter> \
--><li><button>-1</button><input type=\"text\"><span><!-- <DynChild> \
-->3<!-- </DynChild> \
--></span><button>+1</button><button>x</button></li><!-- </Counter> \
--><!-- </EachItem> --><!-- </Each> --></ul>"
);
// remove the first counter
@@ -99,17 +101,20 @@ async fn inc() {
.unchecked_into::<HtmlElement>()
.click();
tick().await;
assert_eq!(
div.inner_html(),
"<button>Add Counter</button><button>Add 1000 \
Counters</button><button>Clear Counters</button><p>Total: \
<span>5</span> from <span>2</span> \
counters.</p><ul><li><button>-1</button><input \
type=\"text\"><span>2</span><button>+1</button><button>x</button></\
li><li><button>-1</button><input \
type=\"text\"><span>3</span><button>+1</button><button>x</button></\
li><!----></ul>"
Counters</button><button>Clear Counters</button><p>Total: <span><!-- \
<DynChild> -->5<!-- </DynChild> --></span> from <span><!-- \
<DynChild> -->2<!-- </DynChild> --></span> counters.</p><ul><!-- \
<Each> --><!-- <EachItem> --><!-- <EachItem> --><!-- <Counter> \
--><li><button>-1</button><input type=\"text\"><span><!-- <DynChild> \
-->2<!-- </DynChild> \
--></span><button>+1</button><button>x</button></li><!-- </Counter> \
--><!-- </EachItem> --><!-- <EachItem> --><!-- <Counter> \
--><li><button>-1</button><input type=\"text\"><span><!-- <DynChild> \
-->3<!-- </DynChild> \
--></span><button>+1</button><button>x</button></li><!-- </Counter> \
--><!-- </EachItem> --><!-- </Each> --></ul>"
);
}

View File

@@ -13,4 +13,5 @@ web-sys = { version = "0.3", features = ["Clipboard", "Navigator"] }
[dev-dependencies]
wasm-bindgen-test = "0.3.0"
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["NodeList"] }
web-sys = "0.3"
gloo-timers = { version = "0.3", features = ["futures"] }

View File

@@ -1,26 +1,25 @@
use leptos::{ev::click, prelude::*};
use web_sys::Element;
use leptos::{ev::click, html::AnyElement, *};
// no extra parameter
pub fn highlight(el: Element) {
pub fn highlight(el: HtmlElement<AnyElement>) {
let mut highlighted = false;
let handle = el.clone().on(click, move |_| {
let _ = el.clone().on(click, move |_| {
highlighted = !highlighted;
if highlighted {
el.style(("background-color", "yellow"));
let _ = el.clone().style("background-color", "yellow");
} else {
el.style(("background-color", "transparent"));
let _ = el.clone().style("background-color", "transparent");
}
});
on_cleanup(move || drop(handle));
}
// one extra parameter
pub fn copy_to_clipboard(el: Element, content: &str) {
let content = content.to_owned();
let handle = el.clone().on(click, move |evt| {
pub fn copy_to_clipboard(el: HtmlElement<AnyElement>, content: &str) {
let content = content.to_string();
let _ = el.clone().on(click, move |evt| {
evt.prevent_default();
evt.stop_propagation();
@@ -30,9 +29,8 @@ pub fn copy_to_clipboard(el: Element, content: &str) {
.expect("navigator.clipboard to be available")
.write_text(&content);
el.set_inner_html(&format!("Copied \"{}\"", &content));
let _ = el.clone().inner_html(format!("Copied \"{}\"", &content));
});
on_cleanup(move || drop(handle));
}
// custom parameter
@@ -54,18 +52,14 @@ impl From<()> for Amount {
}
// .into() will automatically be called on the parameter
pub fn add_dot(el: Element, amount: Amount) {
use leptos::wasm_bindgen::JsCast;
let el = el.unchecked_into::<web_sys::HtmlElement>();
let handle = el.clone().on(click, move |_| {
pub fn add_dot(el: HtmlElement<AnyElement>, amount: Amount) {
_ = el.clone().on(click, move |_| {
el.set_inner_text(&format!(
"{}{}",
el.inner_text(),
".".repeat(amount.0)
))
});
on_cleanup(move || drop(handle));
})
}
#[component]

View File

@@ -1,5 +1,5 @@
use directives::App;
use leptos::prelude::*;
use leptos::*;
fn main() {
_ = console_log::init_with_level(log::Level::Debug);

View File

@@ -1,16 +1,19 @@
use directives::App;
use leptos::{prelude::*, spawn::tick};
use gloo_timers::future::sleep;
use std::time::Duration;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
use web_sys::HtmlElement;
wasm_bindgen_test_configure!(run_in_browser);
use directives::App;
use leptos::*;
use web_sys::HtmlElement;
#[wasm_bindgen_test]
async fn test_directives() {
leptos::mount::mount_to_body(App);
tick().await;
mount_to_body(|| view! { <App/> });
sleep(Duration::ZERO).await;
let document = document();
let document = leptos::document();
let paragraphs = document.query_selector_all("p").unwrap();
assert_eq!(paragraphs.length(), 3);

View File

@@ -8,7 +8,7 @@ test.describe("Clear Number", () => {
await ui.clearInput();
await expect(ui.errorMessage).toHaveText("Not an integer! Errors: ");
await expect(ui.errorMessage).toHaveText("Not a number! Errors: ");
});
test("should see the error list", async ({ page }) => {
const ui = new HomePage(page);

View File

@@ -14,7 +14,7 @@ export class HomePage {
this.pageTitle = page.locator("h1");
this.numberInput = page.getByLabel(
"Type an integer (or something that's not an integer!)"
"Type a number (or something that's not a number!)"
);
this.successMessage = page.locator("label p");
this.errorMessage = page.locator("div p");

View File

@@ -1,44 +1,38 @@
use leptos::prelude::*;
use leptos::*;
#[component]
pub fn App() -> impl IntoView {
let (value, set_value) = signal("".parse::<i32>());
let (value, set_value) = create_signal(Ok(0));
// when input changes, try to parse a number from the input
let on_input =
move |ev| set_value.set(event_target_value(&ev).parse::<i32>());
view! {
<h1>"Error Handling"</h1>
<label>
"Type an integer (or something that's not an integer!)"
<input
type="number"
value=move || value.get().unwrap_or_default()
// when input changes, try to parse a number from the input
on:input:target=move |ev| set_value.set(ev.target().value().parse::<i32>())
/>
// If an `Err(_) has been rendered inside the <ErrorBoundary/>,
"Type a number (or something that's not a number!)"
<input type="number" on:input=on_input/>
// If an `Err(_) had been rendered inside the <ErrorBoundary/>,
// the fallback will be displayed. Otherwise, the children of the
// <ErrorBoundary/> will be displayed.
// the fallback receives a signal containing current errors
<ErrorBoundary fallback=|errors| {
let errors = errors.clone();
view! {
<ErrorBoundary
// the fallback receives a signal containing current errors
fallback=|errors| view! {
<div class="error">
<p>"Not an integer! Errors: "</p>
<p>"Not a number! Errors: "</p>
// we can render a list of errors
// as strings, if we'd like
<ul>
{move || {
errors
.read()
.iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li> })
.collect::<Vec<_>>()
}}
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li>})
.collect_view()
}
</ul>
</div>
}
}>
>
<p>
"You entered "
// because `value` is `Result<i32, _>`,

View File

@@ -1,8 +1,12 @@
use error_boundary::*;
use leptos::prelude::*;
use leptos::*;
pub fn main() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(App)
mount_to_body(|| {
view! {
<App/>
}
})
}

View File

@@ -7,12 +7,15 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
console_log = "1.0"
console_error_panic_hook = "0.1"
leptos = { path = "../../leptos" }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
log = "0.4"
serde = { version = "1", features = ["derive"] }
simple_logger = "4.0"
axum = { version = "0.7", optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true }
@@ -22,7 +25,7 @@ thiserror = "1.0"
wasm-bindgen = "0.2"
[features]
hydrate = ["leptos/hydrate"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",

View File

@@ -1,5 +1,5 @@
use crate::errors::AppError;
use leptos::{logging::log, prelude::*};
use leptos::{logging::log, *};
#[cfg(feature = "ssr")]
use leptos_axum::ResponseOptions;
@@ -7,18 +7,25 @@ use leptos_axum::ResponseOptions;
// Feel free to do more complicated things here than just displaying them.
#[component]
pub fn ErrorTemplate(
#[prop(into)] errors: MaybeSignal<Errors>,
#[prop(optional)] outside_errors: Option<Errors>,
#[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> impl IntoView {
let errors = match outside_errors {
Some(e) => create_rw_signal(e),
None => match errors {
Some(e) => e,
None => panic!("No Errors found and we expected errors!"),
},
};
// Get Errors from Signal
// Downcast lets us take a type that implements `std::error::Error`
let errors = Memo::new(move |_| {
errors
.get_untracked()
.into_iter()
.filter_map(|(_, v)| v.downcast_ref::<AppError>().cloned())
.collect::<Vec<_>>()
});
log!("Errors: {:#?}", &*errors.read_untracked());
let errors: Vec<AppError> = errors
.get_untracked()
.into_iter()
.filter_map(|(_, v)| v.downcast_ref::<AppError>().cloned())
.collect();
log!("Errors: {errors:#?}");
// Only the response code for the first error is actually sent from the server
// this may be customized by the specific application
@@ -26,30 +33,26 @@ pub fn ErrorTemplate(
{
let response = use_context::<ResponseOptions>();
if let Some(response) = response {
response.set_status(errors.read_untracked()[0].status_code());
response.set_status(errors[0].status_code());
}
}
view! {
<h1>{move || {
if errors.read().len() > 1 {
"Errors"
} else {
"Error"
}}}
</h1>
{move || {
errors.get()
.into_iter()
.map(|error| {
let error_string = error.to_string();
let error_code= error.status_code();
view! {
<h2>{error_code.to_string()}</h2>
<p>"Error: " {error_string}</p>
}
})
.collect_view()
}}
<h1>{if errors.len() > 1 {"Errors"} else {"Error"}}</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each= move || {errors.clone().into_iter().enumerate()}
// a unique key for each item as a reference
key=|(index, _)| *index
// renders each item to a view
children=move |error| {
let error_string = error.1.to_string();
let error_code= error.1.status_code();
view! {
<h2>{error_code.to_string()}</h2>
<p>"Error: " {error_string}</p>
}
}
/>
}
}

View File

@@ -1,7 +1,7 @@
use http::status::StatusCode;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[derive(Debug, Clone, Error)]
pub enum AppError {
#[error("Not Found")]
NotFound,

View File

@@ -0,0 +1,48 @@
use crate::landing::App;
use axum::{
body::Body,
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
use leptos::{view, LeptosOptions};
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler = leptos_axum::render_app_to_stream(
options.to_owned(),
move || view! { <App/> },
);
handler(req).await.into_response()
}
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),
)),
}
}

View File

@@ -1,10 +1,7 @@
use crate::{error_template::ErrorTemplate, errors::AppError};
use leptos::prelude::*;
use leptos::*;
use leptos_meta::*;
use leptos_router::{
components::{Route, Router, Routes},
StaticSegment,
};
use leptos_router::*;
#[server(CauseInternalServerError, "/api")]
pub async fn cause_internal_server_error() -> Result<(), ServerFnError> {
@@ -16,44 +13,28 @@ pub async fn cause_internal_server_error() -> Result<(), ServerFnError> {
))
}
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone() />
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
//let id = use_context::<String>();
provide_meta_context();
view! {
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Stylesheet id="leptos" href="/pkg/errors_axum.css"/>
<Router>
<Router fallback=|| {
let mut outside_errors = Errors::default();
outside_errors.insert_with_default_key(AppError::NotFound);
view! {
<ErrorTemplate outside_errors/>
}
.into_view()
}>
<header>
<h1>"Error Examples:"</h1>
</header>
<main>
<Routes fallback=|| {
let mut errors = Errors::default();
errors.insert_with_default_key(AppError::NotFound);
view! {
<ErrorTemplate errors/>
}
.into_view()
}>
<Route path=StaticSegment("") view=ExampleErrors/>
<Routes>
<Route path="" view=ExampleErrors/>
</Routes>
</main>
</Router>
@@ -63,7 +44,7 @@ pub fn App() -> impl IntoView {
#[component]
pub fn ExampleErrors() -> impl IntoView {
let generate_internal_error =
ServerAction::<CauseInternalServerError>::new();
create_server_action::<CauseInternalServerError>();
view! {
<p>
@@ -73,18 +54,18 @@ pub fn ExampleErrors() -> impl IntoView {
</p>
<p>
"After pressing this button check browser network tools. Can be used even when WASM is blocked:"
<ActionForm action=generate_internal_error>
<input name="error1" type="submit" value="Generate Internal Server Error"/>
</ActionForm>
</p>
<ActionForm action=generate_internal_error>
<input name="error1" type="submit" value="Generate Internal Server Error"/>
</ActionForm>
<p>"The following <div> will always contain an error and cause this page to produce status 500. Check browser dev tools. "</p>
<div>
// note that the error boundaries could be placed above in the Router or lower down
// in a particular route. The generated errors on the entire page contribute to the
// final status code sent by the server when producing ssr pages.
<ErrorBoundary fallback=|errors| view!{ <ErrorTemplate errors/>}>
<ReturnsError/>
</ErrorBoundary>
// note that the error boundaries could be placed above in the Router or lower down
// in a particular route. The generated errors on the entire page contribute to the
// final status code sent by the server when producing ssr pages.
<ErrorBoundary fallback=|errors| view!{ <ErrorTemplate errors=errors/>}>
<ReturnsError/>
</ErrorBoundary>
</div>
}
}

View File

@@ -1,11 +1,21 @@
pub mod error_template;
pub mod errors;
#[cfg(feature = "ssr")]
pub mod fallback;
pub mod landing;
use wasm_bindgen::prelude::wasm_bindgen;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
#[wasm_bindgen]
pub fn hydrate() {
use crate::landing::App;
use crate::landing::*;
use leptos::*;
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
leptos::mount_to_body(|| {
view! { <App/> }
});
}

View File

@@ -1,17 +1,15 @@
#[cfg(feature = "ssr")]
mod ssr_imports {
use axum::extract::State;
pub use axum::{
body::Body as AxumBody,
extract::Path,
extract::{Path, State},
http::Request,
response::{IntoResponse, Response},
routing::get,
Router,
};
use errors_axum::landing::shell;
pub use errors_axum::landing::App;
use leptos::{config::LeptosOptions, context::provide_context};
pub use errors_axum::{fallback::*, landing::App};
pub use leptos::{logging::log, *};
pub use leptos_axum::{generate_route_list, LeptosRoutes};
// This custom handler lets us provide Axum State via context
@@ -21,10 +19,11 @@ mod ssr_imports {
req: Request<AxumBody>,
) -> Response {
let handler = leptos_axum::render_app_to_stream_with_context(
options.clone(),
move || {
provide_context(id.clone());
},
move || shell(options.clone()),
App,
);
handler(req).await.into_response()
}
@@ -33,12 +32,18 @@ mod ssr_imports {
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use errors_axum::landing::shell;
use leptos::config::get_configuration;
use ssr_imports::*;
simple_logger::init_with_level(log::Level::Debug)
.expect("couldn't initialize logging");
// Explicit server function registration is no longer required
// on the main branch. On 0.3.0 and earlier, uncomment the lines
// below to register the server functions.
// _ = CauseInternalServerError::register();
// Setting this to None means we'll be using cargo-leptos and its env vars
let conf = get_configuration(None).unwrap();
let conf = get_configuration(None).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
@@ -46,16 +51,13 @@ async fn main() {
// build our application with a route
let app = Router::new()
.route("/special/:id", get(custom_handler))
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.leptos_routes(&leptos_options, routes, App)
.fallback(file_and_error_handler)
.with_state(leptos_options);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
println!("listening on http://{}", &addr);
log!("listening on http://{}", &addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await

View File

@@ -8,17 +8,13 @@ codegen-units = 1
lto = true
[dependencies]
leptos = { path = "../../leptos", features = ["csr", "tracing"] }
leptos = { path = "../../leptos", features = ["csr"] }
reqwasm = "0.5"
gloo-timers = { version = "0.3", features = ["futures"] }
serde = { version = "1", features = ["derive"] }
log = "0.4"
console_log = "1"
console_error_panic_hook = "0.1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-subscriber-wasm = "0.1"
[dev-dependencies]
wasm-bindgen-test = "0.3"

View File

@@ -1,5 +1,4 @@
use leptos::prelude::*;
use leptos::tachys::html::style::style;
use leptos::{error::Result, *};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -18,7 +17,6 @@ type CatCount = usize;
async fn fetch_cats(count: CatCount) -> Result<Vec<String>> {
if count > 0 {
gloo_timers::future::TimeoutFuture::new(1000).await;
// make the request
let res = reqwasm::http::Request::get(&format!(
"https://api.thecatapi.com/v1/images/search?limit={count}",
@@ -35,26 +33,26 @@ async fn fetch_cats(count: CatCount) -> Result<Vec<String>> {
.collect::<Vec<_>>();
Ok(res)
} else {
Err(CatError::NonZeroCats)?
Err(CatError::NonZeroCats.into())
}
}
pub fn fetch_example() -> impl IntoView {
let (cat_count, set_cat_count) = signal::<CatCount>(1);
let (cat_count, set_cat_count) = create_signal::<CatCount>(0);
// 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()));
// we use local_resource here because
// 1) our error type isn't serializable/deserializable
// 2) we're not doing server-side rendering in this example anyway
// (during SSR, create_resource will begin loading on the server and resolve on the client)
let cats = create_local_resource(move || cat_count.get(), fetch_cats);
let fallback = move |errors: ArcRwSignal<Errors>| {
let fallback = move |errors: RwSignal<Errors>| {
let error_list = move || {
errors.with(|errors| {
errors
.iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li> })
.collect::<Vec<_>>()
.collect_view()
})
};
@@ -66,7 +64,17 @@ pub fn fetch_example() -> impl IntoView {
}
};
let spreadable = style(("background-color", "AliceBlue"));
// the renderer can handle Option<_> and Result<_> states
// by displaying nothing for None if the resource is still loading
// and by using the ErrorBoundary fallback to catch Err(_)
// so we'll just use `.and_then()` to map over the happy path
let cats_view = move || {
cats.and_then(|data| {
data.iter()
.map(|s| view! { <p><img src={s}/></p> })
.collect_view()
})
};
view! {
<div>
@@ -75,32 +83,19 @@ pub fn fetch_example() -> impl IntoView {
<input
type="number"
prop:value=move || cat_count.get().to_string()
on:input:target=move |ev| {
let val = ev.target().value().parse::<CatCount>().unwrap_or(0);
on:input=move |ev| {
let val = event_target_value(&ev).parse::<CatCount>().unwrap_or(0);
set_cat_count.set(val);
}
/>
</label>
<Transition fallback=|| view! { <div>"Loading..."</div> } {..spreadable}>
<Transition fallback=move || {
view! { <div>"Loading (Suspense Fallback)..."</div> }
}>
<ErrorBoundary fallback>
<ul>
{move || Suspend::new(async move {
cats.await
.map(|cats| {
cats.iter()
.map(|s| {
view! {
<li>
<img src=s.clone()/>
</li>
}
})
.collect::<Vec<_>>()
})
})}
</ul>
<div>
{cats_view}
</div>
</ErrorBoundary>
</Transition>
</div>

View File

@@ -1,21 +1,8 @@
use fetch::fetch_example;
use leptos::prelude::*;
use leptos::*;
pub fn main() {
use tracing_subscriber::fmt;
use tracing_subscriber_wasm::MakeConsoleWriter;
fmt()
.with_writer(
// To avoide trace events in the browser from showing their
// JS backtrace, which is very annoying, in my opinion
MakeConsoleWriter::default()
.map_trace_level_to(tracing::Level::DEBUG),
)
// For some reason, if we don't do this in the browser, we get
// a runtime error.
.without_time()
.init();
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(fetch_example)
}

View File

@@ -5,14 +5,4 @@ edition = "2021"
[dependencies]
leptos = { path = "../../leptos", features = ["csr"] }
throw_error = { path = "../../any_error/" }
any_spawner = { path = "../../any_spawner/" }
next_tuple = { path = "../../next_tuple/" }
gtk = { version = "0.8.0", package = "gtk4", optional = true }
paste = "1.0.14"
console_error_panic_hook = { version = "0.1", optional = true }
[features]
gtk = ["dep:gtk", "any_spawner/glib"]
wasm = ["any_spawner/wasm-bindgen", "dep:console_error_panic_hook"]
gtk = { version = "0.5.0", package = "gtk4" }

View File

@@ -1,8 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta name="color-scheme" content="dark">
<link rel="css" href="style.css" data-trunk>
</head>
<body></body>
</html>

View File

@@ -1,633 +0,0 @@
use self::properties::Connect;
use gtk::{
ffi::GtkWidget,
glib::{
object::{IsA, IsClass, ObjectExt},
Object, Value,
},
prelude::{Cast, WidgetExt},
Label, Orientation, Widget,
};
use leptos::{
reactive_graph::effect::RenderEffect,
tachys::{
renderer::{CastFrom, Renderer},
view::{Mountable, Render},
},
};
use next_tuple::NextTuple;
use std::{borrow::Cow, marker::PhantomData};
#[derive(Debug)]
pub struct LeptosGtk;
#[derive(Debug, Clone)]
pub struct Element(pub Widget);
impl Element {
pub fn remove(&self) {
self.0.unparent();
}
}
#[derive(Debug, Clone)]
pub struct Text(pub Element);
impl<T> From<T> for Element
where
T: Into<Widget>,
{
fn from(value: T) -> Self {
Element(value.into())
}
}
impl Mountable<LeptosGtk> for Element {
fn unmount(&mut self) {
self.remove()
}
fn mount(
&mut self,
parent: &<LeptosGtk as Renderer>::Element,
marker: Option<&<LeptosGtk as Renderer>::Node>,
) {
self.0
.insert_before(&parent.0, marker.as_ref().map(|m| &m.0));
}
fn insert_before_this(&self,
child: &mut dyn Mountable<LeptosGtk>,
) -> bool {
child.mount(parent, Some(self.as_ref()));
true
}
}
impl Mountable<LeptosGtk> for Text {
fn unmount(&mut self) {
self.0.remove()
}
fn mount(
&mut self,
parent: &<LeptosGtk as Renderer>::Element,
marker: Option<&<LeptosGtk as Renderer>::Node>,
) {
self.0
.0
.insert_before(&parent.0, marker.as_ref().map(|m| &m.0));
}
fn insert_before_this(&self,
child: &mut dyn Mountable<LeptosGtk>,
) -> bool {
child.mount(parent, Some(self.as_ref()));
true
}
}
impl CastFrom<Element> for Element {
fn cast_from(source: Element) -> Option<Self> {
Some(source)
}
}
impl CastFrom<Element> for Text {
fn cast_from(source: Element) -> Option<Self> {
source
.0
.downcast::<Label>()
.ok()
.map(|n| Text(Element::from(n)))
}
}
impl AsRef<Element> for Element {
fn as_ref(&self) -> &Element {
self
}
}
impl AsRef<Element> for Text {
fn as_ref(&self) -> &Element {
&self.0
}
}
impl Renderer for LeptosGtk {
type Node = Element;
type Element = Element;
type Text = Text;
type Placeholder = Element;
fn intern(text: &str) -> &str {
text
}
fn create_text_node(text: &str) -> Self::Text {
Text(Element::from(Label::new(Some(text))))
}
fn create_placeholder() -> Self::Placeholder {
let label = Label::new(None);
label.set_visible(false);
Element::from(label)
}
fn set_text(node: &Self::Text, text: &str) {
let node_as_text = node.0 .0.downcast_ref::<Label>().unwrap();
node_as_text.set_label(text);
}
fn set_attribute(node: &Self::Element, name: &str, value: &str) {
node.0.set_property(name, value);
}
fn remove_attribute(node: &Self::Element, name: &str) {
node.0.set_property(name, None::<&str>);
}
fn insert_node(
parent: &Self::Element,
new_child: &Self::Node,
marker: Option<&Self::Node>,
) {
new_child
.0
.insert_before(&parent.0, marker.as_ref().map(|n| &n.0));
}
fn remove_node(
parent: &Self::Element,
child: &Self::Node,
) -> Option<Self::Node> {
todo!()
}
fn remove(node: &Self::Node) {
todo!()
}
fn get_parent(node: &Self::Node) -> Option<Self::Node> {
node.0.parent().map(Element::from)
}
fn first_child(node: &Self::Node) -> Option<Self::Node> {
todo!()
}
fn next_sibling(node: &Self::Node) -> Option<Self::Node> {
todo!()
}
fn log_node(node: &Self::Node) {
todo!()
}
fn clear_children(parent: &Self::Element) {
todo!()
}
}
pub fn root<Chil>(children: Chil) -> (Widget, impl Mountable<LeptosGtk>)
where
Chil: Render<LeptosGtk>,
{
let state = r#box()
.orientation(Orientation::Vertical)
.spacing(12)
.child(children)
.build();
(state.as_widget().clone(), state)
}
pub trait WidgetClass {
type Widget: Into<Widget> + IsA<Object> + IsClass;
}
pub struct LGtkWidget<Widg, Props, Chil> {
widget: PhantomData<Widg>,
properties: Props,
children: Chil,
}
impl<Widg, Props, Chil> LGtkWidget<Widg, Props, Chil>
where
Widg: WidgetClass,
Chil: NextTuple,
{
pub fn child<T>(
self,
child: T,
) -> LGtkWidget<Widg, Props, Chil::Output<T>> {
let LGtkWidget {
widget,
properties,
children,
} = self;
LGtkWidget {
widget,
properties,
children: children.next_tuple(child),
}
}
}
impl<Widg, Props, Chil> LGtkWidget<Widg, Props, Chil>
where
Widg: WidgetClass,
Props: NextTuple,
Chil: Render<LeptosGtk>,
{
pub fn connect<F>(
self,
signal_name: &'static str,
callback: F,
) -> LGtkWidget<Widg, Props::Output<Connect<F>>, Chil>
where
F: Fn(&[Value]) -> Option<Value> + Send + Sync + 'static,
{
let LGtkWidget {
widget,
properties,
children,
} = self;
LGtkWidget {
widget,
properties: properties.next_tuple(Connect {
signal_name,
callback,
}),
children,
}
}
}
pub struct LGtkWidgetState<Widg, Props, Chil>
where
Chil: Render<LeptosGtk>,
Props: Property,
Widg: WidgetClass,
{
ty: PhantomData<Widg>,
widget: Element,
properties: Props::State,
children: Chil::State,
}
impl<Widg, Props, Chil> LGtkWidgetState<Widg, Props, Chil>
where
Chil: Render<LeptosGtk>,
Props: Property,
Widg: WidgetClass,
{
pub fn as_widget(&self) -> &Widget {
&self.widget.0
}
}
impl<Widg, Props, Chil> Render<LeptosGtk> for LGtkWidget<Widg, Props, Chil>
where
Widg: WidgetClass,
Props: Property,
Chil: Render<LeptosGtk>,
{
type State = LGtkWidgetState<Widg, Props, Chil>;
fn build(self) -> Self::State {
let widget = Object::new::<Widg::Widget>();
let widget = Element::from(widget);
let properties = self.properties.build(&widget);
let mut children = self.children.build();
children.mount(&widget, None);
LGtkWidgetState {
ty: PhantomData,
widget,
properties,
children,
}
}
fn rebuild(self, state: &mut Self::State) {
self.properties
.rebuild(&state.widget, &mut state.properties);
self.children.rebuild(&mut state.children);
}
}
impl<Widg, Props, Chil> Mountable<LeptosGtk>
for LGtkWidgetState<Widg, Props, Chil>
where
Widg: WidgetClass,
Props: Property,
Chil: Render<LeptosGtk>,
{
fn unmount(&mut self) {
self.children.unmount();
self.widget.remove();
}
fn mount(
&mut self,
parent: &<LeptosGtk as Renderer>::Element,
marker: Option<&<LeptosGtk as Renderer>::Node>,
) {
println!("mounting {}", std::any::type_name::<Widg>());
self.children.mount(&self.widget, None);
LeptosGtk::insert_node(parent, &self.widget, marker);
}
fn insert_before_this(&self,
child: &mut dyn Mountable<LeptosGtk>,
) -> bool {
child.mount(parent, Some(self.widget.as_ref()));
true
}
}
pub trait Property {
type State;
fn build(self, element: &Element) -> Self::State;
fn rebuild(self, element: &Element, state: &mut Self::State);
}
impl<T, F> Property for F
where
T: Property,
T::State: 'static,
F: Fn() -> T + 'static,
{
type State = RenderEffect<T::State>;
fn build(self, widget: &Element) -> Self::State {
let widget = widget.clone();
RenderEffect::new(move |prev| {
let value = self();
if let Some(mut prev) = prev {
value.rebuild(&widget, &mut prev);
prev
} else {
value.build(&widget)
}
})
}
fn rebuild(self, widget: &Element, state: &mut Self::State) {}
}
pub fn button() -> LGtkWidget<gtk::Button, (), ()> {
LGtkWidget {
widget: PhantomData,
properties: (),
children: (),
}
}
pub fn r#box() -> LGtkWidget<gtk::Box, (), ()> {
LGtkWidget {
widget: PhantomData,
properties: (),
children: (),
}
}
mod widgets {
use super::WidgetClass;
impl WidgetClass for gtk::Button {
type Widget = Self;
}
impl WidgetClass for gtk::Box {
type Widget = Self;
}
}
pub mod properties {
use super::{
Element, LGtkWidget, LGtkWidgetState, LeptosGtk, Property, WidgetClass,
};
use gtk::glib::{object::ObjectExt, Value};
use leptos::tachys::{renderer::Renderer, view::Render};
use next_tuple::NextTuple;
pub struct Connect<F>
where
F: Fn(&[Value]) -> Option<Value> + Send + Sync + 'static,
{
pub signal_name: &'static str,
pub callback: F,
}
impl<F> Property for Connect<F>
where
F: Fn(&[Value]) -> Option<Value> + Send + Sync + 'static,
{
type State = ();
fn build(self, element: &Element) -> Self::State {
element.0.connect(self.signal_name, false, self.callback);
}
fn rebuild(self, element: &Element, state: &mut Self::State) {}
}
/* examples for macro */
pub struct Orientation {
value: gtk::Orientation,
}
pub struct OrientationState {
value: gtk::Orientation,
}
impl Property for Orientation {
type State = OrientationState;
fn build(self, element: &Element) -> Self::State {
element.0.set_property("orientation", self.value);
OrientationState { value: self.value }
}
fn rebuild(self, element: &Element, state: &mut Self::State) {
if self.value != state.value {
element.0.set_property("orientation", self.value);
state.value = self.value;
}
}
}
impl<Widg, Props, Chil> LGtkWidget<Widg, Props, Chil>
where
Widg: WidgetClass,
Props: NextTuple,
Chil: Render<LeptosGtk>,
{
pub fn orientation(
self,
value: impl Into<gtk::Orientation>,
) -> LGtkWidget<Widg, Props::Output<Orientation>, Chil> {
let LGtkWidget {
widget,
properties,
children,
} = self;
LGtkWidget {
widget,
properties: properties.next_tuple(Orientation {
value: value.into(),
}),
children,
}
}
}
pub struct Spacing {
value: i32,
}
pub struct SpacingState {
value: i32,
}
impl Property for Spacing {
type State = SpacingState;
fn build(self, element: &Element) -> Self::State {
element.0.set_property("spacing", self.value);
SpacingState { value: self.value }
}
fn rebuild(self, element: &Element, state: &mut Self::State) {
if self.value != state.value {
element.0.set_property("spacing", self.value);
state.value = self.value;
}
}
}
impl<Widg, Props, Chil> LGtkWidget<Widg, Props, Chil>
where
Widg: WidgetClass,
Props: NextTuple,
Chil: Render<LeptosGtk>,
{
pub fn spacing(
self,
value: impl Into<i32>,
) -> LGtkWidget<Widg, Props::Output<Spacing>, Chil> {
let LGtkWidget {
widget,
properties,
children,
} = self;
LGtkWidget {
widget,
properties: properties.next_tuple(Spacing {
value: value.into(),
}),
children,
}
}
}
/* end examples for properties macro */
pub struct Label {
value: String,
}
impl Label {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
}
}
}
pub struct LabelState {
value: String,
}
impl Property for Label {
type State = LabelState;
fn build(self, element: &Element) -> Self::State {
LeptosGtk::set_attribute(element, "label", &self.value);
LabelState { value: self.value }
}
fn rebuild(self, element: &Element, state: &mut Self::State) {
todo!()
}
}
impl Property for () {
type State = ();
fn build(self, _element: &Element) -> Self::State {}
fn rebuild(self, _element: &Element, _state: &mut Self::State) {}
}
macro_rules! tuples {
($($ty:ident),* $(,)?) => {
impl<$($ty,)*> Property for ($($ty,)*)
where $($ty: Property,)*
{
type State = ($($ty::State,)*);
fn build(self, element: &Element) -> Self::State {
#[allow(non_snake_case)]
let ($($ty,)*) = self;
($($ty.build(element),)*)
}
fn rebuild(self, element: &Element, state: &mut Self::State) {
paste::paste! {
#[allow(non_snake_case)]
let ($($ty,)*) = self;
#[allow(non_snake_case)]
let ($([<state_ $ty:lower>],)*) = state;
$($ty.rebuild(element, [<state_ $ty:lower>]));*
}
}
}
}
}
tuples!(A);
tuples!(A, B);
tuples!(A, B, C);
tuples!(A, B, C, D);
tuples!(A, B, C, D, E);
tuples!(A, B, C, D, E, F);
tuples!(A, B, C, D, E, F, G);
tuples!(A, B, C, D, E, F, G, H);
tuples!(A, B, C, D, E, F, G, H, I);
tuples!(A, B, C, D, E, F, G, H, I, J);
tuples!(A, B, C, D, E, F, G, H, I, J, K);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U);
tuples!(A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V);
tuples!(
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W
);
tuples!(
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X
);
tuples!(
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X,
Y
);
}

View File

@@ -1,175 +1,59 @@
#[cfg(feature = "gtk")]
use gtk::{
glib::Value, prelude::*, Application, ApplicationWindow, Orientation,
Widget,
};
#[cfg(feature = "wasm")]
use leptos::tachys::{dom::body, html::element, html::event as ev};
use leptos::{
logging,
prelude::*,
reactive_graph::{effect::Effect, owner::Owner, signal::RwSignal},
Executor, For, ForProps,
};
#[cfg(feature = "gtk")]
use leptos_gtk::{Element, LGtkWidget, LeptosGtk};
use std::{mem, thread, time::Duration};
#[cfg(feature = "gtk")]
mod leptos_gtk;
use gtk::{prelude::*, Application, ApplicationWindow, Button};
use leptos::*;
const APP_ID: &str = "dev.leptos.Counter";
// Basic GTK app setup from https://gtk-rs.org/gtk4-rs/stable/latest/book/hello_world.html
fn main() {
// use the glib event loop to power the reactive system
#[cfg(feature = "gtk")]
{
_ = Executor::init_glib();
let app = Application::builder().application_id(APP_ID).build();
let _ = create_runtime();
// Create a new application
let app = Application::builder().application_id(APP_ID).build();
app.connect_startup(|_| load_css());
// Connect to "activate" signal of `app`
app.connect_activate(build_ui);
app.connect_activate(|app| {
// Connect to "activate" signal of `app`
let owner = Owner::new();
let view = owner.with(ui);
let (root, state) = leptos_gtk::root(view);
let window = ApplicationWindow::builder()
.application(app)
.title("TachyGTK")
.child(&root)
.build();
// Present window
window.present();
mem::forget((owner, state));
});
app.run();
}
#[cfg(all(feature = "wasm", not(feature = "gtk")))]
{
console_error_panic_hook::set_once();
_ = Executor::init_wasm_bindgen();
let owner = Owner::new();
let view = owner.with(ui);
let mut state = view.build();
state.mount(&body().into(), None);
mem::forget((owner, state));
}
// Run the application
app.run();
}
#[cfg(feature = "gtk")]
type Rndr = LeptosGtk;
#[cfg(all(feature = "wasm", not(feature = "gtk")))]
type Rndr = Dom;
fn build_ui(app: &Application) {
let button = counter_button();
fn ui() -> impl Render<Rndr> {
let value = RwSignal::new(0);
let rows = RwSignal::new(vec![1, 2, 3, 4, 5]);
// Create a window and set the title
let window = ApplicationWindow::builder()
.application(app)
.title("Leptos-GTK")
.child(&button)
.build();
Effect::new(move |_| {
logging::log!("value = {}", value.get());
// Present window
window.present();
}
fn counter_button() -> Button {
let (value, set_value) = create_signal(0);
// Create a button with label and margins
let button = Button::builder()
.label("Count: ")
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.build();
// Connect to "clicked" signal of `button`
button.connect_clicked(move |_| {
// Set the label to "Hello World!" after the button has been clicked on
set_value.update(|value| *value += 1);
});
// just an example of multithreaded reactivity
#[cfg(feature = "gtk")]
thread::spawn(move || loop {
thread::sleep(Duration::from_millis(250));
value.update(|n| *n += 1);
create_effect({
let button = button.clone();
move |_| {
button.set_label(&format!("Count: {}", value.get()));
}
});
vstack((
hstack((
button("-1", move || value.update(|n| *n -= 1)),
move || value.get().to_string(),
button("+1", move || value.update(|n| *n += 1)),
)),
button("Swap", move || {
rows.update(|items| {
items.swap(1, 3);
})
}),
hstack(For(ForProps::builder()
.each(move || rows.get())
.key(|k| *k)
.children(|v| v)
.build())),
))
}
fn button(
label: impl Render<Rndr>,
callback: impl Fn() + Send + Sync + 'static,
) -> impl Render<Rndr> {
#[cfg(feature = "gtk")]
{
leptos_gtk::button()
.child(label)
.connect("clicked", move |_| {
callback();
None
})
}
#[cfg(all(feature = "wasm", not(feature = "gtk")))]
{
element::button()
.on(ev::click, move |_| callback())
.child(label)
}
}
fn vstack(children: impl Render<Rndr>) -> impl Render<Rndr> {
#[cfg(feature = "gtk")]
{
leptos_gtk::r#box()
.orientation(Orientation::Vertical)
.spacing(12)
.child(children)
}
#[cfg(all(feature = "wasm", not(feature = "gtk")))]
{
element::div()
.style(("display", "flex"))
.style(("flex-direction", "column"))
.style(("align-items", "center"))
.style(("justify-content", "center"))
.style(("margin", "1rem"))
.child(children)
}
}
fn hstack(children: impl Render<Rndr>) -> impl Render<Rndr> {
#[cfg(feature = "gtk")]
{
leptos_gtk::r#box()
.orientation(Orientation::Horizontal)
.spacing(12)
.child(children)
}
#[cfg(all(feature = "wasm", not(feature = "gtk")))]
{
element::div()
.style(("display", "flex"))
.style(("align-items", "center"))
.style(("justify-content", "center"))
.style(("margin", "1rem"))
.child(children)
}
}
#[cfg(feature = "gtk")]
fn load_css() {
use gtk::{gdk::Display, CssProvider};
let provider = CssProvider::new();
provider.load_from_path("style.css");
// Add the provider to the default screen
gtk::style_context_add_provider_for_display(
&Display::default().expect("Could not connect to a display."),
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
button
}

View File

View File

@@ -8,8 +8,6 @@ crate-type = ["cdylib", "rlib"]
[profile.release]
codegen-units = 1
opt-level = "z"
panic = "abort"
lto = true
[dependencies]
@@ -25,18 +23,21 @@ log = "0.4"
serde = { version = "1", features = ["derive"] }
gloo-net = { version = "0.2", features = ["http"] }
reqwest = { version = "0.11", features = ["json"] }
tracing = "0.1"
# openssl = { version = "0.10", features = ["v110"] }
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
send_wrapper = "0.6.0"
[features]
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:actix-files",
"dep:actix-web",
"dep:leptos_actix",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
[profile.wasm-release]

View File

@@ -1,4 +1,4 @@
use serde::de::DeserializeOwned;
use leptos::Serializable;
use serde::{Deserialize, Serialize};
pub fn story(path: &str) -> String {
@@ -10,51 +10,46 @@ pub fn user(path: &str) -> String {
}
#[cfg(not(feature = "ssr"))]
pub fn fetch_api<T>(
path: &str,
) -> impl std::future::Future<Output = Option<T>> + Send + '_
pub async fn fetch_api<T>(path: &str) -> Option<T>
where
T: Serialize + DeserializeOwned,
T: Serializable,
{
use leptos::prelude::on_cleanup;
use send_wrapper::SendWrapper;
let abort_controller = web_sys::AbortController::new().ok();
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
SendWrapper::new(async move {
let abort_controller =
SendWrapper::new(web_sys::AbortController::new().ok());
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
// abort in-flight requests if, e.g., we've navigated away from this page
leptos::on_cleanup(move || {
if let Some(abort_controller) = abort_controller {
abort_controller.abort()
}
});
// abort in-flight requests if, e.g., we've navigated away from this page
on_cleanup(move || {
if let Some(abort_controller) = abort_controller.take() {
abort_controller.abort()
}
});
let json = gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
.await
.map_err(|e| log::error!("{e}"))
.ok()?
.text()
.await
.ok()?;
gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
.await
.map_err(|e| log::error!("{e}"))
.ok()?
.json()
.await
.ok()
})
T::de(&json).ok()
}
#[cfg(feature = "ssr")]
pub async fn fetch_api<T>(path: &str) -> Option<T>
where
T: Serialize + DeserializeOwned,
T: Serializable,
{
reqwest::get(path)
let json = reqwest::get(path)
.await
.map_err(|e| log::error!("{e}"))
.ok()?
.json()
.text()
.await
.ok()
.ok()?;
T::de(&json).map_err(|e| log::error!("{e}")).ok()
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]

View File

@@ -1,37 +1,32 @@
use leptos::prelude::*;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
mod api;
mod routes;
use leptos_meta::{provide_meta_context, Link, Meta, Stylesheet};
use leptos_router::{
components::{FlatRoutes, Route, Router, RoutingProgress},
ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
use std::time::Duration;
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
let (is_routing, set_is_routing) = signal(false);
let (is_routing, set_is_routing) = create_signal(false);
view! {
<Stylesheet id="leptos" href="/pkg/hackernews.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
// adding `set_is_routing` causes the router to wait for async data to load on new pages
<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)/>
<RoutingProgress is_routing max_time=std::time::Duration::from_millis(250)/>
</div>
<Nav />
<main>
<FlatRoutes fallback=|| "Not found.">
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
</FlatRoutes>
<Routes>
<Route path="users/:id" view=User/>
<Route path="stories/:id" view=Story/>
<Route path=":stories?" view=Stories/>
</Routes>
</main>
</Router>
}
@@ -40,6 +35,7 @@ pub fn App() -> impl IntoView {
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
leptos::mount_to_body(App);
}

View File

@@ -4,6 +4,7 @@ mod ssr_imports {
pub use actix_files::Files;
pub use actix_web::*;
pub use hackernews::App;
pub use leptos_actix::{generate_route_list, LeptosRoutes};
#[get("/style.css")]
pub async fn css() -> impl Responder {
@@ -18,44 +19,24 @@ mod ssr_imports {
#[cfg(feature = "ssr")]
#[actix_web::main]
async fn main() -> std::io::Result<()> {
use leptos::prelude::*;
use leptos_actix::{generate_route_list, LeptosRoutes};
use leptos_meta::MetaTags;
use leptos::get_configuration;
use ssr_imports::*;
let conf = get_configuration(None).unwrap();
// Setting this to None means we'll be using cargo-leptos and its env vars.
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
HttpServer::new(move || {
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
let leptos_options = &conf.leptos_options;
let site_root = &leptos_options.site_root;
App::new()
.service(css)
.service(favicon)
.leptos_routes(routes, {
let leptos_options = leptos_options.clone();
move || {
use leptos::prelude::*;
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=leptos_options.clone() />
<HydrationScripts options=leptos_options.clone()/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}})
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), App)
.service(Files::new("/", site_root))
//.wrap(middleware::Compress::default())
})
@@ -68,6 +49,8 @@ async fn main() -> std::io::Result<()> {
#[cfg(not(feature = "ssr"))]
fn main() {
use hackernews::App;
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount::mount_to_body(App)
leptos::mount_to_body(App)
}

View File

@@ -1,5 +1,5 @@
use leptos::prelude::*;
use leptos_router::components::A;
use leptos::{component, view, IntoView};
use leptos_router::*;
#[component]
pub fn Nav() -> impl IntoView {

View File

@@ -1,9 +1,6 @@
use crate::api;
use leptos::{either::Either, prelude::*};
use leptos_router::{
components::A,
hooks::{use_params_map, use_query_map},
};
use leptos::*;
use leptos_router::*;
fn category(from: &str) -> &'static str {
match from {
@@ -21,50 +18,47 @@ pub fn Stories() -> impl IntoView {
let params = use_params_map();
let page = move || {
query
.read()
.get("page")
.and_then(|page| page.parse::<usize>().ok())
.with(|q| q.get("page").and_then(|page| page.parse::<usize>().ok()))
.unwrap_or(1)
};
let story_type = move || {
params
.read()
.get("stories")
.with(|p| p.get("stories").cloned())
.unwrap_or_else(|| "top".to_string())
};
let stories = Resource::new(
let stories = create_resource(
move || (page(), story_type()),
move |(page, story_type)| async move {
let path = format!("{}?page={}", category(&story_type), page);
api::fetch_api::<Vec<api::Story>>(&api::story(&path)).await
},
);
let (pending, set_pending) = signal(false);
let (pending, set_pending) = create_signal(false);
let hide_more_link = move || match &*stories.read() {
Some(Some(stories)) => stories.len() < 28,
_ => true
} || pending.get();
let hide_more_link = move || {
stories.get().unwrap_or(None).unwrap_or_default().len() < 28
|| pending.get()
};
view! {
<div class="news-view">
<div class="news-list-nav">
<span>
{move || if page() > 1 {
Either::Left(view! {
view! {
<a class="page-link"
href=move || format!("/{}?page={}", story_type(), page() - 1)
aria-label="Previous Page"
attr:aria_label="Previous Page"
>
"< prev"
</a>
})
}.into_any()
} else {
Either::Right(view! {
view! {
<span class="page-link disabled" aria-hidden="true">
"< prev"
</span>
})
}.into_any()
}}
</span>
<span>"page " {page}</span>
@@ -87,19 +81,23 @@ pub fn Stories() -> impl IntoView {
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()
key=|story| story.id
let:story
>
<Story story/>
</For>
</ul>
{move || match stories.get() {
None => None,
Some(None) => Some(view! { <p>"Error loading stories."</p> }.into_any()),
Some(Some(stories)) => {
Some(view! {
<ul>
<For
each=move || stories.clone()
key=|story| story.id
let:story
>
<Story story/>
</For>
</ul>
}.into_any())
}
}}
</Transition>
</div>
</main>
@@ -114,23 +112,23 @@ fn Story(story: api::Story) -> impl IntoView {
<span class="score">{story.points}</span>
<span class="title">
{if !story.url.starts_with("item?id=") {
Either::Left(view! {
view! {
<span>
<a href=story.url target="_blank" rel="noreferrer">
{story.title.clone()}
</a>
<span class="host">"("{story.domain}")"</span>
</span>
})
}.into_view()
} else {
let title = story.title.clone();
Either::Right(view! { <A href=format!("/stories/{}", story.id)>{title}</A> })
view! { <A href=format!("/stories/{}", story.id)>{title.clone()}</A> }.into_view()
}}
</span>
<br />
<span class="meta">
{if story.story_type != "job" {
Either::Left(view! {
view! {
<span>
{"by "}
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
@@ -143,10 +141,10 @@ fn Story(story: api::Story) -> impl IntoView {
}}
</A>
</span>
})
}.into_view()
} else {
let title = story.title.clone();
Either::Right(view! { <A href=format!("/item/{}", story.id)>{title}</A> })
view! { <A href=format!("/item/{}", story.id)>{title.clone()}</A> }.into_view()
}}
</span>
{(story.story_type != "link").then(|| view! {

View File

@@ -1,15 +1,13 @@
use crate::api;
use leptos::either::Either;
use leptos::prelude::*;
use leptos_meta::Meta;
use leptos_router::components::A;
use leptos_router::hooks::use_params_map;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[component]
pub fn Story() -> impl IntoView {
let params = use_params_map();
let story = Resource::new(
move || params.read().get("id").unwrap_or_default(),
let story = create_resource(
move || params.get().get("id").cloned().unwrap_or_default(),
move |id| async move {
if id.is_empty() {
None
@@ -19,13 +17,19 @@ pub fn Story() -> impl IntoView {
}
},
);
let meta_description = move || {
story
.get()
.and_then(|story| story.map(|story| story.title))
.unwrap_or_else(|| "Loading story...".to_string())
};
Suspense(SuspenseProps::builder().fallback(|| "Loading...").children(ToChildren::to_children(move || Suspend::new(async move {
match story.await.clone() {
None => Either::Left("Story not found."),
Some(story) => {
Either::Right(view! {
<Meta name="description" content=story.title.clone()/>
view! {
<Suspense fallback=|| view! { "Loading..." }>
<Meta name="description" content=meta_description/>
{move || story.get().map(|story| match story {
None => view! { <div class="item-view">"Error loading this story."</div> },
Some(story) => view! {
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
@@ -42,33 +46,32 @@ pub fn Story() -> impl IntoView {
</p>})}
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
let:comment
>
<Comment comment />
</For>
</ul>
</div>
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
let:comment
>
<Comment comment />
</For>
</ul>
</div>
})
}
}
}))).build())
</div>
}})}
</Suspense>
}
}
#[component]
pub fn Comment(comment: api::Comment) -> impl IntoView {
let (open, set_open) = signal(true);
let (open, set_open) = create_signal(true);
view! {
<li class="comment">
@@ -110,7 +113,7 @@ pub fn Comment(comment: api::Comment) -> impl IntoView {
}
})}
</li>
}.into_any()
}
}
fn pluralize(n: usize) -> &'static str {

View File

@@ -1,13 +1,12 @@
use crate::api::{self, User};
use leptos::server::Resource;
use leptos::{either::Either, prelude::*};
use leptos_router::hooks::use_params_map;
use leptos::*;
use leptos_router::*;
#[component]
pub fn User() -> impl IntoView {
let params = use_params_map();
let user = Resource::new(
move || params.read().get("id").unwrap_or_default(),
let user = create_resource(
move || params.get().get("id").cloned().unwrap_or_default(),
move |id| async move {
if id.is_empty() {
None
@@ -19,11 +18,11 @@ 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! {
{move || user.get().map(|user| match user {
None => view! { <h1>"User not found."</h1> }.into_view(),
Some(user) => view! {
<div>
<h1>"User: " {user.id.clone()}</h1>
<h1>"User: " {&user.id}</h1>
<ul class="meta">
<li>
<span class="label">"Created: "</span> {user.created}
@@ -39,8 +38,8 @@ pub fn User() -> impl IntoView {
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
</p>
</div>
})
}})}
}.into_view()
})}
</Suspense>
</div>
}

View File

@@ -11,11 +11,14 @@ codegen-units = 1
lto = true
[dependencies]
console_log = "1.0"
console_error_panic_hook = "0.1"
leptos = { path = "../../leptos" }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" }
log = "0.4"
simple_logger = "4.0"
serde = { version = "1.0", features = ["derive"] }
tracing = "0.1"
gloo-net = { version = "0.4", features = ["http"] }
@@ -27,12 +30,11 @@ tokio = { version = "1", features = ["full"], optional = true }
http = { version = "1.0", optional = true }
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
wasm-bindgen = "0.2"
send_wrapper = { version = "0.6.0", features = ["futures"] }
[features]
default = ["csr"]
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",

View File

@@ -1,5 +1,4 @@
use leptos::logging;
use serde::de::DeserializeOwned;
use leptos::Serializable;
use serde::{Deserialize, Serialize};
pub fn story(path: &str) -> String {
@@ -11,51 +10,46 @@ pub fn user(path: &str) -> String {
}
#[cfg(not(feature = "ssr"))]
pub fn fetch_api<T>(
path: &str,
) -> impl std::future::Future<Output = Option<T>> + Send + '_
pub async fn fetch_api<T>(path: &str) -> Option<T>
where
T: Serialize + DeserializeOwned,
T: Serializable,
{
use leptos::prelude::on_cleanup;
use send_wrapper::SendWrapper;
let abort_controller = web_sys::AbortController::new().ok();
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
SendWrapper::new(async move {
let abort_controller =
SendWrapper::new(web_sys::AbortController::new().ok());
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
// abort in-flight requests if e.g., we've navigated away from this page
leptos::on_cleanup(move || {
if let Some(abort_controller) = abort_controller {
abort_controller.abort()
}
});
// abort in-flight requests if, e.g., we've navigated away from this page
on_cleanup(move || {
if let Some(abort_controller) = abort_controller.take() {
abort_controller.abort()
}
});
let json = gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
.await
.map_err(|e| log::error!("{e}"))
.ok()?
.text()
.await
.ok()?;
gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
.await
.map_err(|e| logging::error!("{e}"))
.ok()?
.json()
.await
.ok()
})
T::de(&json).ok()
}
#[cfg(feature = "ssr")]
pub async fn fetch_api<T>(path: &str) -> Option<T>
where
T: Serialize + DeserializeOwned,
T: Serializable,
{
reqwest::get(path)
let json = reqwest::get(path)
.await
.map_err(|e| logging::error!("{e}"))
.map_err(|e| log::error!("{e}"))
.ok()?
.json()
.text()
.await
.ok()
.ok()?;
T::de(&json).map_err(|e| log::error!("{e}")).ok()
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]

View File

@@ -0,0 +1,48 @@
use crate::error_template::error_template;
use axum::{
body::Body,
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
use leptos::LeptosOptions;
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler =
leptos_axum::render_app_to_stream(options.to_owned(), || {
error_template(None)
});
handler(req).await.into_response()
}
}
async fn get_static_file(
uri: Uri,
root: &str,
) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
)),
}
}

View File

@@ -1,55 +1,31 @@
use leptos::prelude::*;
use leptos::{component, view, IntoView};
use leptos_meta::*;
use leptos_router::*;
mod api;
pub mod error_template;
#[cfg(feature = "ssr")]
pub mod fallback;
#[cfg(feature = "ssr")]
pub mod handlers;
mod routes;
use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet};
use leptos_router::{
components::{FlatRoutes, Route, Router, RoutingProgress},
ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
use std::time::Duration;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone() />
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[component]
pub fn App() -> impl IntoView {
provide_meta_context();
let (is_routing, set_is_routing) = signal(false);
view! {
<Stylesheet id="leptos" href="/pkg/hackernews_axum.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Stylesheet id="leptos" href="/pkg/hackernews_axum.css"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
<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 />
<main>
<FlatRoutes fallback=|| "Not found.">
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
<Route path=ParamSegment("stories") view=Stories/>
// TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
</FlatRoutes>
<Routes>
<Route path="users/:id" view=User/>
<Route path="stories/:id" view=Story/>
<Route path=":stories?" view=Stories/>
</Routes>
</main>
</Router>
}
@@ -58,6 +34,7 @@ pub fn App() -> impl IntoView {
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
leptos::mount_to_body(App);
}

View File

@@ -1,23 +1,24 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use hackernews_axum::{shell, App};
use leptos::config::get_configuration;
use axum::{routing::get, Router};
use hackernews_axum::{fallback::file_and_error_handler, *};
use leptos::get_configuration;
use leptos_axum::{generate_route_list, LeptosRoutes};
let conf = get_configuration(Some("Cargo.toml")).unwrap();
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
simple_logger::init_with_level(log::Level::Debug)
.expect("couldn't initialize logging");
// build our application with a route
let app = Router::new()
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.route("/favicon.ico", get(file_and_error_handler))
.leptos_routes(&leptos_options, routes, App)
.fallback(file_and_error_handler)
.with_state(leptos_options);
// run our app with hyper

View File

@@ -1,12 +1,12 @@
use leptos::prelude::*;
use leptos_router::components::A;
use leptos::{component, view, IntoView};
use leptos_router::*;
#[component]
pub fn Nav() -> impl IntoView {
view! {
<header class="header">
<nav class="inner">
<A href="/home">
<A href="/">
<strong>"HN"</strong>
</A>
<A href="/new">

View File

@@ -1,9 +1,6 @@
use crate::api;
use leptos::{either::Either, prelude::*};
use leptos_router::{
components::A,
hooks::{use_params_map, use_query_map},
};
use leptos::*;
use leptos_router::*;
fn category(from: &str) -> &'static str {
match from {
@@ -21,65 +18,62 @@ pub fn Stories() -> impl IntoView {
let params = use_params_map();
let page = move || {
query
.read()
.get("page")
.and_then(|page| page.parse::<usize>().ok())
.with(|q| q.get("page").and_then(|page| page.parse::<usize>().ok()))
.unwrap_or(1)
};
let story_type = move || {
params
.read()
.get("stories")
.with(|p| p.get("stories").cloned())
.unwrap_or_else(|| "top".to_string())
};
let stories = Resource::new(
let stories = create_resource(
move || (page(), story_type()),
move |(page, story_type)| async move {
let path = format!("{}?page={}", category(&story_type), page);
api::fetch_api::<Vec<api::Story>>(&api::story(&path)).await
},
);
let (pending, set_pending) = signal(false);
let (pending, set_pending) = create_signal(false);
let hide_more_link = move || match &*stories.read() {
Some(Some(stories)) => stories.len() < 28,
_ => true
} || pending.get();
let hide_more_link = move || {
stories.get().unwrap_or(None).unwrap_or_default().len() < 28
|| pending.get()
};
view! {
<div class="news-view">
<div class="news-list-nav">
<span>
{move || if page() > 1 {
Either::Left(view! {
view! {
<a class="page-link"
href=move || format!("/{}?page={}", story_type(), page() - 1)
aria-label="Previous Page"
attr:aria_label="Previous Page"
>
"< prev"
</a>
})
}.into_any()
} else {
Either::Right(view! {
view! {
<span class="page-link disabled" aria-hidden="true">
"< prev"
</span>
})
}.into_any()
}}
</span>
<span>"page " {page}</span>
<Suspense>
<span class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
<span class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
>
"more >"
</a>
</span>
</Suspense>
"more >"
</a>
</span>
</div>
<main class="news-list">
<div>
@@ -87,19 +81,23 @@ pub fn Stories() -> impl IntoView {
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()
key=|story| story.id
let:story
>
<Story story/>
</For>
</ul>
{move || match stories.get() {
None => None,
Some(None) => Some(view! { <p>"Error loading stories."</p> }.into_any()),
Some(Some(stories)) => {
Some(view! {
<ul>
<For
each=move || stories.clone()
key=|story| story.id
let:story
>
<Story story/>
</For>
</ul>
}.into_any())
}
}}
</Transition>
</div>
</main>
@@ -114,23 +112,23 @@ fn Story(story: api::Story) -> impl IntoView {
<span class="score">{story.points}</span>
<span class="title">
{if !story.url.starts_with("item?id=") {
Either::Left(view! {
view! {
<span>
<a href=story.url target="_blank" rel="noreferrer">
{story.title.clone()}
</a>
<span class="host">"("{story.domain}")"</span>
</span>
})
}.into_view()
} else {
let title = story.title.clone();
Either::Right(view! { <A href=format!("/stories/{}", story.id)>{title}</A> })
view! { <A href=format!("/stories/{}", story.id)>{title.clone()}</A> }.into_view()
}}
</span>
<br />
<span class="meta">
{if story.story_type != "job" {
Either::Left(view! {
view! {
<span>
{"by "}
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
@@ -143,10 +141,10 @@ fn Story(story: api::Story) -> impl IntoView {
}}
</A>
</span>
})
}.into_view()
} else {
let title = story.title.clone();
Either::Right(view! { <A href=format!("/item/{}", story.id)>{title}</A> })
view! { <A href=format!("/item/{}", story.id)>{title.clone()}</A> }.into_view()
}}
</span>
{(story.story_type != "link").then(|| view! {

View File

@@ -1,15 +1,13 @@
use crate::api;
use leptos::either::Either;
use leptos::prelude::*;
use leptos_meta::Meta;
use leptos_router::components::A;
use leptos_router::hooks::use_params_map;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[component]
pub fn Story() -> impl IntoView {
let params = use_params_map();
let story = Resource::new(
move || params.read().get("id").unwrap_or_default(),
let story = create_resource(
move || params.get().get("id").cloned().unwrap_or_default(),
move |id| async move {
if id.is_empty() {
None
@@ -19,13 +17,19 @@ pub fn Story() -> impl IntoView {
}
},
);
let meta_description = move || {
story
.get()
.and_then(|story| story.map(|story| story.title))
.unwrap_or_else(|| "Loading story...".to_string())
};
Suspense(SuspenseProps::builder().fallback(|| "Loading...").children(ToChildren::to_children(move || Suspend::new(async move {
match story.await.clone() {
None => Either::Left("Story not found."),
Some(story) => {
Either::Right(view! {
<Meta name="description" content=story.title.clone()/>
view! {
<Suspense fallback=|| view! { "Loading..." }>
<Meta name="description" content=meta_description/>
{move || story.get().map(|story| match story {
None => view! { <div class="item-view">"Error loading this story."</div> },
Some(story) => view! {
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
@@ -34,7 +38,7 @@ pub fn Story() -> impl IntoView {
<span class="host">
"("{story.domain}")"
</span>
{story.user.map(|user| view! { <p class="meta">
{story.user.map(|user| view! { <p class="meta">
{story.points}
" points | by "
<A href=format!("/users/{user}")>{user.clone()}</A>
@@ -42,33 +46,32 @@ pub fn Story() -> impl IntoView {
</p>})}
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
let:comment
>
<Comment comment />
</For>
</ul>
</div>
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
let:comment
>
<Comment comment />
</For>
</ul>
</div>
})
}
}
}))).build())
</div>
}})}
</Suspense>
}
}
#[component]
pub fn Comment(comment: api::Comment) -> impl IntoView {
let (open, set_open) = signal(true);
let (open, set_open) = create_signal(true);
view! {
<li class="comment">
@@ -77,10 +80,10 @@ pub fn Comment(comment: api::Comment) -> impl IntoView {
{format!(" {}", comment.time_ago)}
</div>
<div class="text" inner_html=comment.content></div>
{(!comment.comments.is_empty()).then(|| {
{(!comment.comments.is_empty()).then(move || {
view! {
<div>
<div class="toggle" class:open=open>
<div class="toggle" class:open=move ||open.get()>
<a on:click=move |_| set_open.update(|n| *n = !*n)>
{
let comments_len = comment.comments.len();
@@ -110,7 +113,7 @@ pub fn Comment(comment: api::Comment) -> impl IntoView {
}
})}
</li>
}.into_any()
}
}
fn pluralize(n: usize) -> &'static str {

View File

@@ -1,13 +1,12 @@
use crate::api::{self, User};
use leptos::server::Resource;
use leptos::{either::Either, prelude::*};
use leptos_router::hooks::use_params_map;
use leptos::*;
use leptos_router::*;
#[component]
pub fn User() -> impl IntoView {
let params = use_params_map();
let user = Resource::new(
move || params.read().get("id").unwrap_or_default(),
let user = create_resource(
move || params.get().get("id").cloned().unwrap_or_default(),
move |id| async move {
if id.is_empty() {
None
@@ -18,12 +17,12 @@ 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! {
<Suspense fallback=|| view! { "Loading..." }>
{move || user.get().map(|user| match user {
None => view! { <h1>"User not found."</h1> }.into_any(),
Some(user) => view! {
<div>
<h1>"User: " {user.id.clone()}</h1>
<h1>"User: " {&user.id}</h1>
<ul class="meta">
<li>
<span class="label">"Created: "</span> {user.created}
@@ -31,7 +30,7 @@ pub fn User() -> impl IntoView {
<li>
<span class="label">"Karma: "</span> {user.karma}
</li>
<li inner_html={user.about} class="about"></li>
{user.about.as_ref().map(|about| view! { <li inner_html=about class="about"></li> })}
</ul>
<p class="links">
<a href=format!("https://news.ycombinator.com/submitted?id={}", user.id)>"submissions"</a>
@@ -39,8 +38,8 @@ pub fn User() -> impl IntoView {
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
</p>
</div>
})
}})}
}.into_any()
})}
</Suspense>
</div>
}

View File

@@ -11,13 +11,18 @@ codegen-units = 1
lto = true
[dependencies]
console_log = "1.0"
console_error_panic_hook = "0.1"
leptos = { path = "../../leptos", features = [
"experimental-islands",
] }
leptos_axum = { path = "../../integrations/axum", optional = true }
leptos_axum = { path = "../../integrations/axum", optional = true, features = [
"experimental-islands",
] }
leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router"}
log = "0.4"
simple_logger = "4.0"
serde = { version = "1.0", features = ["derive"] }
tracing = "0.1"
gloo-net = { version = "0.4", features = ["http"] }
@@ -26,7 +31,6 @@ axum = { version = "0.7", optional = true, features = ["http2"] }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = [
"fs",
"compression-gzip",
"compression-br",
], optional = true }
tokio = { version = "1", features = ["full"], optional = true }
@@ -34,21 +38,17 @@ http = { version = "1.0", optional = true }
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
wasm-bindgen = "0.2"
lazy_static = "1.4.0"
rust-embed = { version = "8", features = ["axum", "mime_guess", "tokio"], optional = true }
mime_guess = { version = "2.0.4", optional = true }
[features]
default = []
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:axum",
"dep:tower",
"dep:tower-http",
"dep:tokio",
"dep:http",
"dep:rust-embed",
"dep:mime_guess",
"leptos/ssr",
"leptos_axum",
"leptos_meta/ssr",
@@ -94,12 +94,6 @@ bin-features = ["ssr"]
# Optional. Defaults to false.
bin-default-features = false
# This feature will add a hash to the filename of assets.
# This is useful here because our files are precompressed and use a `Cache-Control` policy to reduce HTTP requests
#
# Optional. Defaults to false.
hash_file = true
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features

View File

@@ -1,6 +1,6 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/cargo-leptos-compress.toml" },
{ path = "../cargo-make/cargo-leptos.toml" },
]
[env]

View File

@@ -1,11 +1,6 @@
# Leptos Hacker News Example with Axum
This example creates a basic clone of the Hacker News site. It showcases Leptos' ability to:
- Create a client-side rendered app
- Create a server side rendered app with hydration
- Precompress static assets and bundle those in with the server binary
This repo differs from the main Hacker News example by using Axum as it's server, precompressing and embedding static assets into the binary, and dynamically compressing the generated HTML.
This example creates a basic clone of the Hacker News site. It showcases Leptos' ability to create both a client-side rendered app, and a server side rendered app with hydration, in a single repository. This repo differs from the main Hacker News example by using Axum as it's server.
## Getting Started
@@ -13,4 +8,4 @@ See the [Examples README](../README.md) for setup and run instructions.
## Quick Start
Run `cargo leptos watch --release -P` to run this example.
Run `cargo leptos watch` to run this example.

View File

@@ -0,0 +1,8 @@
wasm-pack build --target=web --features=hydrate --release
cd pkg
rm *.br
cp hackernews.js hackernews.unmin.js
cat hackernews.unmin.js | esbuild > hackernews.js
brotli hackernews.js
brotli hackernews_bg.wasm
brotli style.css

View File

@@ -1,30 +1,27 @@
#[cfg(feature = "ssr")]
use serde::de::DeserializeOwned;
#![allow(unused)]
use leptos::Serializable;
use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
pub fn story(path: &str) -> String {
format!("https://node-hnapi.herokuapp.com/{path}")
}
#[cfg(feature = "ssr")]
pub fn user(path: &str) -> String {
format!("https://hacker-news.firebaseio.com/v0/user/{path}.json")
}
lazy_static::lazy_static! {
static ref CLIENT: reqwest::Client = reqwest::Client::new();
}
#[cfg(feature = "ssr")]
pub async fn fetch_api<T>(path: &str) -> Option<T>
where
T: Serialize + DeserializeOwned,
T: Serializable,
{
use leptos::logging;
reqwest::get(path)
.await
.map_err(|e| logging::error!("{e}"))
.ok()?
.json()
.await
.ok()
let json = CLIENT.get(path).send().await.ok()?.text().await.ok()?;
T::de(&json).map_err(|e| log::error!("{e}")).ok()
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]

View File

@@ -0,0 +1,28 @@
use leptos::{view, Errors, For, IntoView, RwSignal, SignalGet, View};
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
// here than just displaying them
pub fn error_template(errors: Option<RwSignal<Errors>>) -> View {
let Some(errors) = errors else {
panic!("No Errors found and we expected errors!");
};
view! {
<h1>"Errors"</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each=move || errors.get()
// a unique key for each item as a reference
key=|(key, _)| key.clone()
// renders each item to a view
children= move | (_, error)| {
let error_string = error.to_string();
view! {
<p>"Error: " {error_string}</p>
}
}
/>
}
.into_view()
}

View File

@@ -1,91 +1,48 @@
use crate::error_template::error_template;
use axum::{
body::Body,
http::{header, Request, Response, StatusCode, Uri},
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
use std::borrow::Cow;
#[cfg(not(debug_assertions))]
const DEV_MODE: bool = false;
#[cfg(debug_assertions)]
const DEV_MODE: bool = true;
#[derive(rust_embed::RustEmbed)]
#[folder = "target/site/"]
struct Assets;
use leptos::LeptosOptions;
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let accept_encoding = req
.headers()
.get("accept-encoding")
.map(|h| h.to_str().unwrap_or("none"))
.unwrap_or("none")
.to_string();
let res = get_static_file(uri.clone(), accept_encoding).await.unwrap();
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
(StatusCode::NOT_FOUND, "Not found.").into_response()
let handler =
leptos_axum::render_app_to_stream(options.to_owned(), || {
error_template(None)
});
handler(req).await.into_response()
}
}
async fn get_static_file(
uri: Uri,
accept_encoding: String,
root: &str,
) -> Result<Response<Body>, (StatusCode, String)> {
let (_, path) = uri.path().split_at(1); // split off the first `/`
let mime = mime_guess::from_path(path);
let (path, encoding) = if DEV_MODE {
// during DEV, don't care about the precompression -> faster workflow
(Cow::from(path), "none")
} else if accept_encoding.contains("br") {
(Cow::from(format!("{}.br", path)), "br")
} else if accept_encoding.contains("gzip") {
(Cow::from(format!("{}.gz", path)), "gzip")
} else {
(Cow::from(path), "none")
};
match Assets::get(path.as_ref()) {
Some(content) => {
let body = Body::from(content.data);
let res = match DEV_MODE {
true => Response::builder()
.header(
header::CONTENT_TYPE,
mime.first_or_octet_stream().as_ref(),
)
.header(header::CONTENT_ENCODING, encoding)
.body(body)
.unwrap(),
false => Response::builder()
.header(header::CACHE_CONTROL, "max-age=86400")
.header(
header::CONTENT_TYPE,
mime.first_or_octet_stream().as_ref(),
)
.header(header::CONTENT_ENCODING, encoding)
.body(body)
.unwrap(),
};
Ok(res.into_response())
}
None => {
eprintln!(">> Asset {} not found", path);
for a in Assets::iter() {
eprintln!("Available asset: {}", a);
}
Err((StatusCode::NOT_FOUND, "Not found".to_string()))
}
let req = Request::builder()
.uri(uri.clone())
.body(Body::empty())
.unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", err),
)),
}
}

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