diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1725cae..87cb7f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,5 @@ jobs: run: cargo clippy --no-deps --target wasm32-unknown-unknown -- -Dwarnings - name: Build | Compile run: cargo build --target wasm32-unknown-unknown + - name: Build | Compile macros + run: cargo build -p yewdux-middleware-macros diff --git a/.github/workflows/publish-dry-run.yml b/.github/workflows/publish-dry-run.yml index 15c284c..8db0eb2 100644 --- a/.github/workflows/publish-dry-run.yml +++ b/.github/workflows/publish-dry-run.yml @@ -21,5 +21,7 @@ jobs: run: rustup default ${{ env.rust_toolchain }} - name: Add wasm target run: rustup target add wasm32-unknown-unknown - - name: Build | Publish Dry Run + - name: Build | Publish Dry Run macros + run: cargo publish --dry-run -p yewdux-middleware-macros + - name: Build | Publish Dry Run main crate run: cargo publish --dry-run --target wasm32-unknown-unknown diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d014efa..a7b6d08 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,7 +24,9 @@ jobs: run: rustup target add wasm32-unknown-unknown - name: Login run: cargo login ${{ secrets.crates_io_token }} - - name: Build | Publish + - name: Build | Publish macros crate + run: cargo publish -p yewdux-middleware-macros + - name: Build | Publish main crate run: cargo publish --target wasm32-unknown-unknown - name: Get the crate version from cargo run: | diff --git a/COPILOT_REMINDER.md b/COPILOT_REMINDER.md new file mode 100644 index 0000000..d0ea18b --- /dev/null +++ b/COPILOT_REMINDER.md @@ -0,0 +1,48 @@ +# Copilot CI Reminder + +Before submitting any PR or pushing changes, always run the complete CI workflow locally to ensure the build passes: + +## CI Steps (from `.github/workflows/ci.yml`) + +1. **Format Check** + ```bash + cargo fmt -- --check + ``` + +2. **Add wasm target** (if not already added) + ```bash + rustup target add wasm32-unknown-unknown + ``` + +3. **Clippy (with warnings as errors)** + ```bash + cargo clippy --no-deps --target wasm32-unknown-unknown -- -Dwarnings + ``` + +4. **Build main crate** + ```bash + cargo build --target wasm32-unknown-unknown + ``` + +5. **Build macros crate** + ```bash + cargo build -p yewdux-middleware-macros + ``` + +## Quick Check Script + +Run all CI steps in sequence: +```bash +cargo fmt -- --check && \ +rustup target add wasm32-unknown-unknown && \ +cargo clippy --no-deps --target wasm32-unknown-unknown -- -Dwarnings && \ +cargo build --target wasm32-unknown-unknown && \ +cargo build -p yewdux-middleware-macros +``` + +## Important Notes + +- Always run these checks before committing +- Fix any warnings or errors before pushing +- The CI enforces `-Dwarnings` for clippy, so all warnings must be resolved +- Tests should also be run when making functional changes: `cargo test --lib` diff --git a/Cargo.toml b/Cargo.toml index 38dfdd4..c2b604d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "yewdux-middleware-macros"] + [package] name = "yewdux-middleware" version = "0.4.0" @@ -10,7 +13,24 @@ license = "MIT OR Apache-2.0" readme = "README.md" #forced-target = "wasm32-unknown-unknown" +[features] +default = ["future"] +future = [] +doctests = [] + [dependencies] -yewdux = { version = "0.11.0", default-features = false } yew = { version = "0.22.0", default-features = false } anymap2 = "0.13.0" + +# === yewdux embedded dependencies (can be removed if switching back to external yewdux) === +yewdux-middleware-macros = { path = "./yewdux-middleware-macros" } +log = "0.4.16" +serde = { version = "1.0.114", features = ["rc"] } +serde_json = "1.0.64" +slab = "0.4" +thiserror = "1.0" +web-sys = "0.3" +chrono = "0.4.22" +wasm-bindgen = "0.2" +# === end yewdux embedded dependencies === + diff --git a/src/lib.rs b/src/lib.rs index 3e8d1e1..dcb0cfa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ use std::rc::Rc; +pub mod yewdux; + pub use yewdux::prelude::{Reducer, Store}; pub use self::context::*; @@ -46,8 +48,8 @@ where mod context { use std::rc::Rc; + use crate::yewdux::{mrc::Mrc, Context}; use anymap2::AnyMap; - use yewdux::{mrc::Mrc, Context}; use crate::MiddlewareDispatch; @@ -87,8 +89,8 @@ mod context { pub fn store(&self, msg: M) where - M: yewdux::prelude::Reducer, - S: yewdux::prelude::Store, + M: crate::yewdux::prelude::Reducer, + S: crate::yewdux::prelude::Store, { self.context.reduce(move |state| msg.apply(state)); } diff --git a/src/yewdux/anymap.rs b/src/yewdux/anymap.rs new file mode 100644 index 0000000..d198021 --- /dev/null +++ b/src/yewdux/anymap.rs @@ -0,0 +1,37 @@ +use std::{ + any::{Any, TypeId}, + collections::HashMap, +}; + +#[derive(Default)] +pub(crate) struct AnyMap { + map: HashMap>, +} + +impl AnyMap { + pub(crate) fn entry(&mut self) -> Entry<'_, T> { + Entry { + map: &mut self.map, + _marker: std::marker::PhantomData, + } + } +} + +pub(crate) struct Entry<'a, T: 'static> { + map: &'a mut HashMap>, + _marker: std::marker::PhantomData, +} + +impl<'a, T: 'static> Entry<'a, T> { + pub(crate) fn or_insert_with(self, default: F) -> &'a mut T + where + F: FnOnce() -> T, + { + let type_id = TypeId::of::(); + let value = self + .map + .entry(type_id) + .or_insert_with(|| Box::new(default())); + value.downcast_mut().expect("type id mismatch") + } +} diff --git a/src/yewdux/context.rs b/src/yewdux/context.rs new file mode 100644 index 0000000..b073cc8 --- /dev/null +++ b/src/yewdux/context.rs @@ -0,0 +1,271 @@ +use std::rc::Rc; + +use crate::yewdux::{ + anymap::AnyMap, + mrc::Mrc, + store::{Reducer, Store}, + subscriber::{Callable, SubscriberId, Subscribers}, +}; + +pub(crate) struct Entry { + pub(crate) store: Mrc>, +} + +impl Clone for Entry { + fn clone(&self) -> Self { + Self { + store: Mrc::clone(&self.store), + } + } +} + +impl Entry { + /// Apply a function to state, returning if it should notify subscribers or not. + pub(crate) fn reduce>(&self, reducer: R) -> bool { + let old = Rc::clone(&self.store.borrow()); + // Apply the reducer. + let new = reducer.apply(Rc::clone(&old)); + // Update to new state. + *self.store.borrow_mut() = new; + // Return whether or not subscribers should be notified. + self.store.borrow().should_notify(&old) + } +} + +/// Execution context for a dispatch +/// +/// # Example +/// +/// ``` +/// use yewdux::prelude::*; +/// +/// #[derive(Clone, PartialEq, Default, Store)] +/// struct Counter(usize); +/// +/// // In a real application, you'd typically get the context from a parent component +/// let cx = yewdux::Context::new(); +/// let dispatch = Dispatch::::new(&cx); +/// ``` +#[derive(Clone, Default, PartialEq)] +pub struct Context { + inner: Mrc, +} + +impl Context { + pub fn new() -> Self { + Default::default() + } + + #[cfg(any(doc, feature = "doctests", target_arch = "wasm32"))] + pub fn global() -> Self { + thread_local! { + static CONTEXT: Context = Default::default(); + } + + CONTEXT + .try_with(|cx| cx.clone()) + .expect("CONTEXTS thread local key init failed") + } + + /// Initialize a store using a custom constructor. `Store::new` will not be called in this + /// case. If already initialized, the custom constructor will not be called. + pub fn init S>(&self, new_store: F) { + self.get_or_init(new_store); + } + + /// Get or initialize a store using a custom constructor. `Store::new` will not be called in + /// this case. If already initialized, the custom constructor will not be called. + pub(crate) fn get_or_init S>(&self, new_store: F) -> Entry { + // Get context, or None if it doesn't exist. + // + // We use an option here because a new Store should not be created during this borrow. We + // want to allow this store access to other stores during creation, so cannot be borrowing + // the global resource while initializing. Instead we create a temporary placeholder, which + // indicates the store needs to be created. Without this indicator we would have needed to + // check if the map contains the entry beforehand, which would have meant two map lookups + // per call instead of just one. + let maybe_entry = self.inner.with_mut(|x| { + x.entry::>>>() + .or_insert_with(|| None.into()) + .clone() + }); + + // If it doesn't exist, create and save the new store. + let exists = maybe_entry.borrow().is_some(); + if !exists { + // Init store outside of borrow. This allows the store to access other stores when it + // is being created. + let entry = Entry { + store: Mrc::new(Rc::new(new_store(self))), + }; + + *maybe_entry.borrow_mut() = Some(entry); + } + + // Now we get the context, which must be initialized because we already checked above. + let entry = maybe_entry + .borrow() + .clone() + .expect("Context not initialized"); + + entry + } + + /// Get or initialize a store with a default Store::new implementation. + pub(crate) fn get_or_init_default(&self) -> Entry { + self.get_or_init(S::new) + } + + pub fn reduce>(&self, r: R) { + let entry = self.get_or_init_default::(); + let should_notify = entry.reduce(r); + + if should_notify { + let state = Rc::clone(&entry.store.borrow()); + self.notify_subscribers(state) + } + } + + pub fn reduce_mut(&self, f: F) { + self.reduce(|mut state| { + f(Rc::make_mut(&mut state)); + state + }); + } + + /// Set state to given value. + pub fn set(&self, value: S) { + self.reduce(move |_| value.into()); + } + + /// Get current state. + pub fn get(&self) -> Rc { + Rc::clone(&self.get_or_init_default::().store.borrow()) + } + + /// Send state to all subscribers. + pub fn notify_subscribers(&self, state: Rc) { + let entry = self.get_or_init_default::>>(); + entry.store.borrow().notify(state); + } + + /// Subscribe to a store. `on_change` is called immediately, then every time state changes. + pub fn subscribe>(&self, on_change: N) -> SubscriberId { + // Notify subscriber with inital state. + on_change.call(self.get::()); + + self.get_or_init_default::>>() + .store + .borrow() + .subscribe(on_change) + } + + /// Similar to [Self::subscribe], however state is not called immediately. + pub fn subscribe_silent>(&self, on_change: N) -> SubscriberId { + self.get_or_init_default::>>() + .store + .borrow() + .subscribe(on_change) + } + + /// Initialize a listener + pub fn init_listener L>(&self, new_listener: F) { + crate::yewdux::init_listener(new_listener, self); + } + + pub fn derived_from(&self) + where + Store: crate::Store, + Derived: crate::yewdux::derived_from::DerivedFrom, + { + crate::yewdux::derived_from::derive_from::(self); + } + + pub fn derived_from_mut(&self) + where + Store: crate::Store, + Derived: crate::yewdux::derived_from::DerivedFromMut, + { + crate::yewdux::derived_from::derive_from_mut::(self); + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use crate::yewdux::*; + + #[derive(Clone, PartialEq, Eq)] + struct TestState(u32); + impl Store for TestState { + fn new(_cx: &Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[derive(Clone, PartialEq, Eq)] + struct TestState2(u32); + impl Store for TestState2 { + fn new(cx: &Context) -> Self { + cx.get_or_init_default::(); + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[test] + fn can_access_other_store_for_new_of_current_store() { + let _context = Context::new().get_or_init_default::(); + } + + #[derive(Clone, PartialEq, Eq)] + struct StoreNewIsOnlyCalledOnce(Rc>); + impl Store for StoreNewIsOnlyCalledOnce { + fn new(_cx: &Context) -> Self { + thread_local! { + /// Stores all shared state. + static COUNT: Rc> = Default::default(); + } + + let count = COUNT.try_with(|x| x.clone()).unwrap(); + + count.set(count.get() + 1); + + Self(count) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[test] + fn store_new_is_only_called_once() { + let cx = Context::new(); + cx.get_or_init_default::(); + let entry = cx.get_or_init_default::(); + + assert!(entry.store.borrow().0.get() == 1) + } + + #[test] + fn recursive_reduce() { + let cx = Context::new(); + let cx2 = cx.clone(); + cx.reduce::(|_s: Rc| { + cx2.reduce::(|s: Rc| TestState(s.0 + 1).into()); + TestState(cx2.get::().0 + 1).into() + }); + + assert_eq!(cx.get::().0, 2); + } +} diff --git a/src/yewdux/context_provider.rs b/src/yewdux/context_provider.rs new file mode 100644 index 0000000..939adcb --- /dev/null +++ b/src/yewdux/context_provider.rs @@ -0,0 +1,18 @@ +use yew::prelude::*; + +use crate::yewdux::context; + +#[derive(PartialEq, Clone, Properties)] +pub struct Props { + pub children: Children, +} + +#[function_component] +pub fn YewduxRoot(Props { children }: &Props) -> Html { + let ctx = use_state(context::Context::new); + html! { + context={(*ctx).clone()}> + { children.clone() } + > + } +} diff --git a/src/yewdux/derived_from.rs b/src/yewdux/derived_from.rs new file mode 100644 index 0000000..3509ea6 --- /dev/null +++ b/src/yewdux/derived_from.rs @@ -0,0 +1,332 @@ +//! Provides functionality for creating derived stores that automatically update +//! based on changes to other stores. +//! +//! This module enables the creation of stores that are computed from other stores, +//! allowing for automatic synchronization when the source stores change. +//! +//! There are two approaches available: +//! - `DerivedFrom`: For immutable transformations where a new derived store is created on each update +//! - `DerivedFromMut`: For mutable transformations where the derived store is updated in-place + +use std::rc::Rc; + +use crate::yewdux::Context; + +/// Trait for creating a derived store that transforms from another store immutably. +/// +/// Implementors of this trait represent a store that derives its state from another store. +/// When the source store changes, `on_change` is called to create a new instance of the derived store. +/// +/// # Type Parameters +/// +/// * `Store`: The source store type this store derives from +pub trait DerivedFrom: crate::Store + 'static { + /// Creates a new instance of the derived store based on the current state of the source store. + /// + /// # Parameters + /// + /// * `state`: The current state of the source store + /// + /// # Returns + /// + /// A new instance of the derived store + fn on_change(&self, state: Rc) -> Self; +} + +/// Internal listener that updates the derived store when the source store changes. +/// +/// This struct implements the `Listener` trait for the source store and manages +/// updating the derived store through its `Dispatch`. +struct Listener +where + Store: crate::Store, + Derived: DerivedFrom, +{ + derived: crate::yewdux::Dispatch, + _marker: std::marker::PhantomData, +} + +impl crate::yewdux::Listener for Listener +where + Store: crate::Store, + Derived: DerivedFrom, +{ + type Store = Store; + + fn on_change(&self, _cx: &Context, state: Rc) { + self.derived + .reduce(|derived| derived.on_change(Rc::clone(&state)).into()); + } +} + +/// Initializes a derived store that automatically updates when the source store changes. +/// +/// This function sets up a listener on the source store that will update the derived store +/// whenever the source store changes, using the `DerivedFrom` implementation to transform the state. +/// +/// # Type Parameters +/// +/// * `Store`: The source store type to derive from +/// * `Derived`: The derived store type that implements `DerivedFrom` +/// +/// # Parameters +/// +/// * `cx`: The Yewdux context +/// +/// # Example +/// +/// ```rust +/// use std::rc::Rc; +/// use yewdux::{Context, Store, Dispatch}; +/// use yewdux::derived_from::{DerivedFrom, derive_from}; +/// +/// #[derive(Clone, PartialEq)] +/// struct SourceStore { value: i32 } +/// impl Store for SourceStore { +/// fn new(_: &Context) -> Self { Self { value: 0 } } +/// fn should_notify(&self, old: &Self) -> bool { self != old } +/// } +/// +/// #[derive(Clone, PartialEq)] +/// struct DerivedStore { doubled_value: i32 } +/// impl Store for DerivedStore { +/// fn new(_: &Context) -> Self { Self { doubled_value: 0 } } +/// fn should_notify(&self, old: &Self) -> bool { self != old } +/// } +/// +/// impl DerivedFrom for DerivedStore { +/// fn on_change(&self, source: Rc) -> Self { +/// Self { doubled_value: source.value * 2 } +/// } +/// } +/// +/// // Create a context - in a real application, you'd typically get this from a parent component +/// let cx = Context::new(); +/// +/// // Set up the derived relationship +/// derive_from::(&cx); +/// +/// // Get dispatches for both stores +/// let source_dispatch = Dispatch::::new(&cx); +/// let derived_dispatch = Dispatch::::new(&cx); +/// +/// source_dispatch.reduce_mut(|state| state.value = 5); +/// assert_eq!(derived_dispatch.get().doubled_value, 10); +/// ``` +pub fn derive_from(cx: &Context) +where + Store: crate::Store, + Derived: DerivedFrom, +{ + crate::yewdux::init_listener( + || Listener { + derived: crate::yewdux::Dispatch::::new(cx), + _marker: std::marker::PhantomData, + }, + cx, + ); +} + +/// Trait for creating a derived store that is mutably updated from another store. +/// +/// Implementors of this trait represent a store that derives its state from another store. +/// When the source store changes, `on_change` is called to mutably update the derived store. +/// +/// # Type Parameters +/// +/// * `Store`: The source store type this store derives from +pub trait DerivedFromMut: crate::Store + Clone + 'static { + /// Updates the derived store based on the current state of the source store. + /// + /// # Parameters + /// + /// * `state`: The current state of the source store + fn on_change(&mut self, state: Rc); +} + +/// Internal listener that mutably updates the derived store when the source store changes. +/// +/// This struct implements the `Listener` trait for the source store and manages +/// updating the derived store through its `Dispatch` using mutable references. +struct ListenerMut +where + Store: crate::Store, + Derived: DerivedFromMut, +{ + derived: crate::yewdux::Dispatch, + _marker: std::marker::PhantomData, +} + +impl crate::yewdux::Listener for ListenerMut +where + Store: crate::Store, + Derived: DerivedFromMut, +{ + type Store = Store; + + fn on_change(&self, _cx: &Context, state: Rc) { + self.derived + .reduce_mut(|derived| derived.on_change(Rc::clone(&state))); + } +} + +/// Initializes a derived store that is mutably updated when the source store changes. +/// +/// This function sets up a listener on the source store that will update the derived store +/// whenever the source store changes, using the `DerivedFromMut` implementation to transform the state. +/// +/// # Type Parameters +/// +/// * `Store`: The source store type to derive from +/// * `Derived`: The derived store type that implements `DerivedFromMut` +/// +/// # Parameters +/// +/// * `cx`: The Yewdux context +/// +/// # Example +/// +/// ```rust +/// use std::rc::Rc; +/// use yewdux::{Context, Store, Dispatch}; +/// use yewdux::derived_from::{DerivedFromMut, derive_from_mut}; +/// +/// #[derive(Clone, PartialEq)] +/// struct SourceStore { value: i32 } +/// impl Store for SourceStore { +/// fn new(_: &Context) -> Self { Self { value: 0 } } +/// fn should_notify(&self, old: &Self) -> bool { self != old } +/// } +/// +/// #[derive(Clone, PartialEq)] +/// struct DerivedStore { doubled_value: i32 } +/// impl Store for DerivedStore { +/// fn new(_: &Context) -> Self { Self { doubled_value: 0 } } +/// fn should_notify(&self, old: &Self) -> bool { self != old } +/// } +/// +/// impl DerivedFromMut for DerivedStore { +/// fn on_change(&mut self, source: Rc) { +/// self.doubled_value = source.value * 2; +/// } +/// } +/// +/// // Create a context - in a real application, you'd typically get this from a parent component +/// let cx = Context::new(); +/// +/// // Set up the derived relationship with mutable updates +/// derive_from_mut::(&cx); +/// +/// // Get dispatches for both stores +/// let source_dispatch = Dispatch::::new(&cx); +/// let derived_dispatch = Dispatch::::new(&cx); +/// +/// source_dispatch.reduce_mut(|state| state.value = 5); +/// assert_eq!(derived_dispatch.get().doubled_value, 10); +/// ``` +pub fn derive_from_mut(cx: &Context) +where + Store: crate::Store, + Derived: DerivedFromMut, +{ + crate::yewdux::init_listener( + || ListenerMut { + derived: crate::yewdux::Dispatch::::new(cx), + _marker: std::marker::PhantomData, + }, + cx, + ); +} + +#[cfg(test)] +mod tests { + use crate::yewdux::derived_from::{derive_from, derive_from_mut}; + use crate::yewdux::Dispatch; + use std::rc::Rc; + + use crate::yewdux::*; + + #[test] + fn can_derive_from() { + #[derive(Clone, PartialEq, Eq)] + struct TestState(u32); + impl crate::yewdux::Store for TestState { + fn new(_cx: &crate::yewdux::Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[derive(Clone, PartialEq, Eq)] + struct TestDerived(u32); + impl crate::yewdux::Store for TestDerived { + fn new(_cx: &crate::yewdux::Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + impl DerivedFrom for TestDerived { + fn on_change(&self, state: Rc) -> Self { + Self(state.0) + } + } + + let cx = crate::yewdux::Context::new(); + derive_from::(&cx); + + let dispatch_derived = Dispatch::::new(&cx); + let dispatch_state = Dispatch::::new(&cx); + + dispatch_state.reduce_mut(|state| state.0 += 1); + assert_eq!(dispatch_derived.get().0, 1); + } + + #[test] + fn can_derive_from_mut() { + #[derive(Clone, PartialEq, Eq)] + struct TestState(u32); + impl crate::yewdux::Store for TestState { + fn new(_cx: &crate::yewdux::Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[derive(Clone, PartialEq, Eq)] + struct TestDerived(u32); + impl crate::yewdux::Store for TestDerived { + fn new(_cx: &crate::yewdux::Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + impl DerivedFromMut for TestDerived { + fn on_change(&mut self, state: Rc) { + self.0 = state.0; + } + } + + let cx = crate::yewdux::Context::new(); + derive_from_mut::(&cx); + + let dispatch_derived = Dispatch::::new(&cx); + let dispatch_state = Dispatch::::new(&cx); + + dispatch_state.reduce_mut(|state| state.0 += 1); + assert_eq!(dispatch_derived.get().0, 1); + } +} diff --git a/src/yewdux/dispatch.rs b/src/yewdux/dispatch.rs new file mode 100644 index 0000000..50e4ac4 --- /dev/null +++ b/src/yewdux/dispatch.rs @@ -0,0 +1,909 @@ +//! This module defines how you can interact with your [`Store`]. +//! +//! ``` +//! use yewdux::prelude::*; +//! +//! #[derive(Default, Clone, PartialEq, Store)] +//! struct State { +//! count: usize, +//! } +//! +//! // Create a context - in a real application, you'd typically get this from a parent component +//! let cx = yewdux::Context::new(); +//! let dispatch = Dispatch::::new(&cx); +//! +//! // Update the state +//! dispatch.reduce_mut(|state| state.count = 1); +//! +//! // Get the current state +//! let state = dispatch.get(); +//! +//! assert!(state.count == 1); +//! ``` +//! +//! ## Usage with YewduxRoot +//! +//! For applications with server-side rendering (SSR) support, the recommended +//! approach is to use `YewduxRoot` to provide context: +//! +//! ``` +//! use std::rc::Rc; +//! use yew::prelude::*; +//! use yewdux::prelude::*; +//! +//! // Define your store +//! #[derive(Default, Clone, PartialEq, Store)] +//! struct State { +//! count: u32, +//! } +//! +//! // Function component using hooks to access state +//! #[function_component] +//! fn Counter() -> Html { +//! // Get both state and dispatch from the context +//! let (state, dispatch) = use_store::(); +//! let onclick = dispatch.reduce_mut_callback(|state| state.count += 1); +//! +//! html! { +//! <> +//!

