From 9dc8e1a0f9256242dbf92a52b5a4d981c93ecd00 Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sun, 11 Jul 2021 14:25:14 -0700 Subject: [PATCH 1/8] Make SCOPE an explicit stack rather than relying on callstack --- packages/sycamore/src/rx.rs | 10 +++++----- packages/sycamore/src/rx/effect.rs | 32 ++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/sycamore/src/rx.rs b/packages/sycamore/src/rx.rs index d0d7e6286..a54c6ac6f 100644 --- a/packages/sycamore/src/rx.rs +++ b/packages/sycamore/src/rx.rs @@ -40,13 +40,13 @@ pub use signal::*; pub fn create_root<'a>(callback: impl FnOnce() + 'a) -> ReactiveScope { /// Internal implementation: use dynamic dispatch to reduce code bloat. fn internal<'a>(callback: Box) -> ReactiveScope { - SCOPE.with(|scope| { - let outer_scope = scope.replace(Some(ReactiveScope::new())); + SCOPES.with(|scopes| { + // Push new empty scope on the stack. + scopes.borrow_mut().push(ReactiveScope::new()); callback(); - scope - .replace(outer_scope) - .expect("ReactiveScope should be valid inside the reactive root") + // Pop the scope from the stack and return it. + scopes.borrow_mut().pop().unwrap() }) } diff --git a/packages/sycamore/src/rx/effect.rs b/packages/sycamore/src/rx/effect.rs index 1b7c775fa..020e9b639 100644 --- a/packages/sycamore/src/rx/effect.rs +++ b/packages/sycamore/src/rx/effect.rs @@ -17,18 +17,28 @@ const REACTIVE_SCOPE_EFFECTS_STACK_CAPACITY: usize = 4; /// Initial capacity for [`CONTEXTS`]. const CONTEXTS_INITIAL_CAPACITY: usize = 10; +/// Initial capacity for [`SCOPES`]. +const SCOPES_INITIAL_CAPACITY: usize = 4; thread_local! { /// Context of the effect that is currently running. `None` if no effect is running. /// - /// This is an array of callbacks that, when called, will add the a `Signal` to the `handle` in the argument. - /// The callbacks return another callback which will unsubscribe the `handle` from the `Signal`. - pub(super) static CONTEXTS: RefCell>>>> = RefCell::new(Vec::with_capacity(CONTEXTS_INITIAL_CAPACITY)); - pub(super) static SCOPE: RefCell> = RefCell::new(None); + /// The [`Running`] contains an array of callbacks that, when called, will add the a `Signal` to + /// the `handle` in the argument. The callbacks return another callback which will unsubscribe the + /// `handle` from the `Signal`. + pub(super) static CONTEXTS: RefCell>>>> = + RefCell::new(Vec::with_capacity(CONTEXTS_INITIAL_CAPACITY)); + /// Explicit stack of [`ReactiveScope`]s. + pub(super) static SCOPES: RefCell> = + RefCell::new(Vec::with_capacity(SCOPES_INITIAL_CAPACITY)); } /// State of the current running effect. /// When the state is dropped, all dependencies are removed (both links and backlinks). +/// +/// The difference between [`Running`] and [`ReactiveScope`] is that [`Running`] is used for +/// dependency tracking whereas [`ReactiveScope`] is used for resource cleanup. Each [`Running`] +/// contains a [`ReactiveScope`]. pub(super) struct Running { /// Callback to run when the effect is recreated. pub(super) execute: Rc>, @@ -54,6 +64,10 @@ impl Running { /// Owns the effects created in the current reactive scope. /// The effects are dropped and the cleanup callbacks are called when the [`ReactiveScope`] is /// dropped. +/// +/// A new [`ReactiveScope`] is usually created with [`create_root`]. A new [`ReactiveScope`] is also +/// created when a new effect is created with [`create_effect`] and other reactive utilities that +/// call it under the hood. #[derive(Default)] pub struct ReactiveScope { /// Effects created in this scope. @@ -242,10 +256,11 @@ pub fn create_effect_initial( "Running should be owned exclusively by ReactiveScope" ); - SCOPE.with(|scope| { - if scope.borrow().is_some() { + SCOPES.with(|scope| { + if scope.borrow().last().is_some() { scope .borrow_mut() + .last_mut() .as_mut() .unwrap() .add_effect_state(running); @@ -428,10 +443,11 @@ pub fn untrack(f: impl FnOnce() -> T) -> T { /// assert_eq!(*cleanup_called.get(), true); /// ``` pub fn on_cleanup(f: impl FnOnce() + 'static) { - SCOPE.with(|scope| { - if scope.borrow().is_some() { + SCOPES.with(|scope| { + if scope.borrow().last().is_some() { scope .borrow_mut() + .last_mut() .as_mut() .unwrap() .add_cleanup(Box::new(f)); From 3ebb64e2c0cdc91ef2e718756ac6738deac3891d Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sun, 11 Jul 2021 15:15:46 -0700 Subject: [PATCH 2/8] Context API --- packages/sycamore/src/rx.rs | 2 + packages/sycamore/src/rx/context.rs | 85 +++++++++++++++++++++++++++++ packages/sycamore/src/rx/effect.rs | 12 ++-- 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 packages/sycamore/src/rx/context.rs diff --git a/packages/sycamore/src/rx.rs b/packages/sycamore/src/rx.rs index a54c6ac6f..b0994ec21 100644 --- a/packages/sycamore/src/rx.rs +++ b/packages/sycamore/src/rx.rs @@ -1,10 +1,12 @@ //! Reactive primitives for Sycamore. +mod context; mod effect; mod iter; mod motion; mod signal; +pub use context::*; pub use effect::*; pub use iter::*; pub use motion::*; diff --git a/packages/sycamore/src/rx/context.rs b/packages/sycamore/src/rx/context.rs new file mode 100644 index 000000000..f5c3affd3 --- /dev/null +++ b/packages/sycamore/src/rx/context.rs @@ -0,0 +1,85 @@ +use std::any::{Any, TypeId}; + +use crate::prelude::*; + +use super::*; + +/// Trait for any type of context. +/// +/// # Equality +/// A `ContextAny` is equal to another `ContextAny` if they are of the same type. +pub(super) trait ContextAny { + /// Get the [`TypeId`] of the type of the value stored in the context. + fn get_type_id(&self) -> TypeId; + + /// Get the value stored in the context. The concrete type of the returned value is guaranteed + /// to match the type when calling [`get_type_id`](ContextAny::get_type_id). + fn get_value(&self) -> &dyn Any; +} + +/// Inner representation of a context. +struct Context { + value: T, +} + +impl ContextAny for Context { + fn get_type_id(&self) -> TypeId { + self.value.type_id() + } + + fn get_value(&self) -> &dyn Any { + &self.value + } +} + +/// Props for [`ContextProvider`]. +pub struct ContextProviderProps +where + T: 'static, + F: FnOnce() -> Template, + G: GenericNode, +{ + value: T, + children: F, +} + +/// Add a new context to the current [`ReactiveScope`]. +#[component(ContextProvider)] +pub fn context_provider(props: ContextProviderProps) -> Template +where + T: 'static, + F: FnOnce() -> Template, +{ + let ContextProviderProps { value, children } = props; + + SCOPES.with(|scopes| { + // Add context to the current scope. + if let Some(scope) = scopes.borrow_mut().last_mut() { + scope + .contexts + .insert(value.type_id(), Box::new(Context { value })); + + children() + } else { + panic!("ContextProvider must be used inside ReactiveScope"); + } + }) +} + +/// Get the value of a context in the current [`ReactiveScope`]. +/// +/// # Panics +/// This function will `panic!` if the context is not found in the current scope or a parent scope. +pub fn use_context() { + SCOPES.with(|scopes| { + // Walk up the scope stack until we find a context that matches type or `panic!`. + for scope in scopes.borrow().iter().rev() { + if let Some(context) = scope.contexts.get(&TypeId::of::()) { + let value = context.get_value().downcast_ref::().unwrap(); + return value.clone(); + } + } + + panic!("context not found for type"); + }); +} diff --git a/packages/sycamore/src/rx/effect.rs b/packages/sycamore/src/rx/effect.rs index 020e9b639..3e15a12c8 100644 --- a/packages/sycamore/src/rx/effect.rs +++ b/packages/sycamore/src/rx/effect.rs @@ -1,6 +1,6 @@ -use std::any::Any; +use std::any::{Any, TypeId}; use std::cell::RefCell; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::mem; use std::ptr; @@ -74,6 +74,8 @@ pub struct ReactiveScope { effects: SmallVec<[Rc>>; REACTIVE_SCOPE_EFFECTS_STACK_CAPACITY]>, /// Callbacks to call when the scope is dropped. cleanup: Vec>, + /// Contexts created in this scope. + pub(super) contexts: HashMap>, } impl ReactiveScope { @@ -261,7 +263,6 @@ pub fn create_effect_initial( scope .borrow_mut() .last_mut() - .as_mut() .unwrap() .add_effect_state(running); } else { @@ -381,7 +382,9 @@ where }) } -/// Run the passed closure inside an untracked scope. +/// Run the passed closure inside an untracked dependency scope. +/// +/// This does **NOT** create a new [`ReactiveScope`]. /// /// See also [`StateHandle::get_untracked()`]. /// @@ -448,7 +451,6 @@ pub fn on_cleanup(f: impl FnOnce() + 'static) { scope .borrow_mut() .last_mut() - .as_mut() .unwrap() .add_cleanup(Box::new(f)); } else { From 5143834b5cd0fcedc38674b5e1e0fddbdeee6200 Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sun, 11 Jul 2021 15:28:04 -0700 Subject: [PATCH 3/8] Add context example --- Cargo.toml | 1 + examples/context/Cargo.toml | 14 +++++++ examples/context/index.html | 15 +++++++ examples/context/src/main.rs | 63 +++++++++++++++++++++++++++++ packages/sycamore/src/rx/context.rs | 12 +++--- 5 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 examples/context/Cargo.toml create mode 100644 examples/context/index.html create mode 100644 examples/context/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index daae68117..914a5edf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "packages/sycamore-router", "packages/sycamore-router-macro", "examples/components", + "examples/context", "examples/counter", "examples/hello", "examples/iteration", diff --git a/examples/context/Cargo.toml b/examples/context/Cargo.toml new file mode 100644 index 000000000..546c8c7a6 --- /dev/null +++ b/examples/context/Cargo.toml @@ -0,0 +1,14 @@ +[package] +authors = ["Luke Chu <37006668+lukechu10@users.noreply.github.com>"] +edition = "2018" +name = "context" +publish = false +version = "0.1.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +console_error_panic_hook = "0.1.6" +console_log = "0.2.0" +log = "0.4.14" +sycamore = {path = "../../packages/sycamore"} diff --git a/examples/context/index.html b/examples/context/index.html new file mode 100644 index 000000000..a90bfaee6 --- /dev/null +++ b/examples/context/index.html @@ -0,0 +1,15 @@ + + + + + + Counter + + + + + diff --git a/examples/context/src/main.rs b/examples/context/src/main.rs new file mode 100644 index 000000000..198c132a5 --- /dev/null +++ b/examples/context/src/main.rs @@ -0,0 +1,63 @@ +use sycamore::prelude::*; +use sycamore::rx::{use_context, ContextProvider, ContextProviderProps}; + +#[component(Counter)] +fn counter() -> Template { + let counter = use_context::>(); + + template! { + p(class="value") { + "Value: " + (counter.get()) + } + } +} + +#[component(Controls)] +pub fn controls() -> Template { + let counter = use_context::>(); + + let increment = cloned!((counter) => move |_| counter.set(*counter.get() + 1)); + + let reset = cloned!((counter) => move |_| counter.set(0)); + + template! { + button(class="increment", on:click=increment) { + "Increment" + } + button(class="reset", on:click=reset) { + "Reset" + } + } +} + +#[component(App)] +fn app() -> Template { + let counter = Signal::new(0); + + create_effect(cloned!((counter) => move || { + log::info!("Counter value: {}", *counter.get()); + })); + + template! { + ContextProvider(ContextProviderProps { + value: counter, + children: move || { + template! { + div { + "Counter demo" + Counter() + Controls() + } + } + } + }) + } +} + +fn main() { + console_error_panic_hook::set_once(); + console_log::init_with_level(log::Level::Debug).unwrap(); + + sycamore::render(|| template! { App() }); +} diff --git a/packages/sycamore/src/rx/context.rs b/packages/sycamore/src/rx/context.rs index f5c3affd3..5dac65859 100644 --- a/packages/sycamore/src/rx/context.rs +++ b/packages/sycamore/src/rx/context.rs @@ -39,8 +39,8 @@ where F: FnOnce() -> Template, G: GenericNode, { - value: T, - children: F, + pub value: T, + pub children: F, } /// Add a new context to the current [`ReactiveScope`]. @@ -58,11 +58,11 @@ where scope .contexts .insert(value.type_id(), Box::new(Context { value })); - - children() } else { panic!("ContextProvider must be used inside ReactiveScope"); } + + children() }) } @@ -70,7 +70,7 @@ where /// /// # Panics /// This function will `panic!` if the context is not found in the current scope or a parent scope. -pub fn use_context() { +pub fn use_context() -> T { SCOPES.with(|scopes| { // Walk up the scope stack until we find a context that matches type or `panic!`. for scope in scopes.borrow().iter().rev() { @@ -81,5 +81,5 @@ pub fn use_context() { } panic!("context not found for type"); - }); + }) } From ea177cfdd6b2a17626d6659f1c73f108e4c6e29a Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sun, 11 Jul 2021 15:48:25 -0700 Subject: [PATCH 4/8] Add docs for context --- docs/next/advanced/advanced_reactivity.md | 54 +++++++++++++++++++++++ packages/sycamore/src/rx/context.rs | 3 ++ 2 files changed, 57 insertions(+) diff --git a/docs/next/advanced/advanced_reactivity.md b/docs/next/advanced/advanced_reactivity.md index 8b447e6a0..184185484 100644 --- a/docs/next/advanced/advanced_reactivity.md +++ b/docs/next/advanced/advanced_reactivity.md @@ -1,5 +1,59 @@ # Advanced Reactivity +## Contexts + +Contexts provide an easy way to share data between components without drilling props through +multiple levels of the component hierarchy. + +Creating a `ContextProvider` is required before any components can use the context. The value used +should implement `Clone`. + +### Using `ContextProvider` + +`ContextProvider` is a component like any other. It takes a `value` prop which is the context value +and a `children` prop which is the child components that have access to the context value. + +### Using `use_context` + +`use_context` returns a clone of the value for a context of a given type. + +### Example + +```rust +use sycamore::prelude::*; +use sycamore::rx::{ContextProvider, ContextProviderProps, use_context}; + +struct Counter(Signal); + +#[component(CounterView)] +fn counter_view() -> Template { + let counter = use_context::(); + + template! { + (counter.0.get()) + } +} + +template! { + ContextProvider(ContextProviderProps { + value: Counter(Signal::new(0)), + children: || template! { + CounterView() + } + }) +} +``` + +Remember that unlike contexts in React and many other libraries, the `value` prop is not reactive by +itself. This is because components only run once. In order to make a context value reactive, you +need to use a `Signal` or other reactive data structure. + +## Reactive scopes + +### on_cleanup + +### Nested effects + TODO Help us out by writing the docs and sending us a PR! diff --git a/packages/sycamore/src/rx/context.rs b/packages/sycamore/src/rx/context.rs index 5dac65859..a0942b845 100644 --- a/packages/sycamore/src/rx/context.rs +++ b/packages/sycamore/src/rx/context.rs @@ -44,6 +44,9 @@ where } /// Add a new context to the current [`ReactiveScope`]. +/// +/// # Panics +/// This component will `panic!` if not inside a reactive scope. #[component(ContextProvider)] pub fn context_provider(props: ContextProviderProps) -> Template where From 0fc4c9db417c176d59fb98010ea6a5ec4be5489c Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sun, 11 Jul 2021 15:55:57 -0700 Subject: [PATCH 5/8] Add unit test --- packages/sycamore/src/rx/context.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/sycamore/src/rx/context.rs b/packages/sycamore/src/rx/context.rs index a0942b845..7bd7506e8 100644 --- a/packages/sycamore/src/rx/context.rs +++ b/packages/sycamore/src/rx/context.rs @@ -86,3 +86,24 @@ pub fn use_context() -> T { panic!("context not found for type"); }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_context() { + sycamore::render_to_string(|| { + template! { + ContextProvider(ContextProviderProps { + value: 1i32, + children: || { + let ctx = use_context::(); + assert_eq!(ctx, 1); + template! {} + } + }) + } + }); + } +} From 99bd720568e6e190f6d2cfcd234851bb38fcee4d Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sun, 11 Jul 2021 18:01:02 -0700 Subject: [PATCH 6/8] Fix test --- packages/sycamore/src/rx/context.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sycamore/src/rx/context.rs b/packages/sycamore/src/rx/context.rs index 7bd7506e8..26e387422 100644 --- a/packages/sycamore/src/rx/context.rs +++ b/packages/sycamore/src/rx/context.rs @@ -87,7 +87,7 @@ pub fn use_context() -> T { }) } -#[cfg(test)] +#[cfg(all(test, feature = "ssr"))] mod tests { use super::*; From e5a607411e2eba2c87df2ac7e42a10900099005b Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Mon, 12 Jul 2021 13:56:54 -0700 Subject: [PATCH 7/8] Make ReactiveScope more compact --- packages/sycamore/src/rx/context.rs | 29 ++++++++++++----------------- packages/sycamore/src/rx/effect.rs | 6 +++--- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/packages/sycamore/src/rx/context.rs b/packages/sycamore/src/rx/context.rs index 26e387422..9e8b4c4db 100644 --- a/packages/sycamore/src/rx/context.rs +++ b/packages/sycamore/src/rx/context.rs @@ -43,10 +43,7 @@ where pub children: F, } -/// Add a new context to the current [`ReactiveScope`]. -/// -/// # Panics -/// This component will `panic!` if not inside a reactive scope. +/// Creates a new [`ReactiveScope`] with a context. #[component(ContextProvider)] pub fn context_provider(props: ContextProviderProps) -> Template where @@ -56,16 +53,13 @@ where let ContextProviderProps { value, children } = props; SCOPES.with(|scopes| { - // Add context to the current scope. - if let Some(scope) = scopes.borrow_mut().last_mut() { - scope - .contexts - .insert(value.type_id(), Box::new(Context { value })); - } else { - panic!("ContextProvider must be used inside ReactiveScope"); - } - - children() + // Create a new ReactiveScope with a context. + let mut scope = ReactiveScope::default(); + scope.context = Some(Box::new(Context { value })); + scopes.borrow_mut().push(scope); + let template = children(); + scopes.borrow_mut().pop(); + template }) } @@ -77,9 +71,10 @@ pub fn use_context() -> T { SCOPES.with(|scopes| { // Walk up the scope stack until we find a context that matches type or `panic!`. for scope in scopes.borrow().iter().rev() { - if let Some(context) = scope.contexts.get(&TypeId::of::()) { - let value = context.get_value().downcast_ref::().unwrap(); - return value.clone(); + if let Some(context) = &scope.context { + if let Some(value) = context.get_value().downcast_ref::() { + return value.clone(); + } } } diff --git a/packages/sycamore/src/rx/effect.rs b/packages/sycamore/src/rx/effect.rs index 3e15a12c8..f376aa7b9 100644 --- a/packages/sycamore/src/rx/effect.rs +++ b/packages/sycamore/src/rx/effect.rs @@ -1,6 +1,6 @@ -use std::any::{Any, TypeId}; +use std::any::Any; use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::mem; use std::ptr; @@ -75,7 +75,7 @@ pub struct ReactiveScope { /// Callbacks to call when the scope is dropped. cleanup: Vec>, /// Contexts created in this scope. - pub(super) contexts: HashMap>, + pub(super) context: Option>, } impl ReactiveScope { From 2f741a0972e98cc14698aa97d24d375b262c155d Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Mon, 12 Jul 2021 14:39:05 -0700 Subject: [PATCH 8/8] Do not destroy cleanup right after children is evaluated --- packages/sycamore/src/rx/context.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sycamore/src/rx/context.rs b/packages/sycamore/src/rx/context.rs index 9e8b4c4db..168f7f60d 100644 --- a/packages/sycamore/src/rx/context.rs +++ b/packages/sycamore/src/rx/context.rs @@ -58,7 +58,8 @@ where scope.context = Some(Box::new(Context { value })); scopes.borrow_mut().push(scope); let template = children(); - scopes.borrow_mut().pop(); + let scope = scopes.borrow_mut().pop().unwrap(); + on_cleanup(move || drop(scope)); template }) }