Compare commits

..

24 Commits

Author SHA1 Message Date
Greg Johnston
05d2eb8ce0 Improve CI disk space usage 2023-01-07 07:43:52 -05:00
Greg Johnston
e12c2d9769 Merge pull request #252 from gbj/additional-meta-tags
Additional meta tags — closes issue #158
2023-01-07 07:37:02 -05:00
Greg Johnston
825245b65f Merge pull request #256 from gbj/router-tests
Fix router tests when no features enabled
2023-01-07 07:36:51 -05:00
Greg Johnston
844dc21efd Merge pull request #255 from martinfrances107/#253_cargo_doc_warnings_rename_EachKey_to_Each
#254 Minor: In docs, Rename EachKey to Each.
2023-01-07 07:25:34 -05:00
Greg Johnston
1a00e99a24 Merge pull request #254 from martinfrances107/#253_unbalanced_tags
#254 Minor: Unbalanced tags.
2023-01-07 07:25:03 -05:00
Martin
6c5bcf30ba #254 Minor: In docs, Rename EachKey to Each. 2023-01-07 11:51:52 +00:00
Martin
be8ffe935d #254 Minor: Unbalanced tags. 2023-01-07 11:22:36 +00:00
Greg Johnston
0b80bba4ec Fix tests 2023-01-06 23:04:25 -05:00
Greg Johnston
9cc38988d8 Corrects style docs 2023-01-06 22:56:23 -05:00
Greg Johnston
0d92a5dec8 Add <Script/> and <Style/> components 2023-01-06 22:54:35 -05:00
Greg Johnston
0029e1d8f7 Merge pull request #251 from martinfrances107/bump_actions_checkout
Bump actions/checkout to version@3
2023-01-06 17:47:21 -05:00
Greg Johnston
635aa5c681 Merge pull request #250 from jquesada2016/on_mount
added `HtmlElement::on_mount`
2023-01-06 17:46:29 -05:00
Martin
f5c4c9448c Bump actions/checkout to version@3 2023-01-06 22:31:13 +00:00
Greg Johnston
bc43a9d329 Use builder syntax and refactor tag registration 2023-01-06 16:49:26 -05:00
Greg Johnston
1850c28d3a Add <Link/> and refactor <Stylesheet/> to use it 2023-01-06 16:06:03 -05:00
Greg Johnston
319a058e63 Fix relative route for stylesheet in hackernews 2023-01-06 16:06:03 -05:00
Greg Johnston
678e49268f Merge pull request #248 from gbj/tracing
Adding `tracing` to `leptos_reactive`
2023-01-06 15:18:54 -05:00
Jose Quesada
6df4a6f120 fixed broken compilation within on_mount 2023-01-06 14:17:35 -06:00
Jose Quesada
73c6bbb225 updated to use the equest animation frame method in helpers.rs 2023-01-06 14:02:49 -06:00
Jose Quesada
fa57085946 added HtmlElement::on_mount 2023-01-06 12:24:24 -06:00
Greg Johnston
aef589cd24 Merge pull request #249 from gbj/router-tests
Add all the missing `router` tests
2023-01-06 12:49:44 -05:00
Greg Johnston
1125a5f7cb Additional tracing 2023-01-06 12:30:19 -05:00
Greg Johnston
dfba1d9656 Memos 2023-01-06 11:38:03 -05:00
Greg Johnston
96418ed684 Start work on instrumenting leptos_reactive 2023-01-06 11:38:03 -05:00
27 changed files with 1243 additions and 320 deletions

View File

@@ -21,7 +21,7 @@ jobs:
- ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Setup Rust
uses: actions-rs/toolchain@v1

View File