{ state.count }

+//! +//! +//! } +//! } +//! +//! // Root component that sets up the YewduxRoot context +//! #[function_component] +//! fn App() -> Html { +//! html! { +//! +//! +//! +//! } +//! } +//! ``` +//! + +use std::{future::Future, rc::Rc}; + +use yew::Callback; + +use crate::yewdux::{ + context::Context, + store::{Reducer, Store}, + subscriber::{Callable, SubscriberId}, +}; + +/// The primary interface to a [`Store`]. +pub struct Dispatch { + pub(crate) _subscriber_id: Option>>, + pub(crate) cx: Context, +} + +impl std::fmt::Debug for Dispatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Dispatch") + .field("_subscriber_id", &self._subscriber_id) + .finish() + } +} + +#[cfg(any(doc, feature = "doctests", target_arch = "wasm32"))] +impl Default for Dispatch { + fn default() -> Self { + Self::global() + } +} + +impl Dispatch { + /// Create a new dispatch with the global context (thread local). + /// + /// This is only available for wasm. For SSR, see the YewduxRoot pattern. + #[cfg(any(doc, feature = "doctests", target_arch = "wasm32"))] + pub fn global() -> Self { + Self::new(&Context::global()) + } + + /// Create a new dispatch with access to the given context. + pub fn new(cx: &Context) -> Self { + Self { + _subscriber_id: Default::default(), + cx: cx.clone(), + } + } + + /// Get the context used by this dispatch. + pub fn context(&self) -> &Context { + &self.cx + } + + /// Spawn a future with access to this dispatch. + #[cfg(feature = "future")] + pub fn spawn_future(&self, f: F) + where + F: FnOnce(Self) -> FU, + FU: Future + 'static, + { + yew::platform::spawn_local(f(self.clone())); + } + + /// Create a callback that will spawn a future with access to this dispatch. + #[cfg(feature = "future")] + pub fn future_callback(&self, f: F) -> Callback + where + F: Fn(Self) -> FU + 'static, + FU: Future + 'static, + { + let dispatch = self.clone(); + Callback::from(move |_| dispatch.spawn_future(&f)) + } + + /// Create a callback that will spawn a future with access to this dispatch and the emitted + /// event. + #[cfg(feature = "future")] + pub fn future_callback_with(&self, f: F) -> Callback + where + F: Fn(Self, E) -> FU + 'static, + FU: Future + 'static, + { + let dispatch = self.clone(); + Callback::from(move |e| dispatch.spawn_future(|dispatch| f(dispatch, e))) + } + + /// Create a dispatch that subscribes to changes in state. Latest state is sent immediately, + /// and on every subsequent change. Automatically unsubscribes when this dispatch is dropped. + /// + /// ## Higher-Order Component Pattern with YewduxRoot + /// + /// ``` + /// use std::rc::Rc; + /// + /// use yew::prelude::*; + /// use yewdux::prelude::*; + /// + /// #[derive(Default, Clone, PartialEq, Eq, Store)] + /// struct State { + /// count: u32, + /// } + /// + /// // Props for our struct component + /// #[derive(Properties, PartialEq, Clone)] + /// struct CounterProps { + /// dispatch: Dispatch, + /// } + /// + /// // Message type for state updates + /// enum Msg { + /// StateChanged(Rc), + /// } + /// + /// // Our struct component that uses the state + /// struct Counter { + /// state: Rc, + /// dispatch: Dispatch, + /// } + /// + /// impl Component for Counter { + /// type Message = Msg; + /// type Properties = CounterProps; + /// + /// fn create(ctx: &Context) -> Self { + /// // Subscribe to state changes + /// let callback = ctx.link().callback(Msg::StateChanged); + /// let dispatch = ctx.props().dispatch.clone().subscribe_silent(callback); + /// + /// Self { + /// state: dispatch.get(), + /// dispatch, + /// } + /// } + /// + /// fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + /// match msg { + /// Msg::StateChanged(state) => { + /// self.state = state; + /// true + /// } + /// } + /// } + /// + /// fn view(&self, _ctx: &Context) -> Html { + /// let count = self.state.count; + /// let onclick = self.dispatch.reduce_mut_callback(|s| s.count += 1); + /// + /// html! { + /// <> + ///

