Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Context API #169

Merged
merged 8 commits into from
Jul 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"packages/sycamore-router",
"packages/sycamore-router-macro",
"examples/components",
"examples/context",
"examples/counter",
"examples/hello",
"examples/iteration",
Expand Down
54 changes: 54 additions & 0 deletions docs/next/advanced/advanced_reactivity.md
Original file line number Diff line number Diff line change
@@ -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<i32>);

#[component(CounterView)]
fn counter_view() -> Template<G> {
let counter = use_context::<Counter>();

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!
14 changes: 14 additions & 0 deletions examples/context/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"}
15 changes: 15 additions & 0 deletions examples/context/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Counter</title>

<style>
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
</style>
</head>
<body></body>
</html>
63 changes: 63 additions & 0 deletions examples/context/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use sycamore::prelude::*;
use sycamore::rx::{use_context, ContextProvider, ContextProviderProps};

#[component(Counter<G>)]
fn counter() -> Template<G> {
let counter = use_context::<Signal<i32>>();

template! {
p(class="value") {
"Value: "
(counter.get())
}
}
}

#[component(Controls<G>)]
pub fn controls() -> Template<G> {
let counter = use_context::<Signal<i32>>();

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<G>)]
fn app() -> Template<G> {
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() });
}
12 changes: 7 additions & 5 deletions packages/sycamore/src/rx.rs
Original file line number Diff line number Diff line change
@@ -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::*;
Expand Down Expand Up @@ -40,13 +42,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<dyn FnOnce() + 'a>) -> 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()
})
}

Expand Down
105 changes: 105 additions & 0 deletions packages/sycamore/src/rx/context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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<T: 'static> {
value: T,
}

impl<T: 'static> ContextAny for Context<T> {
fn get_type_id(&self) -> TypeId {
self.value.type_id()
}

fn get_value(&self) -> &dyn Any {
&self.value
}
}

/// Props for [`ContextProvider`].
pub struct ContextProviderProps<T, F, G>
where
T: 'static,
F: FnOnce() -> Template<G>,
G: GenericNode,
{
pub value: T,
pub children: F,
}

/// Creates a new [`ReactiveScope`] with a context.
#[component(ContextProvider<G>)]
pub fn context_provider<T, F>(props: ContextProviderProps<T, F, G>) -> Template<G>
where
T: 'static,
F: FnOnce() -> Template<G>,
{
let ContextProviderProps { value, children } = props;

SCOPES.with(|scopes| {
// 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();
let scope = scopes.borrow_mut().pop().unwrap();
on_cleanup(move || drop(scope));
template
})
}

/// 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<T: Clone + 'static>() -> 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.context {
if let Some(value) = context.get_value().downcast_ref::<T>() {
return value.clone();
}
}
}

panic!("context not found for type");
})
}

#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;

#[test]
fn basic_context() {
sycamore::render_to_string(|| {
template! {
ContextProvider(ContextProviderProps {
value: 1i32,
children: || {
let ctx = use_context::<i32>();
assert_eq!(ctx, 1);
template! {}
}
})
}
});
}
}
40 changes: 29 additions & 11 deletions packages/sycamore/src/rx/effect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<Weak<RefCell<Option<Running>>>>> = RefCell::new(Vec::with_capacity(CONTEXTS_INITIAL_CAPACITY));
pub(super) static SCOPE: RefCell<Option<ReactiveScope>> = 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<Vec<Weak<RefCell<Option<Running>>>>> =
RefCell::new(Vec::with_capacity(CONTEXTS_INITIAL_CAPACITY));
/// Explicit stack of [`ReactiveScope`]s.
pub(super) static SCOPES: RefCell<Vec<ReactiveScope>> =
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<RefCell<dyn FnMut()>>,
Expand All @@ -54,12 +64,18 @@ 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.
effects: SmallVec<[Rc<RefCell<Option<Running>>>; REACTIVE_SCOPE_EFFECTS_STACK_CAPACITY]>,
/// Callbacks to call when the scope is dropped.
cleanup: Vec<Box<dyn FnOnce()>>,
/// Contexts created in this scope.
pub(super) context: Option<Box<dyn ContextAny>>,
}

impl ReactiveScope {
Expand Down Expand Up @@ -242,11 +258,11 @@ pub fn create_effect_initial<R: 'static>(
"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()
.as_mut()
.last_mut()
.unwrap()
.add_effect_state(running);
} else {
Expand Down Expand Up @@ -366,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()`].
///
Expand Down Expand Up @@ -428,11 +446,11 @@ pub fn untrack<T>(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()
.as_mut()
.last_mut()
.unwrap()
.add_cleanup(Box::new(f));
} else {
Expand Down