Stores: Externally tracked state and reactive collections (#4483)

* map signals mutably

* boxed signals

* remove mutable borrow type from boxed writable

* improve map mut example

* restore boxed_mut helper

* make boxed readable/writable types copy

* Move away from the ReadOnlySignal alias in examples

* automatically convert Write

* Fix some tests

* ReadSignal and WriteSignal

* move read only signal alias

* make maps copy

* store prototype

* implement derive macro

* Handle generics in the derive macro

* Make Selector boxable

* derive boxed conversion

* Get rid of store type

* Remove hardcoded type from derive macro

* selector -> store

* Refactor store implementation

* more vec methods

* todo mvc store example

* Implement Storable for HashMap

* fix write

* restore sorting logic in todomvc

* derive readable and writable

* improve todomvc store example

* Result store

* Option store

* collect dead subscribers

* fix clippy and formatting

* recognize some common foreign types automatically

* fix foreign stores in props

* slice and array selectors

* clean up store example

* writable is child owned

* box selector

* read and write to builtins

* re-use the proc macro in the derive macro

* Switch to a extension trait based approach

* switch to extension traits in the derive macro

* transposed method

* derive enums

* Expose Store::new

* remove stores from the prelude

* More consistent naming scheme for SelectorScope methods

* document hashmap methods

* document index extension

* document option ext

* document result ext

* document slice extension

* document the vec extension

* make more methods borrow the store mutably

* Move to impls to have higher priority over the builtin write extension traits

* fix deadlock

* finish stores documentation

* Document the store macro

* cleanup docs and organization

* finish derive docs

* fix typo

* fix clippy

* fix doc tests

* fix derive macro with generics

* fix clippy

* test and fix empty structs

* clean up todomvc store example

* default to a boxed store writer to make it easier to type when providing to the context api

* fix cargo doc

* fix clippy in store example

* relax generational box lifetime bounds

* relax bounds

* relax signal bounds

* fix compilation

* read store

* relax bounds on default store impls

* fix enum derive

* Convert to readsignal and writesignal automatically

* simplify type matching logic

* add write store

* unsized stores

* shrink the path vec type

* use more concrete types

* remove opaque types

* remove copy bounds

* use lens as generic name everywhere

* more consistent borrow semantics

* use a concrete type for the signal metadata and mention references in the map docs

* improve todomvc store docs

* document store derive macro internals

* refactor macro

* improve selector node documentation

* fix clippy

* add btreemap

* fix doc tests

* more option methods

* fix doc

* fix clippy

* add a bunch more result methods

* add a bunch of bounds to the iterators

* fix clippy

* remove option from subscribers

* add store macro to generate extension traits

* fix store example

* fix deadlock

* fix docs

* fix doc tests

* fix clippy

* add stores to public api

* add store macro, and then update todomvc_store example

---------

Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
Evan AlmloffandJonathan Kelley authored and GitHub committed 2025-08-05 11:30:21 -07:00
1 parent 5591070e92
commit 58c41f8561
57 files changed
+4958 -436

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
use dioxus_core::{ReactiveContext, SubscriberList, Subscribers};
use dioxus_signals::{CopyValue, ReadableExt, SyncStorage, Writable, WritableExt};
use std::fmt::Debug;
use std::hash::BuildHasher;
use std::{
collections::{HashMap, HashSet},
hash::Hash,
ops::Deref,
sync::Arc,
};
/// A single node in the [`StoreSubscriptions`] tree. Each path is a specific view into the store
/// and can be subscribed to and marked dirty separately. If the whole store is read or written to, all
/// nodes in the subtree are subscribed to or marked as dirty.
#[derive(Clone, Default)]
pub(crate) struct SelectorNode {
subscribers: HashSet<ReactiveContext>,
root: HashMap<PathKey, SelectorNode>,
}
impl SelectorNode {
/// Get an existing selector node by its path.
fn get(&self, path: &[PathKey]) -> Option<&SelectorNode> {
let [first, rest @ ..] = path else {
return Some(self);
};
self.root.get(first).and_then(|child| child.get(rest))
}
/// Get an existing selector node by its path mutably.
fn get_mut(&mut self, path: &[PathKey]) -> Option<&mut SelectorNode> {
let [first, rest @ ..] = path else {
return Some(self);
};
self.root
.get_mut(first)
.and_then(|child| child.get_mut(rest))
}
/// Get a selector mutably or create one if it doesn't exist. This is used when subscribing to
/// a path that may not exist yet.
fn get_mut_or_default(&mut self, path: &[PathKey]) -> &mut SelectorNode {
let [first, rest @ ..] = path else {
return self;
};
self.root
.entry(*first)
.or_default()
.get_mut_or_default(rest)
}
/// Visit this node and all of its children in depth-first order, calling the provided function on each node.
///
/// This is used to mark nodes dirty recursively when a Store is written to.
fn visit_depth_first_mut(&mut self, f: &mut dyn FnMut(&mut SelectorNode)) {
f(self);
for child in self.root.values_mut() {
child.visit_depth_first_mut(f);
}
}
/// Mark this selector and all children as dirty. This should be called any time a raw mutable reference to a store
/// is exposed to the user. They could write to any level of the store, so we need to mark all nodes as dirty.
fn mark_children_dirty(&mut self, path: &[PathKey]) {
let Some(node) = self.get_mut(path) else {
return;
};
// Mark the node and all its children as dirty
node.visit_depth_first_mut(&mut |node| {
node.mark_dirty();
});
}
/// Mark only children after a certain index as dirty. This is used when inserting a new item into a list.
/// Items after the index that is inserted need to be marked dirty because the value that index points to may have changed.
fn mark_dirty_at_and_after_index(&mut self, path: &[PathKey], index: usize) {
let Some(node) = self.get_mut(path) else {
return;
};
// Mark the nodes before the index as dirty
for (i, child) in node.root.iter_mut() {
if *i as usize >= index {
child.visit_depth_first_mut(&mut |node| {
node.mark_dirty();
});
}
}
}
/// Mark a specific node as dirty without marking its children. This is used for data structures like HashMaps
/// when inserting or removing items. Inserting an item into a HashMap only changes the length of the map and the
/// specific value that was inserted or removed.
fn mark_dirty_shallow(&mut self, path: &[PathKey]) {
let Some(node) = self.get_mut(path) else {
return;
};
node.mark_dirty();
}
/// Mark this node as dirty, which will notify all subscribers that the value has changed.
fn mark_dirty(&mut self) {
// We cannot hold the subscribers lock while calling mark_dirty, because mark_dirty can run user code which may cause a new subscriber to be added. If we hold the lock, we will deadlock.
#[allow(clippy::mutable_key_type)]
let mut subscribers = std::mem::take(&mut self.subscribers);
subscribers.retain(|reactive_context| reactive_context.mark_dirty());
// Extend the subscribers list instead of overwriting it in case a subscriber is added while reactive contexts are marked dirty
self.subscribers.extend(subscribers);
}
/// Remove a path from the subscription tree
fn remove(&mut self, path: &[PathKey]) {
let [first, rest @ ..] = path else {
return;
};
if let Some(node) = self.root.get_mut(first) {
if rest.is_empty() {
self.root.remove(first);
} else {
node.remove(rest);
}
}
}
}
pub(crate) type PathKey = u16;
#[cfg(feature = "large-path")]
const PATH_LENGTH: usize = 32;
#[cfg(not(feature = "large-path"))]
const PATH_LENGTH: usize = 16;
#[derive(Copy, Clone, PartialEq)]
pub(crate) struct TinyVec {
length: usize,
path: [PathKey; PATH_LENGTH],
}
impl Default for TinyVec {
fn default() -> Self {
Self::new()
}
}
impl Debug for TinyVec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TinyVec")
.field("path", &&self.path[..self.length])
.finish()
}
}
impl TinyVec {
pub(crate) const fn new() -> Self {
Self {
length: 0,
path: [0; PATH_LENGTH],
}
}
pub(crate) const fn push(&mut self, index: u16) {
if self.length < self.path.len() {
self.path[self.length] = index;
self.length += 1;
} else {
panic!("SelectorPath is full");
}
}
}
impl Deref for TinyVec {
type Target = [u16];
fn deref(&self) -> &Self::Target {
&self.path[..self.length]
}
}
#[derive(Default)]
pub(crate) struct StoreSubscriptionsInner {
root: SelectorNode,
hasher: std::collections::hash_map::RandomState,
}
#[derive(Default)]
pub(crate) struct StoreSubscriptions {
inner: CopyValue<StoreSubscriptionsInner, SyncStorage>,
}
impl Clone for StoreSubscriptions {
fn clone(&self) -> Self {
*self
}
}
impl Copy for StoreSubscriptions {}
impl PartialEq for StoreSubscriptions {
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner
}
}
impl StoreSubscriptions {
/// Create a new instance of StoreSubscriptions.
pub(crate) fn new() -> Self {
Self {
inner: CopyValue::new_maybe_sync(StoreSubscriptionsInner {
root: SelectorNode::default(),
hasher: std::collections::hash_map::RandomState::new(),
}),
}
}
/// Hash an index into a PathKey using the hasher. The hash should be consistent
/// across calls
pub(crate) fn hash(&self, index: &impl Hash) -> PathKey {
(self.inner.write_unchecked().hasher.hash_one(index) % PathKey::MAX as u64) as PathKey
}
/// Subscribe to a specific path in the store.
pub(crate) fn track(&self, key: &[PathKey]) {
if let Some(rc) = ReactiveContext::current() {
let subscribers = self.subscribers(key);
rc.subscribe(subscribers);
}
}
/// Subscribe to a path and all of its children recursively. This should be called any time we give out
/// a raw reference to a store, because the user could read any level of the store.
pub(crate) fn track_recursive(&self, key: &[PathKey]) {
if let Some(rc) = ReactiveContext::current() {
let mut paths = Vec::new();
{
let mut write = self.inner.write_unchecked();
let root = write.root.get_mut_or_default(key);
let mut nodes = vec![(key.to_vec(), &*root)];
while let Some((path, node)) = nodes.pop() {
for (child_key, child_node) in &node.root {
let mut new_path = path.clone();
new_path.push(*child_key);
nodes.push((new_path, child_node));
}
paths.push(path);
}
}
for path in paths {
let subscribers = self.subscribers(&path);
rc.subscribe(subscribers);
}
}
}
pub(crate) fn mark_dirty(&self, key: &[PathKey]) {
self.inner.write_unchecked().root.mark_children_dirty(key);
}
pub(crate) fn mark_dirty_shallow(&self, key: &[PathKey]) {
self.inner.write_unchecked().root.mark_dirty_shallow(key);
}
pub(crate) fn mark_dirty_at_and_after_index(&self, key: &[PathKey], index: usize) {
self.inner
.write_unchecked()
.root
.mark_dirty_at_and_after_index(key, index);
}
/// Get a subscriber list for a specific path in the store. This is used to subscribe to changes
/// to a specific path in the store and remove the node from the subscription tree when it is no longer needed.
pub(crate) fn subscribers(&self, key: &[PathKey]) -> Subscribers {
Arc::new(StoreSubscribers {
subscriptions: *self,
path: key.to_vec().into_boxed_slice(),
})
.into()
}
}
/// A subscriber list implementation that handles garbage collection of the subscription tree.
struct StoreSubscribers {
subscriptions: StoreSubscriptions,
path: Box<[PathKey]>,
}
impl SubscriberList for StoreSubscribers {
/// Add a subscriber to the subscription list for this path in the store, creating the node if it doesn't exist.
fn add(&self, subscriber: ReactiveContext) {
let Ok(mut write) = self.subscriptions.inner.try_write_unchecked() else {
return;
};
let node = write.root.get_mut_or_default(&self.path);
node.subscribers.insert(subscriber);
}
/// Remove a subscriber from the subscription list for this path in the store. If the node has no subscribers left
/// remove that node from the subscription tree.
fn remove(&self, subscriber: &ReactiveContext) {
let Ok(mut write) = self.subscriptions.inner.try_write_unchecked() else {
return;
};
let Some(node) = write.root.get_mut(&self.path) else {
return;
};
node.subscribers.remove(subscriber);
if node.subscribers.is_empty() && node.root.is_empty() {
write.root.remove(&self.path);
}
}
/// Visit all subscribers for this path in the store, calling the provided function on each subscriber.
fn visit(&self, f: &mut dyn FnMut(&ReactiveContext)) {
let Ok(read) = self.subscriptions.inner.try_read() else {
return;
};
let Some(node) = read.root.get(&self.path) else {
return;
};
node.subscribers.iter().for_each(f);
}
}