{ count }

+ /// + /// + /// } + /// } + /// } + /// + /// // Higher-Order Component (HOC) that accesses the context + /// #[function_component] + /// fn CounterHoc() -> Html { + /// // Use the hook to get the dispatch from context + /// let dispatch = use_dispatch::(); + /// + /// html! { + /// + /// } + /// } + /// + /// // App component with YewduxRoot for SSR support + /// #[function_component] + /// fn App() -> Html { + /// html! { + /// + /// + /// + /// } + /// } + /// ``` + pub fn subscribe>(self, on_change: C) -> Self { + let id = self.cx.subscribe(on_change); + + Self { + _subscriber_id: Some(Rc::new(id)), + cx: self.cx, + } + } + + /// Create a dispatch that subscribes to changes in state. Similar to [Self::subscribe], + /// however state is **not** sent immediately. Automatically unsubscribes when this dispatch is + /// dropped. + pub fn subscribe_silent>(self, on_change: C) -> Self { + let id = self.cx.subscribe_silent(on_change); + + Self { + _subscriber_id: Some(Rc::new(id)), + cx: self.cx, + } + } + + /// Get the current state. + pub fn get(&self) -> Rc { + self.cx.get::() + } + + /// Apply a [`Reducer`](crate::store::Reducer) immediately. + /// + /// ``` + /// # use std::rc::Rc; + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// #[derive(Default, Clone, PartialEq, Eq, Store)] + /// struct State { + /// count: u32, + /// } + /// + /// struct AddOne; + /// impl Reducer for AddOne { + /// fn apply(self, state: Rc) -> Rc { + /// State { + /// count: state.count + 1, + /// } + /// .into() + /// } + /// } + /// + /// # fn main() { + /// # // Context handling code is omitted for clarity + /// # let cx = yewdux::Context::new(); + /// # let dispatch = Dispatch::::new(&cx); + /// // Apply a reducer to update the state + /// dispatch.apply(AddOne); + /// # ; + /// # } + /// ``` + pub fn apply>(&self, reducer: R) { + self.cx.reduce(reducer); + } + + /// Create a callback that applies a [`Reducer`](crate::store::Reducer). + /// + /// ``` + /// # use std::rc::Rc; + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// #[derive(Default, Clone, PartialEq, Eq, Store)] + /// struct State { + /// count: u32, + /// } + /// + /// struct AddOne; + /// impl Reducer for AddOne { + /// fn apply(self, state: Rc) -> Rc { + /// State { + /// count: state.count + 1, + /// } + /// .into() + /// } + /// } + /// + /// # fn main() { + /// # // Context handling code is omitted for clarity + /// # let cx = yewdux::Context::new(); + /// # let dispatch = Dispatch::::new(&cx); + /// // Create a callback that will update the state when triggered + /// let onclick = dispatch.apply_callback(|_| AddOne); + /// html! { + /// + /// } + /// # ; + /// # } + /// ``` + pub fn apply_callback(&self, f: F) -> Callback + where + M: Reducer, + F: Fn(E) -> M + 'static, + { + let context = self.cx.clone(); + Callback::from(move |e| { + let msg = f(e); + context.reduce(msg); + }) + } + + /// Set state to given value immediately. + /// + /// ``` + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// # #[derive(Default, Clone, PartialEq, Eq, Store)] + /// # struct State { + /// # count: u32, + /// # } + /// # fn main() { + /// # // Context handling code is omitted for clarity + /// # let cx = yewdux::Context::new(); + /// # let dispatch = Dispatch::::new(&cx); + /// // Set the state to a new value + /// dispatch.set(State { count: 0 }); + /// # } + /// ``` + pub fn set(&self, val: S) { + self.cx.set(val); + } + + /// Set state using value from callback. + /// + /// ``` + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// # #[derive(Default, Clone, PartialEq, Eq, Store)] + /// # struct State { + /// # count: u32, + /// # } + /// # #[hook] + /// # fn use_foo() { + /// let dispatch = use_dispatch::(); + /// let onchange = dispatch.set_callback(|event: Event| { + /// let value = event.target_unchecked_into::().value(); + /// State { count: value.parse().unwrap() } + /// }); + /// html! { + /// + /// } + /// # ; + /// # } + /// ``` + pub fn set_callback(&self, f: F) -> Callback + where + F: Fn(E) -> S + 'static, + { + let context = self.cx.clone(); + Callback::from(move |e| { + let val = f(e); + context.set(val); + }) + } + + /// Change state immediately. + /// + /// ``` + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// # #[derive(Default, Clone, PartialEq, Eq, Store)] + /// # struct State { + /// # count: u32, + /// # } + /// # fn main() { + /// # // Context handling code is omitted for clarity + /// # let cx = yewdux::Context::new(); + /// # let dispatch = Dispatch::::new(&cx); + /// // Transform the current state into a new state + /// dispatch.reduce(|state| State { count: state.count + 1 }.into()); + /// # } + /// ``` + pub fn reduce(&self, f: F) + where + F: FnOnce(Rc) -> Rc, + { + self.cx.reduce(f); + } + + /// Create a callback that changes state. + /// + /// ``` + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// # #[derive(Default, Clone, PartialEq, Eq, Store)] + /// # struct State { + /// # count: u32, + /// # } + /// # fn main() { + /// # // Context handling code is omitted for clarity + /// # let cx = yewdux::Context::new(); + /// # let dispatch = Dispatch::::new(&cx); + /// // Create a callback that will transform the state when triggered + /// let onclick = dispatch.reduce_callback(|state| State { count: state.count + 1 }.into()); + /// html! { + /// + /// } + /// # ; + /// # } + /// ``` + pub fn reduce_callback(&self, f: F) -> Callback + where + F: Fn(Rc) -> Rc + 'static, + E: 'static, + { + let context = self.cx.clone(); + Callback::from(move |_| { + context.reduce(&f); + }) + } + + /// Similar to [Self::reduce_callback] but also provides the fired event. + /// + /// ``` + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// # #[derive(Default, Clone, PartialEq, Eq, Store)] + /// # struct State { + /// # count: u32, + /// # } + /// # fn main() { + /// # // Context handling code is omitted for clarity + /// # let cx = yewdux::Context::new(); + /// # let dispatch = Dispatch::::new(&cx); + /// // Create a callback that will transform the state using the event data + /// let onchange = dispatch.reduce_callback_with(|state, event: Event| { + /// let value = event.target_unchecked_into::().value(); + /// State { + /// count: value.parse().unwrap() + /// } + /// .into() + /// }); + /// html! { + /// + /// } + /// # ; + /// # } + /// ``` + pub fn reduce_callback_with(&self, f: F) -> Callback + where + F: Fn(Rc, E) -> Rc + 'static, + E: 'static, + { + let context = self.cx.clone(); + Callback::from(move |e: E| { + context.reduce(|x| f(x, e)); + }) + } + + /// Mutate state with given function. + /// + /// ``` + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// # #[derive(Default, Clone, PartialEq, Eq, Store)] + /// # struct State { + /// # count: u32, + /// # } + /// # fn main() { + /// # // Context handling code is omitted for clarity + /// # let cx = yewdux::Context::new(); + /// # let dispatch = Dispatch::::new(&cx); + /// // Mutate the state in-place + /// dispatch.reduce_mut(|state| state.count += 1); + /// # } + /// ``` + pub fn reduce_mut(&self, f: F) -> R + where + S: Clone, + F: FnOnce(&mut S) -> R, + { + let mut result = None; + + self.cx.reduce_mut(|x| { + result = Some(f(x)); + }); + + result.expect("result not initialized") + } + + /// Like [Self::reduce_mut] but from a callback. + /// + /// ``` + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// # #[derive(Default, Clone, PartialEq, Eq, Store)] + /// # struct State { + /// # count: u32, + /// # } + /// # fn main() { + /// # // Context handling code is omitted for clarity + /// # let cx = yewdux::Context::new(); + /// # let dispatch = Dispatch::::new(&cx); + /// // Create a callback that will mutate the state in-place when triggered + /// let onclick = dispatch.reduce_mut_callback(|s| s.count += 1); + /// html! { + /// + /// } + /// # ; + /// # } + /// ``` + pub fn reduce_mut_callback(&self, f: F) -> Callback + where + S: Clone, + F: Fn(&mut S) -> R + 'static, + E: 'static, + { + let context = self.cx.clone(); + Callback::from(move |_| { + context.reduce_mut(|x| { + f(x); + }); + }) + } + + /// Similar to [Self::reduce_mut_callback] but also provides the fired event. + /// + /// ``` + /// # use yew::prelude::*; + /// # use yewdux::prelude::*; + /// # #[derive(Default, Clone, PartialEq, Eq, Store)] + /// # struct State { + /// # count: u32, + /// # } + /// # fn main() { + /// # // Context handling code is omitted for clarity + /// # let cx = yewdux::Context::new(); + /// # let dispatch = Dispatch::::new(&cx); + /// // Create a callback that will mutate the state using event data + /// let onchange = dispatch.reduce_mut_callback_with(|state, event: Event| { + /// let value = event.target_unchecked_into::().value(); + /// state.count = value.parse().unwrap(); + /// }); + /// html! { + /// + /// } + /// # ; + /// # } + /// ``` + pub fn reduce_mut_callback_with(&self, f: F) -> Callback + where + S: Clone, + F: Fn(&mut S, E) -> R + 'static, + E: 'static, + { + let context = self.cx.clone(); + Callback::from(move |e: E| { + context.reduce_mut(|x| { + f(x, e); + }); + }) + } +} + +impl Clone for Dispatch { + fn clone(&self) -> Self { + Self { + _subscriber_id: self._subscriber_id.clone(), + cx: self.cx.clone(), + } + } +} + +impl PartialEq for Dispatch { + fn eq(&self, other: &Self) -> bool { + match (&self._subscriber_id, &other._subscriber_id) { + (Some(a), Some(b)) => Rc::ptr_eq(a, b), + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::yewdux::{mrc::Mrc, subscriber::Subscribers}; + + use crate::yewdux::*; + + #[derive(Clone, PartialEq, Eq)] + struct TestState(u32); + impl Store for TestState { + fn new(_cx: &Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + #[derive(PartialEq, Eq)] + struct TestStateNoClone(u32); + impl Store for TestStateNoClone { + fn new(_cx: &Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + struct Msg; + impl Reducer for Msg { + fn apply(self, state: Rc) -> Rc { + TestState(state.0 + 1).into() + } + } + + #[test] + fn apply_no_clone() { + Dispatch::new(&Context::new()).reduce(|_| TestStateNoClone(1).into()); + } + + #[test] + fn reduce_changes_value() { + let dispatch = Dispatch::::new(&Context::new()); + + let old = dispatch.get(); + + dispatch.reduce(|_| TestState(1).into()); + + let new = dispatch.get(); + + assert!(old != new); + } + + #[test] + fn reduce_mut_changes_value() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + dispatch.reduce_mut(|state| *state = TestState(1)); + + let new = dispatch.get(); + + assert!(old != new); + } + + #[test] + fn reduce_does_not_require_static() { + let val = "1".to_string(); + Dispatch::new(&Context::new()).reduce(|_| TestState(val.parse().unwrap()).into()); + } + + #[test] + fn reduce_mut_does_not_require_static() { + let val = "1".to_string(); + Dispatch::new(&Context::new()) + .reduce_mut(|state: &mut TestState| state.0 = val.parse().unwrap()); + } + + #[test] + fn set_changes_value() { + let dispatch = Dispatch::::new(&Context::new()); + + let old = dispatch.get(); + + dispatch.set(TestState(1)); + + let new = dispatch.get(); + + assert!(old != new); + } + + #[test] + fn apply_changes_value() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + dispatch.apply(Msg); + + let new = dispatch.get(); + + assert!(old != new); + } + + #[test] + fn dispatch_set_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + dispatch.set(TestState(1)); + + assert!(dispatch.get() != old) + } + + #[test] + fn dispatch_set_callback_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + let cb = dispatch.set_callback(|_| TestState(1)); + cb.emit(()); + + assert!(dispatch.get() != old) + } + + #[test] + fn dispatch_reduce_mut_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + dispatch.reduce_mut(|state| state.0 += 1); + + assert!(dispatch.get() != old) + } + + #[test] + fn dispatch_reduce_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + dispatch.reduce(|_| TestState(1).into()); + + assert!(dispatch.get() != old) + } + + #[test] + fn dispatch_reduce_callback_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + let cb = dispatch.reduce_callback(|_| TestState(1).into()); + cb.emit(()); + + assert!(dispatch.get() != old) + } + + #[test] + fn dispatch_reduce_mut_callback_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + let cb = dispatch.reduce_mut_callback(|state| state.0 += 1); + cb.emit(()); + + assert!(dispatch.get() != old) + } + + #[test] + fn dispatch_reduce_callback_with_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + let cb = dispatch.reduce_callback_with(|_, _| TestState(1).into()); + cb.emit(1); + + assert!(dispatch.get() != old) + } + + #[test] + fn dispatch_reduce_mut_callback_with_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + let cb = dispatch.reduce_mut_callback_with(|state, val| state.0 += val); + cb.emit(1); + + assert!(dispatch.get() != old) + } + + #[test] + fn dispatch_apply_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + dispatch.apply(Msg); + + assert!(dispatch.get() != old) + } + + #[test] + fn dispatch_apply_callback_works() { + let dispatch = Dispatch::::new(&Context::new()); + let old = dispatch.get(); + + let cb = dispatch.apply_callback(|_| Msg); + cb.emit(()); + + assert!(dispatch.get() != old) + } + + #[test] + fn subscriber_is_notified() { + let cx = Context::new(); + let flag = Mrc::new(false); + + let _id = { + let flag = flag.clone(); + Dispatch::::new(&cx) + .subscribe(move |_| flag.clone().with_mut(|flag| *flag = true)) + }; + + *flag.borrow_mut() = false; + + Dispatch::::new(&cx).reduce_mut(|state| state.0 += 1); + + assert!(*flag.borrow()); + } + + #[test] + fn subscriber_is_not_notified_when_state_is_same() { + let cx = Context::new(); + let flag = Mrc::new(false); + let dispatch = Dispatch::::new(&cx); + + // TestState(1) + dispatch.reduce_mut(|_| {}); + + let _id = { + let flag = flag.clone(); + Dispatch::::new(&cx) + .subscribe(move |_| flag.clone().with_mut(|flag| *flag = true)) + }; + + *flag.borrow_mut() = false; + + // TestState(1) + dispatch.reduce_mut(|state| state.0 = 0); + + assert!(!*flag.borrow()); + } + + #[test] + fn dispatch_unsubscribes_when_dropped() { + let cx = Context::new(); + let entry = cx.get_or_init_default::>>(); + + assert!(entry.store.borrow().borrow().0.is_empty()); + + let dispatch = Dispatch::::new(&cx).subscribe(|_| ()); + + assert!(!entry.store.borrow().borrow().0.is_empty()); + + drop(dispatch); + + assert!(entry.store.borrow().borrow().0.is_empty()); + } + + #[test] + fn dispatch_clone_and_original_unsubscribe_when_both_dropped() { + let cx = Context::new(); + let entry = cx.get_or_init_default::>>(); + + assert!(entry.store.borrow().borrow().0.is_empty()); + + let dispatch = Dispatch::::new(&cx).subscribe(|_| ()); + let dispatch_clone = dispatch.clone(); + + assert!(!entry.store.borrow().borrow().0.is_empty()); + + drop(dispatch_clone); + + assert!(!entry.store.borrow().borrow().0.is_empty()); + + drop(dispatch); + + assert!(entry.store.borrow().borrow().0.is_empty()); + } +} diff --git a/src/yewdux/functional.rs b/src/yewdux/functional.rs new file mode 100644 index 0000000..3ac829c --- /dev/null +++ b/src/yewdux/functional.rs @@ -0,0 +1,219 @@ +//! The functional interface for Yewdux +use std::{ops::Deref, rc::Rc}; + +use yew::functional::*; + +use crate::yewdux::{dispatch::Dispatch, store::Store, Context}; + +#[hook] +fn use_cx() -> Context { + #[cfg(target_arch = "wasm32")] + { + use_context::() + .unwrap_or_else(crate::yewdux::context::Context::global) + } + #[cfg(not(target_arch = "wasm32"))] + { + use_context::().expect("YewduxRoot not found") + } +} + +#[hook] +pub fn use_dispatch() -> Dispatch +where + S: Store, +{ + Dispatch::new(&use_cx()) +} + +/// This hook allows accessing the state of a store. When the store is modified, a re-render is +/// automatically triggered. +/// +/// # Example +/// ``` +/// use yew::prelude::*; +/// use yewdux::prelude::*; +/// +/// #[derive(Default, Clone, PartialEq, Store)] +/// struct State { +/// count: u32, +/// } +/// +/// #[function_component] +/// fn App() -> Html { +/// let (state, dispatch) = use_store::(); +/// let onclick = dispatch.reduce_mut_callback(|s| s.count += 1); +/// html! { +/// <> +///