@@ -28,7 +28,6 @@ gloo-net = { git = "https://github.com/rustwasm/gloo" }
[features]
default = []
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:actix-files",
@@ -41,31 +40,31 @@ ssr = [
stable = ["leptos/stable", "leptos_router/stable"]
[package.metadata.cargo-all-features]
denylist = ["actix-files", "actix-web", "leptos_actix"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]
denylist = ["actix-files", "actix-web", "leptos_actix", "stable"]
skip_feature_sets = [["ssr", "hydrate"]]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "counter_isomorphic"
output-name = "counter_isomorphic"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
# style-file = "src/styles/tailwind.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
# assets-dir = "static/assets"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-address = "127.0.0.1:3000"
site-address = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "npx playwright test"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
browserquery = "defaults"
# Set by cargo-leptos watch when building with tha tool. Controls whether autoreload JS will be included in the head
watch = false
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
@@ -86,4 +85,4 @@ lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
lib-default-features = false

View File

@@ -15,7 +15,7 @@ pub fn App(cx: Scope) -> impl IntoView {
view! {
cx,
<>
<Stylesheet id="leptos" href="./target/site/pkg/hackernews.css"/>
<Stylesheet id="leptos" href="/target/site/pkg/hackernews.css"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
<Router>
<Nav />

View File

@@ -12,7 +12,7 @@ cfg-if = "1"
drain_filter_polyfill = "0.1"
educe = "0.4"
futures = "0.3"
gloo = "0.8"
gloo = { version = "0.8", features = ["futures"] }
html-escape = "0.2"
indexmap = "1.9"
itertools = "0.10"
@@ -140,6 +140,7 @@ features = [
]
[features]
default = []
web = ["leptos_reactive/csr"]
ssr = ["leptos_reactive/ssr"]
stable = ["leptos_reactive/stable"]

View File

@@ -6,8 +6,10 @@ edition = "2021"
[dependencies]
console_error_panic_hook = "0.1"
gloo = { version = "0.8", features = ["futures"] }
leptos = { path = "../../../leptos" }
leptos = { path = "../../../leptos", features = ["tracing"] }
tracing = "0.1"
tracing-subscriber = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = "0.3"
web-sys = "0.3"
[workspace]

View File

@@ -6,6 +6,7 @@ extern crate tracing;
mod utils;
use leptos::*;
use tracing::field::debug;
use tracing_subscriber::util::SubscriberInitExt;
fn main() {
@@ -58,6 +59,7 @@ fn view_fn(cx: Scope) -> impl IntoView {
<div>
<button on:click=handle_toggle>"Toggle"</button>
</div>
<Example/>
<A child=Signal::from(a) />
<A child=Signal::from(b) />
</>
@@ -71,7 +73,23 @@ fn A(cx: Scope, child: Signal<View>) -> impl IntoView {
#[component]
fn Example(cx: Scope) -> impl IntoView {
view! { cx,
trace!("rendering <Example/>");
let (value, set_value) = create_signal(cx, 10);
let memo = create_memo(cx, move |_| value() * 2);
let derived = Signal::derive(cx, move || {
value() * 3
});
create_effect(cx, move |_| {
trace!("logging value of derived..., {}", derived.get());
});
set_timeout(move || { set_value.update(|v| *v += 1)}, std::time::Duration::from_millis(50));
view! { cx,
<h1>"Example"</h1>
}
}

View File

@@ -41,7 +41,7 @@ cfg_if! {
use smallvec::SmallVec;
use std::{borrow::Cow, cell::RefCell, fmt, hash::Hash, ops::Deref, rc::Rc};
/// The internal representation of the [`EachKey`] core-component.
/// The internal representation of the [`Each`] core-component.
#[derive(Clone, PartialEq, Eq)]
pub struct EachRepr {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
@@ -156,7 +156,7 @@ impl Mountable for EachRepr {
}
}
/// The internal representation of an [`EachKey`] item.
/// The internal representation of an [`Each`] item.
#[derive(PartialEq, Eq)]
pub(crate) struct EachItem {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
@@ -293,7 +293,7 @@ where
K: Eq + Hash + 'static,
T: 'static,
{
/// Creates a new [`EachKey`] component.
/// Creates a new [`Each`] component.
pub fn new(items_fn: IF, key_fn: KF, each_fn: EF) -> Self {
Self {
items_fn,

View File

@@ -71,8 +71,8 @@ pub fn event_target_checked(ev: &web_sys::Event) -> bool {
/// Runs the given function between the next repaint
/// using [`Window.requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame).
pub fn request_animation_frame(cb: impl FnMut() + 'static) {
let cb = Closure::wrap(Box::new(cb) as Box<dyn FnMut()>).into_js_value();
pub fn request_animation_frame(cb: impl FnOnce() + 'static) {
let cb = Closure::once_into_js(cb);
_ = window().request_animation_frame(cb.as_ref().unchecked_ref());
}

View File

@@ -229,7 +229,7 @@ where
}
}
impl<El: ElementDescriptor> HtmlElement<El> {
impl<El: ElementDescriptor + 'static> HtmlElement<El> {
fn new(cx: Scope, element: El) -> Self {
cfg_if! {
if #[cfg(all(target_arch = "wasm32", feature = "web"))] {
@@ -346,6 +346,71 @@ impl<El: ElementDescriptor> HtmlElement<El> {
self
}
/// Runs the callback when this element has been mounted to the DOM.
///
/// ### Important Note
/// This method will only ever run at most once. If this element
/// is unmounted and remounted, or moved somewhere else, it will not
/// re-run unless you call this method again.
pub fn on_mount(self, f: impl FnOnce(Self) + 'static) -> Self {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
{
use futures::future::poll_fn;
use once_cell::unsync::OnceCell;
use std::{
cell::RefCell,
rc::Rc,
task::{Poll, Waker},
};
let this = self.clone();
let el = self.element.as_ref().clone();
wasm_bindgen_futures::spawn_local(async move {
while !crate::document().body().unwrap().contains(Some(&el)) {
// We need to cook ourselves a small future that resolves
// when the next animation frame is available
let waker = Rc::new(RefCell::new(None::<Waker>));
let ready = Rc::new(OnceCell::new());
crate::request_animation_frame({
let waker = waker.clone();
let ready = ready.clone();
move || {
let _ = ready.set(());
if let Some(waker) = &*waker.borrow() {
waker.wake_by_ref();
}
}
});
// Wait for the animation frame to become available
poll_fn(move |cx| {
let mut waker_borrow = waker.borrow_mut();
*waker_borrow = Some(cx.waker().clone());
if ready.get().is_some() {
Poll::Ready(())
} else {
Poll::<()>::Pending
}
})
.await;
}
f(this);
});
}
#[cfg(not(all(target_arch = "wasm32", feature = "web")))]
{
let _ = f;
}
self
}
/// Adds an attribute to this element.
#[track_caller]
pub fn attr(

View File

@@ -192,7 +192,7 @@ cfg_if! {
}
impl Element {
/// Converts this leptos [`Element`] into [`HtmlElement<AnyElement`].
/// Converts this leptos [`Element`] into [`HtmlElement<AnyElement>`].
pub fn into_html_element(self, cx: Scope) -> HtmlElement<AnyElement> {
#[cfg(all(target_arch = "wasm32", feature = "web"))]
{
@@ -327,7 +327,8 @@ pub struct Text {
/// to possibly reuse a previous node.
#[cfg(all(target_arch = "wasm32", feature = "web"))]
node: web_sys::Node,
content: Cow<'static, str>,
/// The current contents of the text node.
pub content: Cow<'static, str>,
}
impl fmt::Debug for Text {

View File

@@ -20,6 +20,7 @@ serde_json = "1"
base64 = "0.13"
thiserror = "1"
tokio = { version = "1", features = ["rt"], optional = true }
tracing = "0.1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
@@ -47,4 +48,29 @@ miniserde = ["dep:miniserde"]
[package.metadata.cargo-all-features]
denylist = ["stable"]
skip_feature_sets = [
[
"csr",
"ssr",
],
[
"csr",
"hydrate",
],
[
"ssr",
"hydrate",
],
[
"serde",
"serde-lite",
],
[
"serde-lite",
"miniserde",
],
[
"serde",
"miniserde",
],
]

View File

@@ -47,13 +47,26 @@ use std::fmt::Debug;
/// # }
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
scope = %format!("{:?}", cx.id),
ty = %std::any::type_name::<T>()
)
)
)]
#[track_caller]
pub fn create_effect<T>(cx: Scope, f: impl Fn(Option<T>) -> T + 'static)
where
T: 'static,
{
cfg_if! {
if #[cfg(not(feature = "ssr"))] {
create_isomorphic_effect(cx, f);
let e = cx.runtime.create_effect(f);
cx.with_scope_property(|prop| prop.push(ScopeProperty::Effect(e)))
} else {
// clear warnings
_ = cx;
@@ -88,6 +101,18 @@ where
/// });
/// # assert_eq!(b(), 2);
/// # }).dispose();
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
scope = %format!("{:?}", cx.id),
ty = %std::any::type_name::<T>()
)
)
)]
#[track_caller]
pub fn create_isomorphic_effect<T>(cx: Scope, f: impl Fn(Option<T>) -> T + 'static)
where
T: 'static,
@@ -97,6 +122,17 @@ where
}
#[doc(hidden)]
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
scope = %format!("{:?}", cx.id),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn create_render_effect<T>(cx: Scope, f: impl Fn(Option<T>) -> T + 'static)
where
T: 'static,
@@ -116,6 +152,8 @@ where
{
pub(crate) f: F,
pub(crate) value: RefCell<Option<T>>,
#[cfg(debug_assertions)]
pub(crate) defined_at: &'static std::panic::Location<'static>,
}
pub(crate) trait AnyEffect {
@@ -127,6 +165,19 @@ where
T: 'static,
F: Fn(Option<T>) -> T,
{
#[cfg_attr(
debug_assertions,
instrument(
name = "Effect::run()",
level = "debug",
skip_all,
fields(
id = %format!("{:?}", id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn run(&self, id: EffectId, runtime: RuntimeId) {
with_runtime(runtime, |runtime| {
// clear previous dependencies
@@ -162,6 +213,17 @@ impl EffectId {
})
}
#[cfg_attr(
debug_assertions,
instrument(
name = "Effect::cleanup()",
level = "debug",
skip_all,
fields(
id = %format!("{:?}", self),
)
)
)]
pub(crate) fn cleanup(&self, runtime: &Runtime) {
let sources = runtime.effect_sources.borrow();
if let Some(sources) = sources.get(*self) {

View File

@@ -66,11 +66,13 @@
//! });
//! ```
#[cfg_attr(debug_assertions, macro_use)]
pub extern crate tracing;
mod context;
mod effect;
mod hydration;
mod memo;
mod resource;
mod runtime;
mod scope;

View File

@@ -54,6 +54,16 @@ use std::fmt::Debug;
/// });
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
cx = %format!("{:?}", cx.id),
)
)
)]
pub fn create_memo<T>(cx: Scope, f: impl Fn(Option<&T>) -> T + 'static) -> Memo<T>
where
T: PartialEq + Debug + 'static,
@@ -115,7 +125,10 @@ where
/// # }).dispose();
/// ```
#[derive(Debug, PartialEq, Eq)]
pub struct Memo<T>(pub(crate) ReadSignal<Option<T>>)
pub struct Memo<T>(
pub(crate) ReadSignal<Option<T>>,
#[cfg(debug_assertions)] pub(crate) &'static std::panic::Location<'static>,
)
where
T: 'static;
@@ -124,13 +137,30 @@ where
T: 'static,
{
fn clone(&self) -> Self {
Self(self.0)
Self(
self.0,
#[cfg(debug_assertions)]
self.1,
)
}
}
impl<T> Copy for Memo<T> {}
impl<T> UntrackedGettableSignal<T> for Memo<T> {
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "Memo::get_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.0.id),
defined_at = %format!("{:?}", self.1),
ty = %std::any::type_name::<T>()
)
)
)]
fn get_untracked(&self) -> T
where
T: Clone,
@@ -140,6 +170,19 @@ impl<T> UntrackedGettableSignal<T> for Memo<T> {
self.0.get_untracked().unwrap()
}
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "Memo::with_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.0.id),
defined_at = %format!("{:?}", self.1),
ty = %std::any::type_name::<T>()
)
)
)]
fn with_untracked<O>(&self, f: impl FnOnce(&T) -> O) -> O {
// Unwrapping here is fine for the same reasons as <Memo as
// UntrackedSignal>::get_untracked
@@ -167,6 +210,18 @@ where
/// # }).dispose();
/// #
/// ```
#[cfg_attr(
debug_assertions,
instrument(
name = "Memo::get()",
level = "trace",
skip_all,
fields(
id = %format!("{:?}", self.0.id),
defined_at = %format!("{:?}", self.1)
)
)
)]
pub fn get(&self) -> T
where
T: Clone,
@@ -194,6 +249,19 @@ where
/// # }).dispose();
/// #
/// ```
#[cfg_attr(
debug_assertions,
instrument(
name = "Memo::with()",
level = "trace",
skip_all,
fields(
id = %format!("{:?}", self.0.id),
defined_at = %format!("{:?}", self.1),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn with<U>(&self, f: impl FnOnce(&T) -> U) -> U {
// okay to unwrap here, because the value will *always* have initially
// been set by the effect, synchronously

View File

@@ -1,3 +1,10 @@
use crate::{
create_effect, create_isomorphic_effect, create_memo, create_signal, queue_microtask,
runtime::{with_runtime, RuntimeId},
serialization::Serializable,
spawn::spawn_local,
use_context, Memo, ReadSignal, Scope, ScopeProperty, SuspenseContext, WriteSignal,
};
use std::{
any::Any,
cell::{Cell, RefCell},
@@ -8,13 +15,6 @@ use std::{
pin::Pin,
rc::Rc,
};
use crate::{
create_effect, create_isomorphic_effect, create_memo, create_signal, queue_microtask,
runtime::{with_runtime, RuntimeId},
serialization::Serializable,
spawn::spawn_local,
use_context, Memo, ReadSignal, Scope, ScopeProperty, SuspenseContext, WriteSignal,
};
/// Creates [Resource](crate::Resource), which is a signal that reflects the
/// current state of an asynchronous task, allowing you to integrate `async`
@@ -83,6 +83,19 @@ where
/// output type of the Future to be [Serializable]. If your output cannot be
/// serialized, or you just want to make sure the [Future] runs locally, use
/// [create_local_resource_with_initial_value()].
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
scope = %format!("{:?}", cx.id),
ty = %std::any::type_name::<T>(),
signal_ty = %std::any::type_name::<S>(),
)
)
)]
#[track_caller]
pub fn create_resource_with_initial_value<S, T, Fu>(
cx: Scope,
source: impl Fn() -> S + 'static,
@@ -133,6 +146,8 @@ where
id,
source_ty: PhantomData,
out_ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
@@ -187,6 +202,19 @@ where
/// Unlike [create_resource_with_initial_value()], this [Future] will always run
/// on the local system and therefore its output type does not need to be
/// [Serializable].
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
scope = %format!("{:?}", cx.id),
ty = %std::any::type_name::<T>(),
signal_ty = %std::any::type_name::<S>(),
)
)
)]
#[track_caller]
pub fn create_local_resource_with_initial_value<S, T, Fu>(
cx: Scope,
source: impl Fn() -> S + 'static,
@@ -237,6 +265,8 @@ where
id,
source_ty: PhantomData,
out_ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
@@ -430,6 +460,8 @@ where
pub(crate) id: ResourceId,
pub(crate) source_ty: PhantomData<S>,
pub(crate) out_ty: PhantomData<T>,
#[cfg(debug_assertions)]
pub(crate) defined_at: &'static std::panic::Location<'static>,
}
// Resources
@@ -449,6 +481,8 @@ where
id: self.id,
source_ty: PhantomData,
out_ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: self.defined_at,
}
}
}
@@ -635,7 +669,9 @@ where
})
});
Box::pin(async move {
rx.next().await.expect("failed while trying to resolve Resource serializer")
rx.next()
.await
.expect("failed while trying to resolve Resource serializer")
})
}
}

