diff --git a/packages/stores/src/scope.rs b/packages/stores/src/scope.rs index d0ceb15ed..509051897 100644 --- a/packages/stores/src/scope.rs +++ b/packages/stores/src/scope.rs @@ -151,7 +151,7 @@ impl SelectorScope { /// Track this scope recursively. pub fn track(&self) { - self.store.track_recursive(&self.path); + self.store.track_deep(&self.path); } /// Mark this scope as dirty recursively. @@ -161,7 +161,7 @@ impl SelectorScope { /// Mark this scope as dirty shallowly. pub fn mark_dirty_shallow(&self) { - self.store.mark_dirty_shallow(&self.path); + self.store.mark_node_dirty(&self.path); } /// Mark this scope as dirty at and after the given index. @@ -210,7 +210,7 @@ impl Readable for SelectorScope { } fn subscribers(&self) -> Subscribers { - self.store.subscribers(&self.path) + self.store.shallow_subscribers(&self.path) } } diff --git a/packages/stores/src/subscriptions.rs b/packages/stores/src/subscriptions.rs index edad564cd..9b32a7dda 100644 --- a/packages/stores/src/subscriptions.rs +++ b/packages/stores/src/subscriptions.rs @@ -1,20 +1,16 @@ use dioxus_core::{ReactiveContext, SubscriberList, Subscribers}; use dioxus_signals::{CopyValue, ReadableExt, SyncStorage, Writable, WritableExt}; +use std::collections::HashSet; use std::fmt::Debug; use std::hash::BuildHasher; -use std::{ - collections::{HashMap, HashSet}, - hash::Hash, - ops::Deref, - sync::Arc, -}; +use std::ops::BitOrAssign; +use std::{collections::HashMap, 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. +/// and can be subscribed to and marked dirty separately. #[derive(Clone, Default)] pub(crate) struct SelectorNode { - subscribers: HashSet, + subscribers: HashMap, root: HashMap, } @@ -61,7 +57,7 @@ impl SelectorNode { } } - /// Get paths to only children before a certain index. + /// Get paths to children at and after a certain index. /// /// 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. @@ -75,7 +71,7 @@ impl SelectorNode { return; }; - // Mark the nodes before the index as dirty + // Mark the nodes at and after the index as dirty for (i, child) in node.root.iter() { if *i as usize >= index { let mut child_path: Vec = path.into(); @@ -85,19 +81,68 @@ impl SelectorNode { } } - /// Remove a path from the subscription tree - fn remove(&mut self, path: &[PathKey]) { + fn is_empty(&self) -> bool { + self.subscribers.is_empty() && self.root.is_empty() + } + + fn prune_if_empty(&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); - } + let Some(child) = self.root.get_mut(first) else { + return; + }; + child.prune_if_empty(rest); + if child.is_empty() { + self.root.remove(first); } } + + fn add_subscriber(&mut self, reactive_context: ReactiveContext, depth: SubscriptionDepth) { + self.subscribers + .entry(reactive_context) + .and_modify(|existing_depth| { + *existing_depth |= depth; + }) + .or_insert(depth); + } + + fn remove_subscriber(&mut self, reactive_context: &ReactiveContext) { + self.subscribers.remove(reactive_context); + } + + // ReactiveContext uses stable pointer/id-based Eq+Hash, so it is safe as a map key here. + #[allow(clippy::mutable_key_type)] + fn take_subscribers(&mut self) -> HashMap { + std::mem::take(&mut self.subscribers) + } + + // ReactiveContext uses stable pointer/id-based Eq+Hash, so it is safe as a map key here. + #[allow(clippy::mutable_key_type)] + fn restore_subscribers(&mut self, subscribers: HashMap) { + self.subscribers.extend(subscribers); + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SubscriptionDepth { + Shallow, + Deep, +} + +impl BitOrAssign for SubscriptionDepth { + fn bitor_assign(&mut self, rhs: Self) { + match (*self, rhs) { + (SubscriptionDepth::Shallow, SubscriptionDepth::Shallow) => {} + _ => *self = SubscriptionDepth::Deep, + } + } +} + +impl SubscriptionDepth { + fn includes_deep(self) -> bool { + matches!(self, Self::Deep) + } } pub(crate) type PathKey = u16; @@ -134,6 +179,14 @@ impl TinyVec { } } + pub(crate) fn from_slice(path: &[PathKey]) -> Self { + let mut out = Self::new(); + for key in path { + out.push(*key); + } + out + } + pub(crate) const fn push(&mut self, index: u16) { if self.length < self.path.len() { self.path[self.length] = index; @@ -194,77 +247,68 @@ impl StoreSubscriptions { (self.inner.write_unchecked().hasher.hash_one(index) % PathKey::MAX as u64) as PathKey } - /// Subscribe to a specific path in the store. + /// Subscribe shallowly to a specific path in the store. + /// + /// Shallow subscriptions rerun only when this exact path is written to. pub(crate) fn track(&self, key: &[PathKey]) { if let Some(rc) = ReactiveContext::current() { - let subscribers = self.subscribers(key); + let subscribers = self.shallow_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]) { + /// Subscribe deeply to a specific path in the store. + /// + /// Deep subscriptions rerun when this path or any descendant path is written to. + pub(crate) fn track_deep(&self, key: &[PathKey]) { if let Some(rc) = ReactiveContext::current() { - let mut paths = Vec::new(); - { - let mut write = self.inner.write_unchecked(); + let subscribers = self.deep_subscribers(key); + rc.subscribe(subscribers); + } + } - 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); + /// Mark the written node and its descendants as dirty. + /// + /// Deep subscribers on ancestor nodes are also notified because child writes + /// change the value observed by a deep read of the parent. + pub(crate) fn mark_dirty(&self, key: &[PathKey]) { + self.mark_node_dirty(key); + let descendant_paths = { + let read = &self.inner.read_unchecked(); + let mut paths = Vec::new(); + if let Some(node) = read.root.get(key) { + for (child_key, child_node) in &node.root { + let mut child_path: Vec = key.into(); + child_path.push(*child_key); + child_node.paths_under(&child_path, &mut paths); } } - for path in paths { - let subscribers = self.subscribers(&path); - rc.subscribe(subscribers); - } - } - } - - /// Mark the node and all its children as dirty - pub(crate) fn mark_dirty(&self, key: &[PathKey]) { - let paths = { - let read = &self.inner.read_unchecked(); - let Some(node) = read.root.get(key) else { - return; - }; - let mut paths = Vec::new(); - node.paths_under(key, &mut paths); paths }; - for path in paths { - self.mark_dirty_shallow(&path); + for path in descendant_paths { + self.mark_node_subscribers_dirty(&path); } } - /// Mark a single node as dirty - pub(crate) fn mark_dirty_shallow(&self, key: &[PathKey]) { - // 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 = { - let mut write = self.inner.write_unchecked(); - let Some(node) = write.root.get_mut(key) else { - return; - }; - std::mem::take(&mut node.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 - let mut write = self.inner.write_unchecked(); - let Some(node) = write.root.get_mut(key) else { - return; - }; - node.subscribers.extend(subscribers); + /// Mark all subscribers on a single node as dirty. + /// + /// Deep subscribers on ancestors are also notified because an exact write to + /// this path changes any deep read of its parents. + pub(crate) fn mark_node_dirty(&self, key: &[PathKey]) { + for i in 0..key.len() { + self.mark_ancestor_deep_subscribers_dirty(&key[..i]); + } + self.mark_node_subscribers_dirty(key); } - /// Mark all nodes after the index and their children as dirty + /// Mark only deep subscribers for a single ancestor node as dirty. + fn mark_ancestor_deep_subscribers_dirty(&self, key: &[PathKey]) { + self.retain_subscribers(key, |reactive_context, depth| { + !depth.includes_deep() || reactive_context.mark_dirty() + }); + } + + /// Mark all nodes at and after the index and their children as dirty. pub(crate) fn mark_dirty_at_and_after_index(&self, key: &[PathKey], index: usize) { let paths = { let read = self.inner.read_unchecked(); @@ -273,25 +317,76 @@ impl StoreSubscriptions { paths }; for path in paths { - self.mark_dirty_shallow(&path); + self.mark_node_dirty(&path); } } - /// 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 { + /// Get the shallow subscribers for a specific path in the store. + pub(crate) fn shallow_subscribers(&self, key: &[PathKey]) -> Subscribers { Arc::new(StoreSubscribers { subscriptions: *self, - path: key.to_vec().into_boxed_slice(), + path: TinyVec::from_slice(key), + depth: SubscriptionDepth::Shallow, }) .into() } + + /// Get the deep subscribers for a specific path in the store. + pub(crate) fn deep_subscribers(&self, key: &[PathKey]) -> Subscribers { + Arc::new(StoreSubscribers { + subscriptions: *self, + path: TinyVec::from_slice(key), + depth: SubscriptionDepth::Deep, + }) + .into() + } + + fn retain_subscribers( + &self, + key: &[PathKey], + mut retain: impl FnMut(&ReactiveContext, SubscriptionDepth) -> bool, + ) { + // 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. + // ReactiveContext uses stable pointer/id-based Eq+Hash, so it is safe as a map key here. + #[allow(clippy::mutable_key_type)] + let mut subscribers = { + let mut write = self.inner.write_unchecked(); + let Some(node) = write.root.get_mut(key) else { + return; + }; + node.take_subscribers() + }; + subscribers.retain(|reactive_context, depth| retain(reactive_context, *depth)); + + // Extend the subscribers list instead of overwriting it in case a + // subscriber is added while reactive contexts are marked dirty. + let mut write = self.inner.write_unchecked(); + let Some(node) = write.root.get_mut(key) else { + return; + }; + node.restore_subscribers(subscribers); + } + + fn mark_node_subscribers_dirty(&self, key: &[PathKey]) { + self.retain_subscribers(key, |reactive_context, _| reactive_context.mark_dirty()); + } } /// A subscriber list implementation that handles garbage collection of the subscription tree. struct StoreSubscribers { subscriptions: StoreSubscriptions, - path: Box<[PathKey]>, + path: TinyVec, + depth: SubscriptionDepth, +} + +impl StoreSubscribers { + fn visible_prefixes(&self) -> impl Iterator { + (0..=self.path.len()) + .rev() + .map(move |len| &self.path[..len]) + } } impl SubscriberList for StoreSubscribers { @@ -301,7 +396,7 @@ impl SubscriberList for StoreSubscribers { return; }; let node = write.root.get_mut_or_default(&self.path); - node.subscribers.insert(subscriber); + node.add_subscriber(subscriber, self.depth); } /// Remove a subscriber from the subscription list for this path in the store. If the node has no subscribers left @@ -310,12 +405,23 @@ impl SubscriberList for StoreSubscribers { 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); + let mut empty_prefixes = Vec::new(); + for prefix in self.visible_prefixes() { + let Some(node) = write.root.get_mut(prefix) else { + continue; + }; + let Some(depth) = node.subscribers.get(subscriber).copied() else { + continue; + }; + if prefix.len() == self.path.len() || depth.includes_deep() { + node.remove_subscriber(subscriber); + if node.is_empty() { + empty_prefixes.push(prefix.to_vec()); + } + } + } + for prefix in empty_prefixes { + write.root.prune_if_empty(&prefix); } } @@ -324,9 +430,72 @@ impl SubscriberList for StoreSubscribers { 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); + // ReactiveContext uses stable pointer/id-based Eq+Hash, so it is safe in this set. + #[allow(clippy::mutable_key_type)] + let mut seen = HashSet::new(); + for prefix in self.visible_prefixes() { + let Some(node) = read.root.get(prefix) else { + continue; + }; + for (reactive_context, depth) in &node.subscribers { + if (prefix.len() == self.path.len() || depth.includes_deep()) + && seen.insert(*reactive_context) + { + f(reactive_context); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dioxus_core::{ScopeId, VNode, VirtualDom}; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + fn empty_app() -> dioxus_core::Element { + VNode::empty() + } + + #[track_caller] + fn counting_context(counter: Arc) -> ReactiveContext { + ReactiveContext::new_with_callback( + move || { + counter.fetch_add(1, Ordering::Relaxed); + }, + ScopeId::ROOT, + std::panic::Location::caller(), + ) + } + + #[test] + fn mark_dirty_marks_descendants_without_remarking_ancestors() { + let dom = VirtualDom::new(empty_app); + + dom.in_scope(ScopeId::ROOT, || { + let subscriptions = StoreSubscriptions::new(); + + let root_count = Arc::new(AtomicUsize::new(0)); + let root = counting_context(root_count.clone()); + root.subscribe(subscriptions.deep_subscribers(&[])); + + let child_count = Arc::new(AtomicUsize::new(0)); + let child = counting_context(child_count.clone()); + child.subscribe(subscriptions.deep_subscribers(&[1, 2])); + + let leaf_count = Arc::new(AtomicUsize::new(0)); + let leaf = counting_context(leaf_count.clone()); + leaf.subscribe(subscriptions.shallow_subscribers(&[1, 2, 3])); + + subscriptions.mark_dirty(&[1]); + + assert_eq!(root_count.load(Ordering::Relaxed), 1); + assert_eq!(child_count.load(Ordering::Relaxed), 1); + assert_eq!(leaf_count.load(Ordering::Relaxed), 1); + }); } } diff --git a/packages/stores/tests/store.rs b/packages/stores/tests/store.rs new file mode 100644 index 000000000..75e390859 --- /dev/null +++ b/packages/stores/tests/store.rs @@ -0,0 +1,483 @@ +use dioxus::prelude::*; +use dioxus_core::Mutation::*; +use dioxus_core::*; +use dioxus_stores::GlobalStore; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Store, std::fmt::Debug, Clone, Default)] +struct X { + inner1: i32, + inner2: i32, +} + +#[derive(Store, std::fmt::Debug, Clone, Default)] +struct Y { + outer: X, + other: i32, +} + +#[derive(Store, std::fmt::Debug, Clone, Default)] +struct Z { + items: Vec, + other: i32, +} + +#[derive(Store, std::fmt::Debug, Clone, Default)] +struct W { + nested: Y, + other: i32, +} + +#[test] +fn children_see_parent_write() { + fn default_x() -> X { + X::default() + } + + static STORE: GlobalStore = Global::new(default_x); + + fn app() -> Element { + let x = STORE.resolve(); + let inner1 = x.inner1(); + rsx! { + "x = {inner1}" + } + } + + let mut dom = VirtualDom::new(app); + dom.rebuild_in_place(); + + dom.in_scope(ScopeId::APP, || { + STORE.resolve().set(X { + inner1: 1, + inner2: 0, + }); + }); + + let edits = dom.render_immediate_to_vec(); + assert_eq!( + edits.edits, + [SetText { + value: "x = 1".into(), + id: ElementId(1) + }] + ); +} + +// https://github.com/DioxusLabs/dioxus/issues/5363 +// When a store is written through a child lens, readers of the root store +// must be notified. A child node's subscriber visit must surface deep +// subscriptions from ancestor nodes. +#[test] +fn parents_see_child_write() { + fn default_x() -> X { + X::default() + } + + static STORE: GlobalStore = Global::new(default_x); + + #[derive(Default, PartialEq)] + struct RunCounter { + root: usize, + inner1: usize, + inner2: usize, + } + + #[component] + fn inner1_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().inner1 += 1; + rsx! { + "inner1 = {value}" + } + } + + #[component] + fn inner2_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().inner2 += 1; + rsx! { + "inner2 = {value}" + } + } + + #[component] + fn root_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().root += 1; + rsx! { + "x = {value:?}" + } + } + + let counter = Rc::new(RefCell::new(RunCounter::default())); + let mut dom = VirtualDom::new_with_props( + |counter: Rc>| { + let x = STORE.resolve(); + rsx! { + inner1_reader { value: x.inner1(), counter: counter.clone() } + inner2_reader { value: x.inner2(), counter: counter.clone() } + root_reader { value: x, counter: counter.clone() } + } + }, + counter.clone(), + ); + dom.rebuild_in_place(); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.root, 1); + assert_eq!(current_counter.inner1, 1); + assert_eq!(current_counter.inner2, 1); + } + + dom.in_scope(ScopeId::APP, || { + STORE.resolve().inner1().set(1); + }); + + dom.render_immediate(&mut NoOpMutations); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.root, 2); + assert_eq!(current_counter.inner1, 2); + assert_eq!(current_counter.inner2, 1); + } +} + +#[test] +fn deep_root_reader_sees_future_child_write() { + fn default_x() -> X { + X::default() + } + + static STORE: GlobalStore = Global::new(default_x); + + let runs = Rc::new(RefCell::new(0)); + let mut dom = VirtualDom::new_with_props( + |runs: Rc>| { + *runs.borrow_mut() += 1; + let x = STORE.resolve(); + rsx! { + "x = {x:?}" + } + }, + runs.clone(), + ); + dom.rebuild_in_place(); + + assert_eq!(*runs.borrow(), 1); + + dom.in_scope(ScopeId::APP, || { + STORE.resolve().inner1().set(1); + }); + + dom.render_immediate(&mut NoOpMutations); + + assert_eq!(*runs.borrow(), 2); +} + +#[test] +fn boxed_child_subscribers_remove_visited_parent_deep_subscriber() { + fn default_x() -> X { + X::default() + } + + static STORE: GlobalStore = Global::new(default_x); + + fn app() -> Element { + let x = STORE.resolve(); + rsx! { + "x = {x:?}" + } + } + + let mut dom = VirtualDom::new(app); + dom.rebuild_in_place(); + + dom.in_scope(ScopeId::APP, || { + let boxed_child = ReadSignal::new(STORE.resolve().inner1()); + let subscribers = boxed_child.subscribers(); + + let mut visited = Vec::new(); + subscribers.visit(|subscriber| visited.push(*subscriber)); + assert_eq!( + visited.len(), + 1, + "expected exactly one visited parent deep subscriber, got {:?}", + visited.len() + ); + + subscribers.remove(&visited[0]); + + let mut after_remove = Vec::new(); + subscribers.visit(|subscriber| after_remove.push(*subscriber)); + assert!( + after_remove.is_empty(), + "removing through boxed child subscribers should remove visited subscribers, got {} left", + after_remove.len() + ); + }); +} + +#[test] +fn deep_parent_reader_sees_grandchild_write() { + fn default_y() -> Y { + Y::default() + } + + static STORE: GlobalStore = Global::new(default_y); + + #[derive(Default, PartialEq)] + struct RunCounter { + outer: usize, + other: usize, + } + + #[component] + fn outer_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().outer += 1; + rsx! { + "outer = {value:?}" + } + } + + #[component] + fn other_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().other += 1; + rsx! { + "other = {value}" + } + } + + let counter = Rc::new(RefCell::new(RunCounter::default())); + let mut dom = VirtualDom::new_with_props( + |counter: Rc>| { + let y = STORE.resolve(); + rsx! { + outer_reader { value: y.outer(), counter: counter.clone() } + other_reader { value: y.other(), counter: counter.clone() } + } + }, + counter.clone(), + ); + dom.rebuild_in_place(); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.outer, 1); + assert_eq!(current_counter.other, 1); + } + + dom.in_scope(ScopeId::APP, || { + STORE.resolve().outer().inner1().set(1); + }); + + dom.render_immediate(&mut NoOpMutations); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.outer, 2); + assert_eq!(current_counter.other, 1); + } +} + +#[test] +fn deep_grandparent_reader_sees_descendant_write() { + fn default_w() -> W { + W::default() + } + + static STORE: GlobalStore = Global::new(default_w); + + #[derive(Default, PartialEq)] + struct RunCounter { + nested: usize, + other: usize, + } + + #[component] + fn grandparent_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().nested += 1; + rsx! { + "nested = {value:?}" + } + } + + #[component] + fn other_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().other += 1; + rsx! { + "other = {value}" + } + } + + let counter = Rc::new(RefCell::new(RunCounter::default())); + let mut dom = VirtualDom::new_with_props( + |counter: Rc>| { + let w = STORE.resolve(); + rsx! { + grandparent_reader { value: w.nested(), counter: counter.clone() } + other_reader { value: w.other(), counter: counter.clone() } + } + }, + counter.clone(), + ); + dom.rebuild_in_place(); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.nested, 1); + assert_eq!(current_counter.other, 1); + } + + dom.in_scope(ScopeId::APP, || { + STORE.resolve().nested().outer().inner1().set(1); + }); + + dom.render_immediate(&mut NoOpMutations); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.nested, 2); + assert_eq!(current_counter.other, 1); + } +} + +#[test] +fn len_is_shallow_but_push_marks_it_dirty() { + fn initial_items() -> Vec { + vec![1, 2, 3] + } + + static STORE: GlobalStore> = Global::new(initial_items); + + #[derive(Default, PartialEq)] + struct RunCounter { + len: usize, + first: usize, + } + + #[component] + fn len_reader(value: Store>, counter: Rc>) -> Element { + counter.borrow_mut().len += 1; + rsx! { + "len = {value.len()}" + } + } + + #[component] + fn first_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().first += 1; + rsx! { + "first = {value}" + } + } + + let counter = Rc::new(RefCell::new(RunCounter::default())); + let mut dom = VirtualDom::new_with_props( + |counter: Rc>| { + let items = STORE.resolve(); + rsx! { + len_reader { value: items, counter: counter.clone() } + first_reader { value: items.index(0), counter: counter.clone() } + } + }, + counter.clone(), + ); + dom.rebuild_in_place(); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.len, 1); + assert_eq!(current_counter.first, 1); + } + + dom.in_scope(ScopeId::APP, || { + STORE.resolve().index(0).set(10); + }); + + dom.render_immediate(&mut NoOpMutations); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.len, 1); + assert_eq!(current_counter.first, 2); + } + + dom.in_scope(ScopeId::APP, || { + let mut items = STORE.resolve(); + items.push(4); + }); + + dom.render_immediate(&mut NoOpMutations); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.len, 2); + assert_eq!(current_counter.first, 2); + } +} + +#[test] +fn deep_parent_reader_sees_nested_vec_push() { + fn initial_z() -> Z { + Z { + items: vec![1, 2, 3], + other: 0, + } + } + + static STORE: GlobalStore = Global::new(initial_z); + + #[derive(Default, PartialEq)] + struct RunCounter { + collection: usize, + other: usize, + } + + #[component] + fn collection_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().collection += 1; + rsx! { + "z = {value:?}" + } + } + + #[component] + fn other_reader(value: Store, counter: Rc>) -> Element { + counter.borrow_mut().other += 1; + rsx! { + "other = {value}" + } + } + + let counter = Rc::new(RefCell::new(RunCounter::default())); + let mut dom = VirtualDom::new_with_props( + |counter: Rc>| { + let z = STORE.resolve(); + rsx! { + collection_reader { value: z, counter: counter.clone() } + other_reader { value: z.other(), counter: counter.clone() } + } + }, + counter.clone(), + ); + dom.rebuild_in_place(); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.collection, 1); + assert_eq!(current_counter.other, 1); + } + + dom.in_scope(ScopeId::APP, || { + let mut items = STORE.resolve().items(); + items.push(4); + }); + + dom.render_immediate(&mut NoOpMutations); + + { + let current_counter = counter.borrow(); + assert_eq!(current_counter.collection, 2); + assert_eq!(current_counter.other, 1); + } +}