{ state.count }

+/// +/// +/// } +/// } +/// ``` +#[hook] +pub fn use_store() -> (Rc, Dispatch) +where + S: Store, +{ + let dispatch = use_dispatch::(); + let state: UseStateHandle> = use_state(|| dispatch.get()); + let dispatch = { + let state = state.clone(); + use_state(move || dispatch.subscribe_silent(move |val| state.set(val))) + }; + + (Rc::clone(&state), dispatch.deref().clone()) +} + +/// Simliar to ['use_store'], but only provides the state. +#[hook] +pub fn use_store_value() -> Rc +where + S: Store, +{ + let (state, _dispatch) = use_store(); + + state +} + +/// Provides access to some derived portion of state. Useful when you only want to rerender +/// when that portion has changed. +/// +/// # Example +/// ``` +/// use yew::prelude::*; +/// use yewdux::prelude::*; +/// +/// #[derive(Default, Clone, PartialEq, Store)] +/// struct State { +/// count: u32, +/// } +/// +/// #[function_component] +/// fn App() -> Html { +/// let dispatch = use_dispatch::(); +/// let count = use_selector(|state: &State| state.count); +/// let onclick = dispatch.reduce_mut_callback(|state| state.count += 1); +/// +/// html! { +/// <> +///

{ *count }