View File

@@ -112,6 +112,7 @@ impl RuntimeId {
ret
}
#[track_caller]
pub(crate) fn create_signal<T>(self, value: T) -> (ReadSignal<T>, WriteSignal<T>)
where
T: Any + 'static,
@@ -127,11 +128,15 @@ impl RuntimeId {
runtime: self,
id,
ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller()
},
WriteSignal {
runtime: self,
id,
ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller()
},
)
}
@@ -150,17 +155,25 @@ impl RuntimeId {
runtime: self,
id,
ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller()
}
}
#[track_caller]
pub(crate) fn create_effect<T>(self, f: impl Fn(Option<T>) -> T + 'static) -> EffectId
where
T: Any + 'static,
{
#[cfg(debug_assertions)]
let defined_at = std::panic::Location::caller();
with_runtime(self, |runtime| {
let effect = Effect {
f,
value: RefCell::new(None),
#[cfg(debug_assertions)]
defined_at
};
let id = { runtime.effects.borrow_mut().insert(Rc::new(effect)) };
id.run::<T>(self);
@@ -168,10 +181,14 @@ impl RuntimeId {
})
}
#[track_caller]
pub(crate) fn create_memo<T>(self, f: impl Fn(Option<&T>) -> T + 'static) -> Memo<T>
where
T: PartialEq + Any + 'static,
{
#[cfg(debug_assertions)]
let defined_at = std::panic::Location::caller();
let (read, write) = self.create_signal(None);
self.create_effect(move |_| {
@@ -186,7 +203,11 @@ impl RuntimeId {
}
});
Memo(read)
Memo(
read,
#[cfg(debug_assertions)]
defined_at
)
}
}

View File

@@ -45,6 +45,18 @@ use thiserror::Error;
/// # }).dispose();
/// #
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
scope = %format!("{:?}", cx.id),
ty = %std::any::type_name::<T>()
)
)
)]
#[track_caller]
pub fn create_signal<T>(cx: Scope, value: T) -> (ReadSignal<T>, WriteSignal<T>) {
let s = cx.runtime.create_signal(value);
cx.with_scope_property(|prop| prop.push(ScopeProperty::Signal(s.0.id)));
@@ -54,6 +66,16 @@ pub fn create_signal<T>(cx: Scope, value: T) -> (ReadSignal<T>, WriteSignal<T>)
/// Creates a signal that always contains the most recent value emitted by a [Stream].
/// If the stream has not yet emitted a value since the signal was created, the signal's
/// value will be `None`.
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
scope = %format!("{:?}", cx.id),
)
)
)]
pub fn create_signal_from_stream<T>(
cx: Scope,
mut stream: impl Stream<Item = T> + Unpin + 'static,
@@ -120,9 +142,24 @@ where
pub(crate) runtime: RuntimeId,
pub(crate) id: SignalId,
pub(crate) ty: PhantomData<T>,
#[cfg(debug_assertions)]
pub(crate) defined_at: &'static std::panic::Location<'static>,
}
impl<T> UntrackedGettableSignal<T> for ReadSignal<T> {
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "ReadSignal::get_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn get_untracked(&self) -> T
where
T: Clone,
@@ -130,6 +167,19 @@ impl<T> UntrackedGettableSignal<T> for ReadSignal<T> {
self.with_no_subscription(|v| v.clone())
}
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "ReadSignal::with_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn with_untracked<O>(&self, f: impl FnOnce(&T) -> O) -> O {
self.with_no_subscription(f)
}
@@ -157,6 +207,19 @@ where
/// assert_eq!(first_char(), 'B');
/// });
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "ReadSignal::with()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn with<U>(&self, f: impl FnOnce(&T) -> U) -> U {
self.id.with(self.runtime, f)
}
@@ -184,6 +247,19 @@ where
/// assert_eq!(count(), 0);
/// });
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "ReadSignal::get()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn get(&self) -> T
where
T: Clone,
@@ -219,6 +295,8 @@ impl<T> Clone for ReadSignal<T> {
runtime: self.runtime,
id: self.id,
ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: self.defined_at,
}
}
}
@@ -298,21 +376,62 @@ where
pub(crate) runtime: RuntimeId,
pub(crate) id: SignalId,
pub(crate) ty: PhantomData<T>,
#[cfg(debug_assertions)]
pub(crate) defined_at: &'static std::panic::Location<'static>,
}
impl<T> UntrackedSettableSignal<T> for WriteSignal<T>
where
T: 'static,
{
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "WriteSignal::set_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn set_untracked(&self, new_value: T) {
self.id
.update_with_no_effect(self.runtime, |v| *v = new_value);
}
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "WriteSignal::updated_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn update_untracked(&self, f: impl FnOnce(&mut T)) {
self.id.update_with_no_effect(self.runtime, f);
}
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "WriteSignal::update_returning_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn update_returning_untracked<U>(&self, f: impl FnOnce(&mut T) -> U) -> Option<U> {
self.id.update_with_no_effect(self.runtime, f)
}
@@ -342,6 +461,19 @@ where
/// assert_eq!(count(), 1);
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
name = "WriteSignal::update()",
level = "trace",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn update(&self, f: impl FnOnce(&mut T)) {
self.id.update(self.runtime, f);
}
@@ -367,6 +499,19 @@ where
/// assert_eq!(count(), 2);
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "WriteSignal::update_returning()"
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn update_returning<U>(&self, f: impl FnOnce(&mut T) -> U) -> Option<U> {
self.id.update(self.runtime, f)
}
@@ -390,6 +535,19 @@ where
/// assert_eq!(count(), 1);
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "WriteSignal::set()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn set(&self, new_value: T) {
self.id.update(self.runtime, |n| *n = new_value);
}
@@ -401,6 +559,8 @@ impl<T> Clone for WriteSignal<T> {
runtime: self.runtime,
id: self.id,
ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: self.defined_at,
}
}
}
@@ -460,6 +620,16 @@ where
/// # }).dispose();
/// #
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
ty = %std::any::type_name::<T>()
)
)
)]
pub fn create_rw_signal<T>(cx: Scope, value: T) -> RwSignal<T> {
let s = cx.runtime.create_rw_signal(value);
cx.with_scope_property(|prop| prop.push(ScopeProperty::Signal(s.id)));
@@ -495,6 +665,8 @@ where
pub(crate) runtime: RuntimeId,
pub(crate) id: SignalId,
pub(crate) ty: PhantomData<T>,
#[cfg(debug_assertions)]
pub(crate) defined_at: &'static std::panic::Location<'static>,
}
impl<T> Clone for RwSignal<T> {
@@ -503,6 +675,8 @@ impl<T> Clone for RwSignal<T> {
runtime: self.runtime,
id: self.id,
ty: self.ty,
#[cfg(debug_assertions)]
defined_at: self.defined_at,
}
}
}
@@ -510,6 +684,19 @@ impl<T> Clone for RwSignal<T> {
impl<T> Copy for RwSignal<T> {}
impl<T> UntrackedGettableSignal<T> for RwSignal<T> {
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::get_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn get_untracked(&self) -> T
where
T: Clone,
@@ -518,21 +705,73 @@ impl<T> UntrackedGettableSignal<T> for RwSignal<T> {
.with_no_subscription(self.runtime, |v: &T| v.clone())
}
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::with_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn with_untracked<O>(&self, f: impl FnOnce(&T) -> O) -> O {
self.id.with_no_subscription(self.runtime, f)
}
}
impl<T> UntrackedSettableSignal<T> for RwSignal<T> {
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::set_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn set_untracked(&self, new_value: T) {
self.id
.update_with_no_effect(self.runtime, |v| *v = new_value);
}
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::update_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn update_untracked(&self, f: impl FnOnce(&mut T)) {
self.id.update_with_no_effect(self.runtime, f);
}
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::update_returning_untracked()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
fn update_returning_untracked<U>(&self, f: impl FnOnce(&mut T) -> U) -> Option<U> {
self.id.update_with_no_effect(self.runtime, f)
}
@@ -561,6 +800,19 @@ where
/// # }).dispose();
/// #
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::with()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn with<U>(&self, f: impl FnOnce(&T) -> U) -> U {
self.id.with(self.runtime, f)
}
@@ -578,7 +830,20 @@ where
/// assert_eq!(count(), 0);
/// # }).dispose();
/// #
/// ```
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::get()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn get(&self) -> T
where
T: Clone,
@@ -603,6 +868,19 @@ where
/// assert_eq!(count(), 1);
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::update()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn update(&self, f: impl FnOnce(&mut T)) {
self.id.update(self.runtime, f);
}
@@ -626,6 +904,19 @@ where
/// assert_eq!(count(), 2);
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::update_returning()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn update_returning<U>(&self, f: impl FnOnce(&mut T) -> U) -> Option<U> {
self.id.update(self.runtime, f)
}
@@ -644,6 +935,19 @@ where
/// assert_eq!(count(), 1);
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::set()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn set(&self, value: T) {
self.id.update(self.runtime, |n| *n = value);
}
@@ -664,11 +968,27 @@ where
/// assert_eq!(read_count(), 1);
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::read_only()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
#[track_caller]
pub fn read_only(&self) -> ReadSignal<T> {
ReadSignal {
runtime: self.runtime,
id: self.id,
ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
@@ -686,11 +1006,27 @@ where
/// assert_eq!(count(), 1);
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::write_only()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
#[track_caller]
pub fn write_only(&self) -> WriteSignal<T> {
WriteSignal {
runtime: self.runtime,
id: self.id,
ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
@@ -707,22 +1043,53 @@ where
/// assert_eq!(get_count(), 1);
/// # }).dispose();
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::split()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
#[track_caller]
pub fn split(&self) -> (ReadSignal<T>, WriteSignal<T>) {
(
ReadSignal {
runtime: self.runtime,
id: self.id,
ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
},
WriteSignal {
runtime: self.runtime,
id: self.id,
ty: PhantomData,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
},
)
}
/// Generates a [Stream] that emits the new value of the signal whenever it changes.
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
name = "RwSignal::to_stream()",
skip_all,
fields(
id = %format!("{:?}", self.id),
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn to_stream(&self) -> impl Stream<Item = T>
where
T: Clone,

View File

@@ -27,13 +27,22 @@ use crate::{store_value, Memo, ReadSignal, RwSignal, Scope, StoredValue, Untrack
/// # });
/// ```
#[derive(Debug, PartialEq, Eq)]
pub struct Signal<T>(SignalTypes<T>)
pub struct Signal<T>
where
T: 'static;
T: 'static,
{
inner: SignalTypes<T>,
#[cfg(debug_assertions)]
defined_at: &'static std::panic::Location<'static>,
}
impl<T> Clone for Signal<T> {
fn clone(&self) -> Self {
Self(self.0)
Self {
inner: self.inner,
#[cfg(debug_assertions)]
defined_at: self.defined_at,
}
}
}
@@ -50,7 +59,7 @@ where
where
T: Clone,
{
match &self.0 {
match &self.inner {
SignalTypes::ReadSignal(s) => s.get_untracked(),
SignalTypes::Memo(m) => m.get_untracked(),
SignalTypes::DerivedSignal(cx, f) => cx.untrack(|| f.with(|f| f())),
@@ -58,7 +67,7 @@ where
}
fn with_untracked<O>(&self, f: impl FnOnce(&T) -> O) -> O {
match &self.0 {
match &self.inner {
SignalTypes::ReadSignal(s) => s.with_untracked(f),
SignalTypes::Memo(s) => s.with_untracked(f),
SignalTypes::DerivedSignal(cx, v_f) => {
@@ -93,11 +102,30 @@ where
/// assert_eq!(above_3(&double_count), true);
/// # });
/// ```
#[track_caller]
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
cx = %format!("{:?}", cx.id)
)
)
)]
pub fn derive(cx: Scope, derived_signal: impl Fn() -> T + 'static) -> Self {
Self(SignalTypes::DerivedSignal(
cx,
store_value(cx, Box::new(derived_signal)),
))
let span = ::tracing::Span::current();
let derived_signal = move || {
let _guard = span.enter();
derived_signal()
};
Self {
inner: SignalTypes::DerivedSignal(cx, store_value(cx, Box::new(derived_signal))),
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
/// Applies a function to the current value of the signal, and subscribes
@@ -129,8 +157,19 @@ where
/// assert_eq!(memoized_lower(), "alice");
/// });
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn with<U>(&self, f: impl FnOnce(&T) -> U) -> U {
match &self.0 {
match &self.inner {
SignalTypes::ReadSignal(s) => s.with(f),
SignalTypes::Memo(s) => s.with(f),
SignalTypes::DerivedSignal(_, s) => f(&s.with(|s| s())),
@@ -159,11 +198,22 @@ where
/// assert_eq!(above_3(&memoized_double_count.into()), true);
/// # });
/// ```
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn get(&self) -> T
where
T: Clone,
{
match &self.0 {
match &self.inner {
SignalTypes::ReadSignal(s) => s.get(),
SignalTypes::Memo(s) => s.get(),
SignalTypes::DerivedSignal(_, s) => s.with(|s| s()),
@@ -181,20 +231,35 @@ where
}
impl<T> From<ReadSignal<T>> for Signal<T> {
#[track_caller]
fn from(value: ReadSignal<T>) -> Self {
Self(SignalTypes::ReadSignal(value))
Self {
inner: SignalTypes::ReadSignal(value),
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
}
impl<T> From<RwSignal<T>> for Signal<T> {
#[track_caller]
fn from(value: RwSignal<T>) -> Self {
Self(SignalTypes::ReadSignal(value.read_only()))
Self {
inner: SignalTypes::ReadSignal(value.read_only()),
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
}
impl<T> From<Memo<T>> for Signal<T> {
#[track_caller]
fn from(value: Memo<T>) -> Self {
Self(SignalTypes::Memo(value))
Self {
inner: SignalTypes::Memo(value),
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
}

View File

@@ -28,19 +28,33 @@ use crate::{store_value, RwSignal, Scope, StoredValue, WriteSignal};
/// # });
/// ```
#[derive(Debug, PartialEq, Eq)]
pub struct SignalSetter<T>(SignalSetterTypes<T>)
pub struct SignalSetter<T>
where
T: 'static;
T: 'static,
{
inner: SignalSetterTypes<T>,
#[cfg(debug_assertions)]
defined_at: &'static std::panic::Location<'static>,
}
impl<T> Clone for SignalSetter<T> {
fn clone(&self) -> Self {
Self(self.0)
Self {
inner: self.inner,
#[cfg(debug_assertions)]
defined_at: self.defined_at,
}
}
}
impl<T: Default + 'static> Default for SignalSetter<T> {
#[track_caller]
fn default() -> Self {
Self(SignalSetterTypes::Default)
Self {
inner: SignalSetterTypes::Default,
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
}
@@ -71,11 +85,23 @@ where
/// assert_eq!(count(), 8);
/// # });
/// ```
#[track_caller]
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
cx = %format!("{:?}", cx.id),
)
)
)]
pub fn map(cx: Scope, mapped_setter: impl Fn(T) + 'static) -> Self {
Self(SignalSetterTypes::Mapped(
cx,
store_value(cx, Box::new(mapped_setter)),
))
Self {
inner: SignalSetterTypes::Mapped(cx, store_value(cx, Box::new(mapped_setter))),
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
/// Calls the setter function with the given value.
@@ -98,8 +124,19 @@ where
/// set_to_4(&set_double_count);
/// assert_eq!(count(), 8);
/// # });
#[cfg_attr(
debug_assertions,
instrument(
level = "trace",
skip_all,
fields(
defined_at = %format!("{:?}", self.defined_at),
ty = %std::any::type_name::<T>()
)
)
)]
pub fn set(&self, value: T) {
match &self.0 {
match &self.inner {
SignalSetterTypes::Write(s) => s.set(value),
SignalSetterTypes::Mapped(_, s) => s.with(|s| s(value)),
SignalSetterTypes::Default => {}
@@ -108,14 +145,24 @@ where
}
impl<T> From<WriteSignal<T>> for SignalSetter<T> {
#[track_caller]
fn from(value: WriteSignal<T>) -> Self {
Self(SignalSetterTypes::Write(value))
Self {
inner: SignalSetterTypes::Write(value),
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
}
impl<T> From<RwSignal<T>> for SignalSetter<T> {
#[track_caller]
fn from(value: RwSignal<T>) -> Self {
Self(SignalSetterTypes::Write(value.write_only()))
Self {
inner: SignalSetterTypes::Write(value.write_only()),
#[cfg(debug_assertions)]
defined_at: std::panic::Location::caller(),
}
}
}

View File

@@ -10,6 +10,7 @@ description = "Tools to set HTML metadata in the Leptos web framework."
[dependencies]
cfg-if = "1"
leptos = { path = "../leptos", version = "0.1.0-beta", default-features = false }
tracing = "0.1"
typed-builder = "0.11"
[dependencies.web-sys]
@@ -18,10 +19,11 @@ features = ["HtmlLinkElement", "HtmlMetaElement", "HtmlTitleElement"]
[features]
default = ["csr"]
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
ssr = ["leptos/ssr"]
stable = ["leptos/stable"]
csr = ["leptos/csr", "leptos/tracing"]
hydrate = ["leptos/hydrate", "leptos/tracing"]
ssr = ["leptos/ssr", "leptos/tracing"]
stable = ["leptos/stable", "leptos/tracing"]
[package.metadata.cargo-all-features]
denylist = ["stable"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]

View File

@@ -36,14 +36,26 @@
//!
//! ```
use std::{fmt::Debug, rc::Rc};
use cfg_if::cfg_if;
use std::{
cell::{Cell, RefCell},
collections::HashMap,
fmt::Debug,
rc::Rc,
};
use leptos::{leptos_dom::debug_warn, *};
mod link;
mod meta_tags;
mod script;
mod style;
mod stylesheet;
mod title;
pub use link::*;
pub use meta_tags::*;
pub use script::*;
pub use style::*;
pub use stylesheet::*;
pub use title::*;
@@ -51,11 +63,87 @@ pub use title::*;
///
/// This should generally by provided somewhere in the root of your application using
/// [provide_meta_context].
#[derive(Debug, Clone, Default)]
#[derive(Clone, Default)]
pub struct MetaContext {
pub(crate) title: TitleContext,
pub(crate) stylesheets: StylesheetContext,
pub(crate) meta_tags: MetaTagsContext,
pub(crate) tags: MetaTagsContext,
}
/// Manages all of the element created by components.
#[derive(Clone, Default)]
pub(crate) struct MetaTagsContext {
next_id: Rc<Cell<MetaTagId>>,
#[allow(clippy::type_complexity)]
els: Rc<RefCell<HashMap<String, (HtmlElement<AnyElement>, Scope, Option<web_sys::Element>)>>>,
}
impl MetaTagsContext {
#[cfg(feature = "ssr")]
pub fn as_string(&self) -> String {
println!(
"\n\nrendering {} elements to strings\n\n",
self.els.borrow().len()
);
self.els
.borrow()
.iter()
.map(|(_, (builder_el, cx, _))| builder_el.clone().into_view(*cx).render_to_string(*cx))
.collect()
}
pub fn register(&self, cx: Scope, id: String, builder_el: HtmlElement<AnyElement>) {
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
use leptos::document;
let element_to_hydrate = document()
.get_element_by_id(&id);
let el = element_to_hydrate.unwrap_or_else({
let builder_el = builder_el.clone();
move || {
let head = document().head().unwrap_throw();
head
.append_child(&builder_el)
.unwrap_throw();
(*builder_el).clone().unchecked_into()
}
});
on_cleanup(cx, {
let el = el.clone();
let els = self.els.clone();
let id = id.clone();
move || {
let head = document().head().unwrap_throw();
_ = head.remove_child(&el);
els.borrow_mut().remove(&id);
}
});
self
.els
.borrow_mut()
.insert(id, (builder_el.into_any(), cx, Some(el)));
} else {
self.els.borrow_mut().insert(id, (builder_el, cx, None));
}
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
struct MetaTagId(usize);
impl MetaTagsContext {
fn get_next_id(&self) -> MetaTagId {
let current_id = self.next_id.get();
let next_id = MetaTagId(current_id.0 + 1);
self.next_id.set(next_id);
next_id
}
}
/// Provides a [MetaContext], if there is not already one provided. This ensures that you can provide it
@@ -98,7 +186,7 @@ impl MetaContext {
Default::default()
}
#[cfg(not(any(feature = "csr", feature = "hydrate")))]
#[cfg(feature = "ssr")]
/// Converts the existing metadata tags into HTML that can be injected into the document head.
///
/// This should be called *after* the apps component tree has been rendered into HTML, so that
@@ -123,14 +211,15 @@ impl MetaContext {
/// // `app` contains only the body content w/ hydration stuff, not the meta tags
/// assert_eq!(
/// app.into_view(cx).render_to_string(cx),
/// "<main id=\"_0-1\"><leptos-unit leptos id=_0-2c></leptos-unit><leptos-unit leptos id=_0-3c></leptos-unit><p id=\"_0-4\">Some text</p></main>"
/// "<main id=\"_0-1\"><leptos-unit leptos id=_0-2c></leptos-unit><leptos-unit leptos id=_0-4c></leptos-unit><p id=\"_0-5\">Some text</p></main>"
/// );
/// // `MetaContext::dehydrate()` gives you HTML that should be in the `<head>`
/// assert_eq!(use_head(cx).dehydrate(), r#"<title>my title</title><link rel="stylesheet" href="/style.css">"#)
/// assert_eq!(use_head(cx).dehydrate(), r#"<title>my title</title><link id="leptos-link-1" href="/style.css" rel="stylesheet" leptos-hk="_0-3"/>"#)
/// });
/// # }
/// ```
pub fn dehydrate(&self) -> String {
let prev_key = HydrationCtx::peek();
let mut tags = String::new();
// Title
@@ -139,12 +228,9 @@ impl MetaContext {
tags.push_str(&title);
tags.push_str("</title>");
}
// Stylesheets
tags.push_str(&self.stylesheets.as_string());
// Meta tags
tags.push_str(&self.meta_tags.as_string());
tags.push_str(&self.tags.as_string());
HydrationCtx::continue_from(prev_key);
tags
}
}

109
meta/src/link.rs Normal file
View File

@@ -0,0 +1,109 @@
use crate::use_head;
use leptos::*;
/// Injects an [HTMLLinkElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement) into the document
/// head, accepting any of the valid attributes for that tag.
/// ```
/// use leptos::*;
/// use leptos_meta::*;
///
/// #[component]
/// fn MyApp(cx: Scope) -> impl IntoView {
/// provide_meta_context(cx);
///
/// view! { cx,
/// <main>
/// <Link rel="preload"
/// href="myFont.woff2"
/// as_="font"
/// type_="font/woff2"
/// crossorigin="anonymous"
/// />
/// </main>
/// }
/// }
/// ```
#[component(transparent)]
pub fn Link(
cx: Scope,
/// The [`id`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-id) attribute.
#[prop(optional, into)]
id: Option<String>,
/// The [`as`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-as) attribute.
#[prop(optional, into)]
as_: Option<String>,
/// The [`crossorigin`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-crossorigin) attribute.
#[prop(optional, into)]
crossorigin: Option<String>,
/// The [`disabled`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-disabled) attribute.
#[prop(optional, into)]
disabled: Option<bool>,
/// The [`fetchpriority`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-fetchpriority) attribute.
#[prop(optional, into)]
fetchpriority: Option<String>,
/// The [`href`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-href) attribute.
#[prop(optional, into)]
href: Option<String>,
/// The [`hreflang`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-hreflang) attribute.
#[prop(optional, into)]
hreflang: Option<String>,
/// The [`imagesizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-imagesizes) attribute.
#[prop(optional, into)]
imagesizes: Option<String>,
/// The [`imagesrcset`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-imagesrcset) attribute.
#[prop(optional, into)]
imagesrcset: Option<String>,
/// The [`integrity`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-integrity) attribute.
#[prop(optional, into)]
integrity: Option<String>,
/// The [`media`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-media) attribute.
#[prop(optional, into)]
media: Option<String>,
/// The [`prefetch`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-prefetch) attribute.
#[prop(optional, into)]
prefetch: Option<String>,
/// The [`referrerpolicy`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-referrerpolicy) attribute.
#[prop(optional, into)]
referrerpolicy: Option<String>,
/// The [`rel`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-rel) attribute.
#[prop(optional, into)]
rel: Option<String>,
/// The [`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-sizes) attribute.
#[prop(optional, into)]
sizes: Option<String>,
/// The [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-title) attribute.
#[prop(optional, into)]
title: Option<String>,
/// The [`type`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-type) attribute.
#[prop(optional, into)]
type_: Option<String>,
/// The [`blocking`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link#attr-blocking) attribute.
#[prop(optional, into)]
blocking: Option<String>,
) -> impl IntoView {
let meta = use_head(cx);
let next_id = meta.tags.get_next_id();
let id = id.unwrap_or_else(|| format!("leptos-link-{}", next_id.0));
let builder_el = leptos::link(cx)
.attr("id", &id)
.attr("as_", as_)
.attr("crossorigin", crossorigin)
.attr("disabled", disabled.unwrap_or(false))
.attr("fetchpriority", fetchpriority)
.attr("href", href)
.attr("hreflang", hreflang)
.attr("imagesizes", imagesizes)
.attr("imagesrcset", imagesrcset)
.attr("integrity", integrity)
.attr("media", media)
.attr("prefetch", prefetch)
.attr("referrerpolicy", referrerpolicy)
.attr("rel", rel)
.attr("sizes", sizes)
.attr("title", title)
.attr("type", type_)
.attr("blocking", blocking);
meta.tags.register(cx, id, builder_el.into_any());
}

View File

@@ -1,73 +1,7 @@
use cfg_if::cfg_if;
use leptos::{component, IntoView, Scope};
use std::{
cell::{Cell, RefCell},
collections::HashMap,
rc::Rc,
};
use crate::{use_head, TextProp};
/// Manages all of the `<meta>` elements set by [Meta] components.
#[derive(Clone, Default, Debug)]
pub struct MetaTagsContext {
next_id: Cell<MetaTagId>,
#[allow(clippy::type_complexity)]
els: Rc<RefCell<HashMap<MetaTagId, (Option<MetaTag>, Option<web_sys::HtmlMetaElement>)>>>,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
struct MetaTagId(usize);
impl MetaTagsContext {
fn get_next_id(&self) -> MetaTagId {
let current_id = self.next_id.get();
let next_id = MetaTagId(current_id.0 + 1);
self.next_id.set(next_id);
next_id
}
}
#[derive(Clone, Debug)]
enum MetaTag {
Charset(TextProp),
HttpEquiv {
http_equiv: TextProp,
content: Option<TextProp>,
},
Name {
name: TextProp,
content: TextProp,
},
}
impl MetaTagsContext {
/// Converts the set of `<meta>` elements into an HTML string that can be injected into the `<head>`.
pub fn as_string(&self) -> String {
self.els
.borrow()
.iter()
.filter_map(|(id, (tag, _))| {
tag.as_ref().map(|tag| {
let id = id.0;
match tag {
MetaTag::Charset(charset) => format!(r#"<meta charset="{}" data-leptos-meta="{id}">"#, charset.get()),
MetaTag::HttpEquiv { http_equiv, content } => {
if let Some(content) = &content {
format!(r#"<meta http-equiv="{}" content="{}" data-leptos-meta="{id}">"#, http_equiv.get(), content.get())
} else {
format!(r#"<meta http-equiv="{}" data-leptos-meta="{id}">"#, http_equiv.get())
}
},
MetaTag::Name { name, content } => format!(r#"<meta name="{}" content="{}" data-leptos-meta="{id}">"#, name.get(), content.get()),
}
})
})
.collect()
}
}
/// Injects an [HTMLMetaElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMetaElement) into the document
/// head to set metadata
///
@@ -104,89 +38,15 @@ pub fn Meta(
#[prop(optional, into)]
content: Option<TextProp>,
) -> impl IntoView {
let tag = match (charset, name, http_equiv, content) {
(Some(charset), _, _, _) => MetaTag::Charset(charset),
(_, _, Some(http_equiv), content) => MetaTag::HttpEquiv { http_equiv, content },
(_, Some(name), _, Some(content)) => MetaTag::Name { name, content },
_ => panic!("<Meta/> tag expects either `charset`, `http_equiv`, or `name` and `content` to be set.")
};
let meta = use_head(cx);
let next_id = meta.tags.get_next_id();
let id = format!("leptos-link-{}", next_id.0);
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
use leptos::{document, JsCast, UnwrapThrowExt, create_effect};
let builder_el = leptos::meta(cx)
.attr("charset", move || charset.as_ref().map(|v| v.get()))
.attr("name", move || name.as_ref().map(|v| v.get()))
.attr("http-equiv", move || http_equiv.as_ref().map(|v| v.get()))
.attr("content", move || content.as_ref().map(|v| v.get()));
let meta = use_head(cx);
let meta_tags = meta.meta_tags;
let id = meta_tags.get_next_id();
let el = if let Ok(Some(el)) = document().query_selector(&format!("[data-leptos-meta='{}']", id.0)) {
el
} else {
document().create_element("meta").unwrap_throw()
};
match tag {
MetaTag::Charset(charset) => {
create_effect(cx, {
let el = el.clone();
move |_| {
_ = el.set_attribute("charset", &charset.get());
}
})
},
MetaTag::HttpEquiv { http_equiv, content } => {
create_effect(cx, {
let el = el.clone();
move |_| {
_ = el.set_attribute("http-equiv", &http_equiv.get());
}
});
if let Some(content) = content {
create_effect(cx, {
let el = el.clone();
move |_| {
_ = el.set_attribute("content", &content.get());
}
});
}
},
MetaTag::Name { name, content } => {
create_effect(cx, {
let el = el.clone();
move |_| {
_ = el.set_attribute("name", &name.get());
}
});
create_effect(cx, {
let el = el.clone();
move |_| {
_ = el.set_attribute("content", &content.get());
}
});
},
}
// add to head
let head = document()
.query_selector("head")
.unwrap_throw()
.unwrap_throw();
head.append_child(&el)
.unwrap_throw();
leptos::on_cleanup(cx, {
let el = el.clone();
move || {
head.remove_child(&el);
}
});
// add to meta tags
meta_tags.els.borrow_mut().insert(id, (None, Some(el.unchecked_into())));
} else {
let meta = use_head(cx);
let meta_tags = meta.meta_tags;
meta_tags.els.borrow_mut().insert(meta_tags.get_next_id(), (Some(tag), None));
}
}
meta.tags.register(cx, id, builder_el.into_any());
}

98
meta/src/script.rs Normal file
View File

@@ -0,0 +1,98 @@
use crate::use_head;
use leptos::*;
/// Injects an [HTMLScriptElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement) into the document
/// head, accepting any of the valid attributes for that tag.
/// ```
/// use leptos::*;
/// use leptos_meta::*;
///
/// #[component]
/// fn MyApp(cx: Scope) -> impl IntoView {
/// provide_meta_context(cx);
///
/// view! { cx,
/// <main>
/// <Script>
/// "console.log('Hello, world!');"
/// </Script>
/// </main>
/// }
/// }
/// ```
#[component(transparent)]
pub fn Script(
cx: Scope,
/// An ID for the `<script>` tag.
#[prop(optional, into)]
id: Option<String>,
/// The [`async`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-async) attribute.
#[prop(optional, into)]
async_: Option<String>,
/// The [`crossorigin`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-crossorigin) attribute.
#[prop(optional, into)]
crossorigin: Option<String>,
/// The [`defer`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-defer) attribute.
#[prop(optional, into)]
defer: Option<String>,
/// The [`fetchpriority `](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-fetchpriority ) attribute.
#[prop(optional, into)]
fetchpriority: Option<String>,
/// The [`integrity`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-integrity) attribute.
#[prop(optional, into)]
integrity: Option<String>,
/// The [`nomodule`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-nomodule) attribute.
#[prop(optional, into)]
nomodule: Option<String>,
/// The [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-nonce) attribute.
#[prop(optional, into)]
nonce: Option<String>,
/// The [`referrerpolicy`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-referrerpolicy) attribute.
#[prop(optional, into)]
referrerpolicy: Option<String>,
/// The [`src`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-src) attribute.
#[prop(optional, into)]
src: Option<String>,
/// The [`type`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-type) attribute.
#[prop(optional, into)]
type_: Option<String>,
/// The [`blocking`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attr-blocking) attribute.
#[prop(optional, into)]
blocking: Option<String>,
/// The content of the `<script>` tag.
#[prop(optional)]
children: Option<Box<dyn FnOnce(Scope) -> Fragment>>,
) -> impl IntoView {
let meta = use_head(cx);
let next_id = meta.tags.get_next_id();
let id = id.unwrap_or_else(|| format!("leptos-link-{}", next_id.0));
let builder_el = leptos::script(cx)
.attr("id", &id)
.attr("async", async_)
.attr("crossorigin", crossorigin)
.attr("defer", defer)
.attr("fetchpriority ", fetchpriority)
.attr("integrity", integrity)
.attr("nomodule", nomodule)
.attr("nonce", nonce)
.attr("referrerpolicy", referrerpolicy)
.attr("src", src)
.attr("type", type_)
.attr("blocking", blocking);
let builder_el = if let Some(children) = children {
let frag = children(cx);
let mut script = String::new();
for node in frag.nodes {
match node {
View::Text(text) => script.push_str(&text.content),
_ => leptos::warn!("Only text nodes are supported as children of <Script/>."),
}
}
builder_el.child(script)
} else {
builder_el
};
meta.tags.register(cx, id, builder_el.into_any());
}

70
meta/src/style.rs Normal file
View File

@@ -0,0 +1,70 @@
use crate::use_head;
use leptos::*;
/// Injects an [HTMLStyleElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLStyleElement) into the document
/// head, accepting any of the valid attributes for that tag.
/// ```
/// use leptos::*;
/// use leptos_meta::*;
///
/// #[component]
/// fn MyApp(cx: Scope) -> impl IntoView {
/// provide_meta_context(cx);
///
/// view! { cx,
/// <main>
/// <Style>
/// "body { font-weight: bold; }"
/// </Style>
/// </main>
/// }
/// }
/// ```
#[component(transparent)]
pub fn Style(
cx: Scope,
/// An ID for the `<script>` tag.
#[prop(optional, into)]
id: Option<String>,
/// The [`media`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style#attr-media) attribute.
#[prop(optional, into)]
media: Option<String>,
/// The [`nonce`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style#attr-nonce) attribute.
#[prop(optional, into)]
nonce: Option<String>,
/// The [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style#attr-title) attribute.
#[prop(optional, into)]
title: Option<String>,
/// The [`blocking`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style#attr-blocking) attribute.
#[prop(optional, into)]
blocking: Option<String>,
/// The content of the `<style>` tag.
#[prop(optional)]
children: Option<Box<dyn FnOnce(Scope) -> Fragment>>,
) -> impl IntoView {
let meta = use_head(cx);
let next_id = meta.tags.get_next_id();
let id = id.unwrap_or_else(|| format!("leptos-link-{}", next_id.0));
let builder_el = leptos::style(cx)
.attr("id", &id)
.attr("media", media)
.attr("nonce", nonce)
.attr("title", title)
.attr("blocking", blocking);
let builder_el = if let Some(children) = children {
let frag = children(cx);
let mut style = String::new();
for node in frag.nodes {
match node {
View::Text(text) => style.push_str(&text.content),
_ => leptos::warn!("Only text nodes are supported as children of <Style/>."),
}
}
builder_el.child(style)
} else {
builder_el
};
meta.tags.register(cx, id, builder_el.into_any());
}

View File

@@ -1,51 +1,5 @@
use crate::use_head;
use cfg_if::cfg_if;
use crate::{Link, LinkProps};
use leptos::*;
use std::{
cell::{Cell, RefCell},
collections::HashMap,
rc::Rc,
};
/// Manages all of the stylesheets set by [Stylesheet] components.
#[derive(Clone, Default, Debug)]
pub struct StylesheetContext {
#[allow(clippy::type_complexity)]
// key is (id, href)
els: Rc<RefCell<HashMap<StyleSheetData, Option<web_sys::HtmlLinkElement>>>>,
next_id: Rc<Cell<StylesheetId>>,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
struct StylesheetId(usize);
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct StyleSheetData {
id: String,
href: String,
}
impl StylesheetContext {
fn get_next_id(&self) -> StylesheetId {
let current_id = self.next_id.get();
let next_id = StylesheetId(current_id.0 + 1);
self.next_id.set(next_id);
next_id
}
}
impl StylesheetContext {
/// Converts the set of stylesheets into an HTML string that can be injected into the `<head>`.
pub fn as_string(&self) -> String {
self.els
.borrow()
.iter()
.map(|(StyleSheetData { id, href }, _)| {
format!(r#"<link rel="stylesheet" id="{id}" href="{href}">"#)
})
.collect()
}
}
/// Injects an [HTMLLinkElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLLinkElement) into the document
/// head that loads a stylesheet from the URL given by the `href` property.
@@ -75,50 +29,13 @@ pub fn Stylesheet(
#[prop(optional, into)]
id: Option<String>,
) -> impl IntoView {
let meta = use_head(cx);
let stylesheets = &meta.stylesheets;
let next_id = stylesheets.get_next_id();
let id = id.unwrap_or_else(|| format!("leptos-style-{}", next_id.0));
let key = StyleSheetData { id, href };
cfg_if! {
if #[cfg(any(feature = "csr", feature = "hydrate"))] {
use leptos::document;
let element_to_hydrate = document().get_element_by_id(&key.id);
let el = element_to_hydrate.unwrap_or_else(|| {
let el = document().create_element("link").unwrap_throw();
el.set_attribute("rel", "stylesheet").unwrap_throw();
el.set_attribute("id", &key.id).unwrap_throw();
el.set_attribute("href", &key.href).unwrap_throw();
let head = document().head().unwrap_throw();
head
.append_child(el.unchecked_ref())
.unwrap_throw();
el
});
on_cleanup(cx, {
let el = el.clone();
let els = meta.stylesheets.els.clone();
let key = key.clone();
move || {
let head = document().head().unwrap_throw();
_ = head.remove_child(&el);
els.borrow_mut().remove(&key);
}
});
meta.stylesheets
.els
.borrow_mut()
.insert(key, Some(el.unchecked_into()));
} else {
let meta = use_head(cx);
meta.stylesheets.els.borrow_mut().insert(key, None);
if let Some(id) = id {
view! { cx,
<Link id rel="stylesheet" href/>
}
} else {
view! { cx,
<Link rel="stylesheet" href/>
}
}
}

View File

@@ -62,3 +62,4 @@ stable = ["leptos/stable"]
[package.metadata.cargo-all-features]
# No need to test optional dependencies as they are enabled by the ssr feature
denylist = ["url", "regex", "stable"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]