+/// +/// +/// } +/// } +/// ``` +#[hook] +pub fn use_selector(selector: F) -> Rc +where + S: Store, + R: PartialEq + 'static, + F: Fn(&S) -> R + 'static, +{ + use_selector_eq(selector, |a, b| a == b) +} + +/// Similar to [`use_selector`], with the additional flexibility of a custom equality check for +/// selected value. +#[hook] +pub fn use_selector_eq(selector: F, eq: E) -> Rc +where + S: Store, + R: 'static, + F: Fn(&S) -> R + 'static, + E: Fn(&R, &R) -> bool + 'static, +{ + use_selector_eq_with_deps(move |state, _| selector(state), eq, ()) +} + +/// Similar to [`use_selector`], but also allows for dependencies from environment. This is +/// necessary when the derived value uses some captured value. +/// +/// # Example +/// ``` +/// use std::collections::HashMap; +/// +/// use yew::prelude::*; +/// use yewdux::prelude::*; +/// +/// #[derive(Default, Clone, PartialEq, Store)] +/// struct State { +/// user_names: HashMap, +/// } +/// +/// #[derive(Properties, PartialEq, Clone)] +/// struct AppProps { +/// user_id: u32, +/// } +/// +/// #[function_component] +/// fn ViewName(&AppProps { user_id }: &AppProps) -> Html { +/// let user_name = use_selector_with_deps( +/// |state: &State, id| state.user_names.get(id).cloned().unwrap_or_default(), +/// user_id, +/// ); +/// +/// html! { +///

+/// { user_name } +///

+/// } +/// } +/// ``` +#[hook] +pub fn use_selector_with_deps(selector: F, deps: D) -> Rc +where + S: Store, + R: PartialEq + 'static, + D: Clone + PartialEq + 'static, + F: Fn(&S, &D) -> R + 'static, +{ + use_selector_eq_with_deps(selector, |a, b| a == b, deps) +} + +/// Similar to [`use_selector_with_deps`], but also allows an equality function, similar to +/// [`use_selector_eq`] +#[hook] +pub fn use_selector_eq_with_deps(selector: F, eq: E, deps: D) -> Rc +where + S: Store, + R: 'static, + D: Clone + PartialEq + 'static, + F: Fn(&S, &D) -> R + 'static, + E: Fn(&R, &R) -> bool + 'static, +{ + let dispatch = use_dispatch::(); + // Given to user, this is what we update to force a re-render. + let selected = { + let state = dispatch.get(); + let value = selector(&state, &deps); + + use_state(|| Rc::new(value)) + }; + // Local tracking value, because `selected` isn't updated in our subscriber scope. + let current = { + let value = Rc::clone(&selected); + use_mut_ref(|| value) + }; + + let _dispatch = { + let selected = selected.clone(); + use_memo(deps, move |deps| { + let deps = deps.clone(); + dispatch.subscribe(move |val: Rc| { + let value = selector(&val, &deps); + + if !eq(¤t.borrow(), &value) { + let value = Rc::new(value); + // Update value for user. + selected.set(Rc::clone(&value)); + // Make sure to update our tracking value too. + *current.borrow_mut() = Rc::clone(&value); + } + }) + }) + }; + + Rc::clone(&selected) +} diff --git a/src/yewdux/input/mod.rs b/src/yewdux/input/mod.rs new file mode 100644 index 0000000..c6f5152 --- /dev/null +++ b/src/yewdux/input/mod.rs @@ -0,0 +1,104 @@ +use std::{rc::Rc, str::FromStr}; + +use crate::yewdux::{prelude::*, Context}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::JsCast; +use web_sys::{HtmlInputElement, HtmlTextAreaElement}; +use yew::prelude::*; + +pub enum InputElement { + Input(HtmlInputElement), + TextArea(HtmlTextAreaElement), +} + +pub trait FromInputElement: Sized { + fn from_input_element(el: InputElement) -> Option; +} + +impl FromInputElement for T +where + T: FromStr, +{ + fn from_input_element(el: InputElement) -> Option { + match el { + InputElement::Input(el) => el.value().parse().ok(), + InputElement::TextArea(el) => el.value().parse().ok(), + } + } +} + +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Checkbox(bool); + +impl Checkbox { + pub fn checked(&self) -> bool { + self.0 + } +} + +impl FromInputElement for Checkbox { + fn from_input_element(el: InputElement) -> Option { + if let InputElement::Input(el) = el { + Some(Self(el.checked())) + } else { + None + } + } +} + +pub trait InputDispatch { + fn context(&self) -> &Context; + + fn input(&self, f: F) -> Callback + where + R: FromInputElement, + F: Fn(Rc, R) -> Rc + 'static, + E: AsRef + JsCast + 'static, + { + let cx = self.context(); + Dispatch::::new(cx).reduce_callback_with(move |s, e| { + if let Some(value) = input_value(e) { + f(s, value) + } else { + s + } + }) + } + + fn input_mut(&self, f: F) -> Callback + where + S: Clone, + R: FromInputElement, + F: Fn(&mut S, R) + 'static, + E: AsRef + JsCast + 'static, + { + let cx = self.context(); + Dispatch::::new(cx).reduce_mut_callback_with(move |s, e| { + if let Some(value) = input_value(e) { + f(s, value); + } + }) + } +} + +impl InputDispatch for Dispatch { + fn context(&self) -> &Context { + self.context() + } +} + +/// Get any parsable value out of an input event. +pub fn input_value(event: E) -> Option +where + R: FromInputElement, + E: AsRef + JsCast, +{ + event + .target_dyn_into::() + .and_then(|el| R::from_input_element(InputElement::Input(el))) + .or_else(|| { + event + .target_dyn_into::() + .and_then(|el| R::from_input_element(InputElement::TextArea(el))) + }) +} diff --git a/src/yewdux/listener.rs b/src/yewdux/listener.rs new file mode 100644 index 0000000..f9b3014 --- /dev/null +++ b/src/yewdux/listener.rs @@ -0,0 +1,192 @@ +use std::rc::Rc; + +use crate::yewdux::{context::Context, dispatch::Dispatch, store::Store}; + +/// Listens to [Store](crate::store::Store) changes. +pub trait Listener: 'static { + type Store: Store; + + fn on_change(&self, cx: &Context, state: Rc); +} + +#[allow(unused)] +struct ListenerStore(Dispatch); +impl Store for ListenerStore { + fn new(_cx: &Context) -> Self { + // This is a private type, and only ever constructed by `init_listener` with a manual + // constructor, so this should never run. + unreachable!() + } + + fn should_notify(&self, _other: &Self) -> bool { + false + } +} + +/// Initiate a [Listener]. Does nothing if listener is already initiated. +pub fn init_listener L>(new_listener: F, cx: &Context) { + cx.get_or_init(|cx| { + let dispatch = { + let listener = new_listener(); + let cx = cx.clone(); + Dispatch::new(&cx).subscribe_silent(move |state| listener.on_change(&cx, state)) + }; + + ListenerStore::(dispatch) + }); +} + +#[cfg(test)] +#[allow(dead_code)] +mod tests { + use std::rc::Rc; + + use std::cell::Cell; + + use crate::yewdux::*; + + #[derive(Clone, PartialEq, Eq)] + struct TestState(u32); + impl Store for TestState { + fn new(_cx: &Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[derive(Clone)] + struct TestListener(Rc>); + impl Listener for TestListener { + type Store = TestState; + + fn on_change(&self, _cx: &Context, state: Rc) { + self.0.set(state.0); + } + } + + #[derive(Clone)] + struct AnotherTestListener(Rc>); + impl Listener for AnotherTestListener { + type Store = TestState; + + fn on_change(&self, _cx: &Context, state: Rc) { + self.0.set(state.0); + } + } + + #[derive(Clone, PartialEq, Eq)] + struct TestState2; + impl Store for TestState2 { + fn new(cx: &Context) -> Self { + init_listener(|| TestListener2, cx); + Self + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[derive(Clone)] + struct TestListener2; + impl Listener for TestListener2 { + type Store = TestState2; + + fn on_change(&self, _cx: &Context, _state: Rc) {} + } + + #[derive(Clone, PartialEq, Eq)] + struct TestStateRecursive(u32); + impl Store for TestStateRecursive { + fn new(_cx: &Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[derive(Clone)] + struct TestListenerRecursive; + impl Listener for TestListenerRecursive { + type Store = TestStateRecursive; + + fn on_change(&self, cx: &Context, state: Rc) { + let dispatch = Dispatch::::new(cx); + if state.0 < 10 { + dispatch.reduce_mut(|state| state.0 += 1); + } + } + } + + #[test] + fn recursion() { + let cx = Context::new(); + init_listener(|| TestListenerRecursive, &cx); + let dispatch = Dispatch::::new(&cx); + dispatch.reduce_mut(|state| state.0 = 1); + assert_eq!(dispatch.get().0, 10); + } + + #[test] + fn listener_is_called() { + let cx = Context::new(); + let listener = TestListener(Default::default()); + + init_listener(|| listener.clone(), &cx); + + Dispatch::new(&cx).reduce_mut(|state: &mut TestState| state.0 = 1); + + assert_eq!(listener.0.get(), 1) + } + + #[test] + fn listener_is_not_replaced() { + let cx = Context::new(); + let listener1 = TestListener(Default::default()); + let listener2 = TestListener(Default::default()); + + init_listener(|| listener1.clone(), &cx); + + Dispatch::new(&cx).reduce_mut(|state: &mut TestState| state.0 = 1); + + assert_eq!(listener1.0.get(), 1); + + init_listener(|| listener2.clone(), &cx); + + Dispatch::new(&cx).reduce_mut(|state: &mut TestState| state.0 = 2); + + assert_eq!(listener1.0.get(), 2); + assert_eq!(listener2.0.get(), 0); + } + + #[test] + fn listener_of_different_type_is_not_replaced() { + let cx = Context::new(); + let listener1 = TestListener(Default::default()); + let listener2 = AnotherTestListener(Default::default()); + + init_listener(|| listener1.clone(), &cx); + + cx.reduce_mut(|state: &mut TestState| state.0 = 1); + + assert_eq!(listener1.0.get(), 1); + + init_listener(|| listener2.clone(), &cx); + + cx.reduce_mut(|state: &mut TestState| state.0 = 2); + + assert_eq!(listener1.0.get(), 2); + assert_eq!(listener2.0.get(), 2); + } + + #[test] + fn can_init_listener_from_store() { + let cx = Context::new(); + cx.get::(); + } +} diff --git a/src/yewdux/mod.rs b/src/yewdux/mod.rs new file mode 100644 index 0000000..37d88a0 --- /dev/null +++ b/src/yewdux/mod.rs @@ -0,0 +1,70 @@ +//! # Yewdux +//! +//! Simple state management for [Yew](https://yew.rs) applications. +//! +//! See the [book](https://intendednull.github.io/yewdux/) for more details. +//! +//! ## Example +//! +//! ```rust +//! use yew::prelude::*; +//! use yewdux::prelude::*; +//! +//! #[derive(Default, Clone, PartialEq, Eq, Store)] +//! struct State { +//! count: u32, +//! } +//! +//! #[function_component] +//! fn App() -> Html { +//! let (state, dispatch) = use_store::(); +//! let onclick = dispatch.reduce_mut_callback(|state| state.count += 1); +//! +//! html! { +//! <> +//!

{ state.count }

+//! +//! +//! } +//! } +//! ``` +#![allow(clippy::needless_doctest_main)] + +mod anymap; +pub mod context; +pub mod context_provider; +pub mod derived_from; +pub mod dispatch; +pub mod functional; +pub mod input; +pub mod listener; +pub mod mrc; +#[cfg(any(feature = "doctests", target_arch = "wasm32"))] +pub mod storage; +pub mod store; +mod subscriber; +pub mod utils; + +// Used by macro. +#[doc(hidden)] +pub use log; + +// Allow shorthand, like `yewdux::Dispatch` +pub use context::Context; +pub use prelude::*; + +pub mod prelude { + //! Default exports + + pub use crate::yewdux::{ + context_provider::YewduxRoot, + derived_from::{DerivedFrom, DerivedFromMut}, + dispatch::Dispatch, + functional::{ + use_dispatch, use_selector, use_selector_eq, use_selector_eq_with_deps, + use_selector_with_deps, use_store, use_store_value, + }, + listener::{init_listener, Listener}, + store::{Reducer, Store}, + }; +} diff --git a/src/yewdux/mrc.rs b/src/yewdux/mrc.rs new file mode 100644 index 0000000..bd564f7 --- /dev/null +++ b/src/yewdux/mrc.rs @@ -0,0 +1,198 @@ +//! Mutable reference counted wrapper type that works well with Yewdux. +//! +//! Useful when you don't want to implement `Clone` or `PartialEq` for a type. +//! +//! ``` +//! # use yewdux::mrc::Mrc; +//! # fn main() { +//! let expensive_data = Mrc::new("Some long string that shouldn't be cloned.".to_string()); +//! let old_ref = expensive_data.clone(); +//! +//! // They are equal (for now). +//! assert!(expensive_data == old_ref); +//! +//! // Here we use interior mutability to change the inner value. Doing so will mark the +//! // container as changed. +//! *expensive_data.borrow_mut() += " Here we can modify our expensive data."; +//! +//! // Once marked as changed, it will cause any equality check to fail (forcing a re-render). +//! assert!(expensive_data != old_ref); +//! // The underlying state is the same though. +//! assert!(*expensive_data.borrow() == *old_ref.borrow()); +//! # } +//! ``` + +use std::{ + cell::{Cell, RefCell}, + ops::{Deref, DerefMut}, + rc::Rc, +}; + +use serde::{Deserialize, Serialize}; + +use crate::yewdux::{store::Store, Context}; + +fn nonce() -> u32 { + thread_local! { + static NONCE: Cell = Default::default(); + } + + NONCE + .try_with(|nonce| { + nonce.set(nonce.get().wrapping_add(1)); + nonce.get() + }) + .expect("NONCE thread local key init failed") +} + +/// Mutable reference counted wrapper type that works well with Yewdux. +/// +/// This is basically a wrapper over `Rc>`, with the notable difference of simple change +/// detection (so it works with Yewdux). Whenever this type borrows mutably, it is marked as +/// changed. Because there is no way to detect whether it has actually changed or not, it is up to +/// the user to prevent unecessary re-renders. +#[derive(Debug, Serialize, Deserialize)] +pub struct Mrc { + inner: Rc>, + nonce: Cell, +} + +impl Mrc { + pub fn new(value: T) -> Self { + Self { + inner: Rc::new(RefCell::new(value)), + nonce: Cell::new(nonce()), + } + } + + pub fn with_mut(&self, f: impl FnOnce(&mut T) -> R) -> R { + let mut this = self.borrow_mut(); + f(this.deref_mut()) + } + + pub fn borrow(&self) -> impl Deref + '_ { + self.inner.borrow() + } + + /// Provide a mutable reference to inner value. + pub fn borrow_mut(&self) -> impl DerefMut + '_ { + // Mark as changed. + self.nonce.set(nonce()); + self.inner.borrow_mut() + } +} + +impl Store for Mrc { + fn new(cx: &Context) -> Self { + T::new(cx).into() + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } +} + +impl Default for Mrc { + fn default() -> Self { + Self::new(Default::default()) + } +} + +impl Clone for Mrc { + fn clone(&self) -> Self { + Self { + inner: Rc::clone(&self.inner), + nonce: self.nonce.clone(), + } + } +} + +impl From for Mrc { + fn from(value: T) -> Self { + Self::new(value) + } +} + +impl PartialEq for Mrc { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.inner, &other.inner) && self.nonce == other.nonce + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::yewdux::{dispatch::Dispatch, store::Store, Context}; + + use super::Mrc; + use crate::yewdux::*; + + #[derive(Clone, PartialEq)] + struct TestState(Mrc); + impl Store for TestState { + fn new(_cx: &Context) -> Self { + Self(Mrc::new(0)) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + struct CanImplStoreForMrcDirectly; + impl Store for Mrc { + fn new(_cx: &Context) -> Self { + CanImplStoreForMrcDirectly.into() + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[test] + fn subscriber_is_notified_on_borrow_mut() { + let flag = Mrc::new(false); + let cx = Context::new(); + + let dispatch = { + let flag = flag.clone(); + Dispatch::::new(&cx) + .subscribe(move |_| flag.clone().with_mut(|flag| *flag = true)) + }; + + *flag.borrow_mut() = false; + + dispatch.reduce_mut(|state| { + state.0.borrow_mut(); + }); + + assert!(*flag.borrow()); + } + + #[test] + fn subscriber_is_notified_on_with_mut() { + let flag = Mrc::new(false); + let cx = Context::new(); + + let dispatch = { + let flag = flag.clone(); + Dispatch::::new(&cx) + .subscribe(move |_| flag.clone().with_mut(|flag| *flag = true)) + }; + + *flag.borrow_mut() = false; + + dispatch.reduce_mut(|state| state.0.with_mut(|_| ())); + + assert!(*flag.borrow()); + } + + #[test] + fn can_wrap_store_with_mrc() { + let cx = Context::new(); + let dispatch = Dispatch::>::new(&cx); + assert!(*dispatch.get().borrow().0.borrow() == 0) + } +} diff --git a/src/yewdux/storage.rs b/src/yewdux/storage.rs new file mode 100644 index 0000000..444595a --- /dev/null +++ b/src/yewdux/storage.rs @@ -0,0 +1,188 @@ +//! Store persistence through session or local storage +//! +//! ``` +//! use std::rc::Rc; +//! +//! use yewdux::{prelude::*, storage}; +//! +//! use serde::{Deserialize, Serialize}; +//! +//! struct StorageListener; +//! impl Listener for StorageListener { +//! type Store = State; +//! +//! fn on_change(&mut self, _cx: &Context, state: Rc) { +//! if let Err(err) = storage::save(state.as_ref(), storage::Area::Local) { +//! println!("Error saving state to storage: {:?}", err); +//! } +//! } +//! } +//! +//! #[derive(Default, Clone, PartialEq, Eq, Deserialize, Serialize)] +//! struct State { +//! count: u32, +//! } +//! +//! impl Store for State { +//! fn new(cx: &yewdux::Context) -> Self { +//! init_listener(StorageListener, cx); +//! +//! storage::load(storage::Area::Local) +//! .ok() +//! .flatten() +//! .unwrap_or_default() +//! } +//! +//! fn should_notify(&self, other: &Self) -> bool { +//! self != other +//! } +//! } +//! ``` + +use std::{any::type_name, rc::Rc}; + +use serde::{de::DeserializeOwned, Serialize}; +use wasm_bindgen::{prelude::Closure, JsCast, JsValue}; +use web_sys::{Event, Storage}; + +use crate::yewdux::{dispatch::Dispatch, listener::Listener, store::Store, Context}; + +#[derive(Debug, thiserror::Error)] +pub enum StorageError { + #[error("Window not found")] + WindowNotFound, + #[error("Could not access {0:?} storage")] + StorageAccess(Area), + #[error("A web-sys error occurred")] + WebSys(JsValue), + #[error("A serde error occurred")] + Serde(#[from] serde_json::Error), +} + +#[derive(Debug, Clone, Copy)] +pub enum Area { + Local, + Session, +} + +/// A [Listener] that will save state to browser storage whenever state has changed. +pub struct StorageListener { + area: Area, + _marker: std::marker::PhantomData, +} + +impl StorageListener { + pub fn new(area: Area) -> Self { + Self { + area, + _marker: Default::default(), + } + } +} + +impl Listener for StorageListener +where + T: Store + Serialize, +{ + type Store = T; + + fn on_change(&self, _cx: &Context, state: Rc) { + if let Err(err) = save(state.as_ref(), self.area) { + crate::yewdux::log::error!("Error saving state to storage: {:?}", err); + } + } +} + +fn get_storage(area: Area) -> Result { + let window = web_sys::window().ok_or(StorageError::WindowNotFound)?; + let storage = match area { + Area::Local => window.local_storage(), + Area::Session => window.session_storage(), + }; + + storage + .map_err(StorageError::WebSys)? + .ok_or(StorageError::StorageAccess(area)) +} + +/// Save state to session or local storage. +pub fn save(state: &T, area: Area) -> Result<(), StorageError> { + let storage = get_storage(area)?; + + let value = &serde_json::to_string(state).map_err(StorageError::Serde)?; + storage + .set(type_name::(), value) + .map_err(StorageError::WebSys)?; + + Ok(()) +} + +/// Load state from session or local storage. +pub fn load(area: Area) -> Result, StorageError> { + let storage = get_storage(area)?; + + let value = storage + .get(type_name::()) + .map_err(StorageError::WebSys)?; + + match value { + Some(value) => { + let state = serde_json::from_str(&value).map_err(StorageError::Serde)?; + + Ok(Some(state)) + } + None => Ok(None), + } +} + +/// Synchronize state across all tabs. **WARNING**: This provides no protection for multiple +/// calls. Doing so will result in repeated loading. Using the macro is advised. +pub fn init_tab_sync( + area: Area, + cx: &Context, +) -> Result<(), StorageError> { + let cx = cx.clone(); + let closure = Closure::wrap(Box::new(move |_: &Event| match load(area) { + Ok(Some(state)) => { + Dispatch::::new(&cx).set(state); + } + Err(e) => { + crate::yewdux::log::error!("Unable to load state: {:?}", e); + } + _ => {} + }) as Box); + + web_sys::window() + .ok_or(StorageError::WindowNotFound)? + .add_event_listener_with_callback("storage", closure.as_ref().unchecked_ref()) + .map_err(StorageError::WebSys)?; + + closure.forget(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::yewdux::*; + use std::rc::Rc; + + use serde::Deserialize; + + #[derive(Deserialize)] + struct TestStore; + impl Store for TestStore { + fn new(_cx: &Context) -> Self { + Self + } + + fn should_notify(&self, _old: &Self) -> bool { + true + } + } + + #[test] + fn tab_sync() { + init_tab_sync::(Area::Local, &Context::global()).unwrap(); + } +} diff --git a/src/yewdux/store.rs b/src/yewdux/store.rs new file mode 100644 index 0000000..bd5a519 --- /dev/null +++ b/src/yewdux/store.rs @@ -0,0 +1,71 @@ +//! Unique state shared application-wide +use std::rc::Rc; + +pub use yewdux_middleware_macros::Store; + +use crate::yewdux::Context; + +/// A type that holds application state. +pub trait Store: 'static { + /// Create this store. + fn new(cx: &Context) -> Self; + + /// Indicate whether or not subscribers should be notified about this change. Usually this + /// should be set to `self != old`. + fn should_notify(&self, old: &Self) -> bool; +} + +/// A type that can change state. +/// +/// ``` +/// use std::rc::Rc; +/// +/// use yew::prelude::*; +/// use yewdux::prelude::*; +/// +/// #[derive(Default, Clone, PartialEq, Eq, Store)] +/// struct Counter { +/// count: u32, +/// } +/// +/// enum Msg { +/// AddOne, +/// } +/// +/// impl Reducer for Msg { +/// fn apply(self, mut counter: Rc) -> Rc { +/// let state = Rc::make_mut(&mut counter); +/// match self { +/// Msg::AddOne => state.count += 1, +/// }; +/// +/// counter +/// } +/// } +/// +/// #[function_component] +/// fn App() -> Html { +/// let (counter, dispatch) = use_store::(); +/// let onclick = dispatch.apply_callback(|_| Msg::AddOne); +/// +/// html! { +/// <> +///

{ counter.count }

+/// +/// +/// } +/// } +/// ``` +pub trait Reducer { + /// Mutate state. + fn apply(self, state: Rc) -> Rc; +} + +impl Reducer for F +where + F: FnOnce(Rc) -> Rc, +{ + fn apply(self, state: Rc) -> Rc { + self(state) + } +} diff --git a/src/yewdux/subscriber.rs b/src/yewdux/subscriber.rs new file mode 100644 index 0000000..19eed83 --- /dev/null +++ b/src/yewdux/subscriber.rs @@ -0,0 +1,217 @@ +use std::rc::Rc; +use std::{any::Any, marker::PhantomData}; + +use slab::Slab; +use yew::Callback; + +use crate::yewdux::{mrc::Mrc, store::Store, Context}; + +pub(crate) struct Subscribers(pub(crate) Slab>>); + +impl Store for Subscribers { + fn new(_cx: &Context) -> Self { + Self(Default::default()) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } +} + +impl Mrc> { + pub(crate) fn subscribe>(&self, on_change: C) -> SubscriberId { + let key = self.borrow_mut().0.insert(Box::new(on_change)); + SubscriberId { + subscribers_ref: self.clone(), + key, + _store_type: Default::default(), + } + } + + pub(crate) fn unsubscribe(&mut self, key: usize) { + self.borrow_mut().0.remove(key); + } + + pub(crate) fn notify(&self, state: Rc) { + for (_, subscriber) in &self.borrow().0 { + subscriber.call(Rc::clone(&state)); + } + } +} + +impl PartialEq for Subscribers { + fn eq(&self, _other: &Self) -> bool { + true + } +} + +impl Default for Subscribers { + fn default() -> Self { + Self(Default::default()) + } +} + +/// Points to a subscriber in context. That subscriber is removed when this is dropped. +pub struct SubscriberId { + subscribers_ref: Mrc>, + pub(crate) key: usize, + pub(crate) _store_type: PhantomData, +} + +impl std::fmt::Debug for SubscriberId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubscriberId") + .field("key", &self.key) + .finish() + } +} + +impl SubscriberId { + /// Leak this subscription, so it is never dropped. + pub fn leak(self) { + thread_local! { + static LEAKED: Mrc>> = Default::default(); + } + + LEAKED + .try_with(|leaked| leaked.clone()) + .expect("LEAKED thread local key init failed") + .with_mut(|leaked| leaked.push(Box::new(self))); + } +} + +impl Drop for SubscriberId { + fn drop(&mut self) { + self.subscribers_ref.unsubscribe(self.key) + } +} + +pub trait Callable: 'static { + fn call(&self, value: Rc); +} + +impl) + 'static> Callable for F { + fn call(&self, value: Rc) { + self(value) + } +} + +impl Callable for Callback> { + fn call(&self, value: Rc) { + self.emit(value) + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::yewdux::*; + + use crate::yewdux::context::Context; + use crate::yewdux::dispatch::Dispatch; + use crate::yewdux::mrc::Mrc; + use crate::yewdux::subscriber::Subscribers; + + #[derive(Clone, PartialEq, Eq)] + struct TestState(u32); + impl Store for TestState { + fn new(_cx: &Context) -> Self { + Self(0) + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + + #[test] + fn subscribe_adds_to_list() { + let cx = Context::new(); + let entry = cx.get_or_init_default::>>(); + + assert!(entry.store.borrow().borrow().0.is_empty()); + + let _id = Dispatch::new(&cx).subscribe(|_: Rc| ()); + + assert!(!entry.store.borrow().borrow().0.is_empty()); + } + + #[test] + fn unsubscribe_removes_from_list() { + let cx = Context::new(); + let entry = cx.get_or_init_default::>>(); + + assert!(entry.store.borrow().borrow().0.is_empty()); + + let id = Dispatch::new(&cx).subscribe(|_: Rc| ()); + + assert!(!entry.store.borrow().borrow().0.is_empty()); + + drop(id); + + assert!(entry.store.borrow().borrow().0.is_empty()); + } + + #[test] + fn subscriber_id_unsubscribes_when_dropped() { + let cx = Context::new(); + let entry = cx.get_or_init_default::>>(); + + assert!(entry.store.borrow().borrow().0.is_empty()); + + let id = Dispatch::::new(&cx).subscribe(|_| {}); + + assert!(!entry.store.borrow().borrow().0.is_empty()); + + drop(id); + + assert!(entry.store.borrow().borrow().0.is_empty()); + } + + #[test] + fn subscriber_is_notified_on_subscribe() { + let flag = Mrc::new(false); + let cx = Context::new(); + + let _id = { + let flag = flag.clone(); + Dispatch::::new(&cx) + .subscribe(move |_| flag.clone().with_mut(|flag| *flag = true)) + }; + + assert!(*flag.borrow()); + } + + #[test] + fn subscriber_is_notified_after_leak() { + let flag = Mrc::new(false); + let cx = Context::new(); + + let id = { + let flag = flag.clone(); + cx.subscribe::(move |_| flag.clone().with_mut(|flag| *flag = true)) + }; + + *flag.borrow_mut() = false; + + id.leak(); + + cx.reduce_mut(|state: &mut TestState| state.0 += 1); + + assert!(*flag.borrow()); + } + + #[test] + fn can_modify_state_inside_on_changed() { + let cx = Context::new(); + let cxo = cx.clone(); + let dispatch = Dispatch::::new(&cx).subscribe(move |state: Rc| { + if state.0 == 0 { + Dispatch::new(&cxo).reduce_mut(|state: &mut TestState| state.0 += 1); + } + }); + + assert_eq!(dispatch.get().0, 1) + } +} diff --git a/src/yewdux/utils/mod.rs b/src/yewdux/utils/mod.rs new file mode 100644 index 0000000..273627c --- /dev/null +++ b/src/yewdux/utils/mod.rs @@ -0,0 +1,147 @@ +use crate::yewdux::{prelude::*, Context}; +use std::{marker::PhantomData, rc::Rc}; + +#[derive(Default)] +pub struct HistoryListener(PhantomData); + +struct HistoryChangeMessage(Rc); + +impl Reducer> for HistoryChangeMessage { + fn apply(self, mut state: Rc>) -> Rc> { + if state.matches_current(&self.0) { + return state; + } + + let mut_state = Rc::make_mut(&mut state); + mut_state.index += 1; + mut_state.vector.truncate(mut_state.index); + mut_state.vector.push(self.0); + + state + } +} + +impl Listener for HistoryListener { + type Store = T; + + fn on_change(&self, cx: &Context, state: Rc) { + Dispatch::>::new(cx).apply(HistoryChangeMessage::(state)) + } +} + +#[derive(Debug, PartialEq)] +pub struct HistoryStore { + vector: Vec>, + index: usize, + dispatch: Dispatch, +} + +impl Clone for HistoryStore { + fn clone(&self) -> Self { + Self { + vector: self.vector.clone(), + index: self.index, + dispatch: self.dispatch.clone(), + } + } +} + +impl HistoryStore { + pub fn can_apply(&self, message: &HistoryMessage) -> bool { + match message { + HistoryMessage::Undo => self.index > 0, + HistoryMessage::Redo => self.index + 1 < self.vector.len(), + HistoryMessage::Clear => self.vector.len() > 1, + HistoryMessage::JumpTo(index) => index != &self.index && index < &self.vector.len(), + } + } + + fn matches_current(&self, state: &Rc) -> bool { + let c = self.current(); + Rc::ptr_eq(c, state) + } + + fn current(&self) -> &Rc { + &self.vector[self.index] + } + + pub fn index(&self) -> usize { + self.index + } + + pub fn states(&self) -> &[Rc] { + self.vector.as_slice() + } +} + +impl Store for HistoryStore { + fn new(cx: &Context) -> Self { + let dispatch = Dispatch::::new(cx); + let s1 = dispatch.get(); + Self { + vector: vec![s1], + index: 0, + dispatch, + } + } + + fn should_notify(&self, other: &Self) -> bool { + self != other + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryMessage { + Undo, + Redo, + Clear, + JumpTo(usize), +} + +impl Reducer> for HistoryMessage { + fn apply(self, mut state: Rc>) -> Rc> { + let mut_state = Rc::make_mut(&mut state); + + let state_changed = match self { + HistoryMessage::Undo => { + if let Some(new_index) = mut_state.index.checked_sub(1) { + mut_state.index = new_index; + true + } else { + false + } + } + HistoryMessage::Redo => { + let new_index = mut_state.index + 1; + if new_index < mut_state.vector.len() { + mut_state.index = new_index; + true + } else { + false + } + } + HistoryMessage::Clear => { + let current = mut_state.vector[mut_state.index].clone(); + mut_state.vector.clear(); + mut_state.vector.push(current); + mut_state.index = 0; + false + } + HistoryMessage::JumpTo(index) => { + if index < mut_state.vector.len() { + mut_state.index = index; + + true + } else { + false + } + } + }; + + if state_changed { + mut_state.dispatch.reduce(|_| mut_state.current().clone()); + } + + state + } +} diff --git a/yewdux-middleware-macros/Cargo.toml b/yewdux-middleware-macros/Cargo.toml new file mode 100644 index 0000000..d9d2d0b --- /dev/null +++ b/yewdux-middleware-macros/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "yewdux-middleware-macros" +version = "0.11.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Macros for yewdux (embedded in yewdux-middleware)" + +[lib] +proc-macro = true + +[dependencies] +darling = "0.20" +proc-macro-error = "1.0.4" +proc-macro2 = "1.0.36" +quote = "1.0.14" +syn = "2" diff --git a/yewdux-middleware-macros/src/lib.rs b/yewdux-middleware-macros/src/lib.rs new file mode 100644 index 0000000..eebfb49 --- /dev/null +++ b/yewdux-middleware-macros/src/lib.rs @@ -0,0 +1,12 @@ +use proc_macro::TokenStream; +use proc_macro_error::proc_macro_error; +use syn::{parse_macro_input, DeriveInput}; + +mod store; + +#[proc_macro_derive(Store, attributes(store))] +#[proc_macro_error] +pub fn store(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + store::derive(input).into() +} diff --git a/yewdux-middleware-macros/src/store.rs b/yewdux-middleware-macros/src/store.rs new file mode 100644 index 0000000..605a511 --- /dev/null +++ b/yewdux-middleware-macros/src/store.rs @@ -0,0 +1,127 @@ +use darling::{util::PathList, FromDeriveInput}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::DeriveInput; + +#[derive(FromDeriveInput, Default)] +#[darling(default, attributes(store))] +struct Opts { + storage: Option, + storage_tab_sync: bool, + listener: PathList, + derived_from: PathList, + derived_from_mut: PathList, +} + +pub(crate) fn derive(input: DeriveInput) -> TokenStream { + let opts = Opts::from_derive_input(&input).expect("Invalid options"); + let ident = input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let extra_listeners: Vec<_> = opts + .listener + .iter() + .map(|path| { + quote! { + ::yewdux::listener::init_listener( + || #path, cx + ); + } + }) + .collect(); + + let derived_from_init: Vec<_> = opts + .derived_from + .iter() + .map(|source_type| { + quote! { + cx.derived_from::<#source_type, Self>(); + } + }) + .collect(); + + let derived_from_mut_init: Vec<_> = opts + .derived_from_mut + .iter() + .map(|source_type| { + quote! { + cx.derived_from_mut::<#source_type, Self>(); + } + }) + .collect(); + + let impl_ = match opts.storage { + Some(storage) => { + let area = match storage.as_ref() { + "local" => quote! { ::yewdux::storage::Area::Local }, + "session" => quote! { ::yewdux::storage::Area::Session }, + x => panic!( + "'{}' is not a valid option. Must be 'local' or 'session'.", + x + ), + }; + + let sync = if opts.storage_tab_sync { + quote! { + if let Err(err) = ::yewdux::storage::init_tab_sync::(#area, cx) { + ::yewdux::log::error!("Unable to init tab sync for storage: {:?}", err); + } + } + } else { + quote!() + }; + + quote! { + #[cfg(target_arch = "wasm32")] + fn new(cx: &::yewdux::Context) -> Self { + ::yewdux::listener::init_listener( + || ::yewdux::storage::StorageListener::::new(#area), + cx + ); + #(#extra_listeners)* + #(#derived_from_init)* + #(#derived_from_mut_init)* + + #sync + + match ::yewdux::storage::load(#area) { + Ok(val) => val.unwrap_or_default(), + Err(err) => { + ::yewdux::log::error!("Error loading state from storage: {:?}", err); + + Default::default() + } + } + + } + + #[cfg(not(target_arch = "wasm32"))] + fn new(cx: &::yewdux::Context) -> Self { + #(#extra_listeners)* + #(#derived_from_init)* + #(#derived_from_mut_init)* + Default::default() + } + } + } + None => quote! { + fn new(cx: &::yewdux::Context) -> Self { + #(#extra_listeners)* + #(#derived_from_init)* + #(#derived_from_mut_init)* + Default::default() + } + }, + }; + + quote! { + #[automatically_derived] + impl #impl_generics ::yewdux::store::Store for #ident #ty_generics #where_clause { + #impl_ + + fn should_notify(&self, other: &Self) -> bool { + self != other + } + } + } +}