From cec0fb8d85f6736f4d5b0ac8f9b5a7906f316b7c Mon Sep 17 00:00:00 2001 From: Greg Johnston Date: Fri, 19 Dec 2025 11:40:44 -0500 Subject: [PATCH 1/2] chore: add regression test to make sure untracked writes on store fields don't notify anything --- reactive_stores/src/lib.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/reactive_stores/src/lib.rs b/reactive_stores/src/lib.rs index 9c0d8b824..7acf7bb75 100644 --- a/reactive_stores/src/lib.rs +++ b/reactive_stores/src/lib.rs @@ -833,7 +833,7 @@ mod tests { use reactive_graph::{ effect::Effect, owner::StoredValue, - traits::{Read, ReadUntracked, Set, Update, Write}, + traits::{Read, ReadUntracked, Set, Track, Update, Write}, }; use std::sync::{ atomic::{AtomicUsize, Ordering}, @@ -1375,4 +1375,34 @@ mod tests { assert_eq!(combined_count.load(Ordering::Relaxed), 3); } + + #[tokio::test] + async fn untracked_write_on_subfield_shouldnt_notify() { + _ = any_spawner::Executor::init_tokio(); + + let name_count = Arc::new(AtomicUsize::new(0)); + + let store = Store::new(data()); + + let tracked_field = store.user(); + + Effect::new_sync({ + let name_count = Arc::clone(&name_count); + move |_| { + tracked_field.track(); + name_count.fetch_add(1, Ordering::Relaxed); + } + }); + + tick().await; + assert_eq!(name_count.load(Ordering::Relaxed), 1); + + tracked_field.write().push('!'); + tick().await; + assert_eq!(name_count.load(Ordering::Relaxed), 2); + + tracked_field.write_untracked().push('!'); + tick().await; + assert_eq!(name_count.load(Ordering::Relaxed), 2); + } } From 270536adb1797f15706d327e5fe5b201420b7ae1 Mon Sep 17 00:00:00 2001 From: Greg Johnston Date: Fri, 19 Dec 2025 11:41:13 -0500 Subject: [PATCH 2/2] fix: prevent untracked writes on keyed subfields from notifying parent (closes #4488) --- reactive_stores/src/keyed.rs | 46 +++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/reactive_stores/src/keyed.rs b/reactive_stores/src/keyed.rs index ea27b0484..5312bcc0d 100644 --- a/reactive_stores/src/keyed.rs +++ b/reactive_stores/src/keyed.rs @@ -176,6 +176,7 @@ where { inner: KeyedSubfield, guard: Option, + untracked: bool, } impl Deref @@ -227,6 +228,7 @@ where K: Debug + Send + Sync + PartialEq + Eq + Hash + 'static, { fn untrack(&mut self) { + self.untracked = true; if let Some(inner) = self.guard.as_mut() { inner.untrack(); } @@ -251,7 +253,10 @@ where // now that the write lock is release, we can get a read lock to refresh this keyed field // based on the new value self.inner.update_keys(); - self.inner.notify(); + + if !self.untracked { + self.inner.notify(); + } // reactive updates happen on the next tick } @@ -344,6 +349,7 @@ where Some(KeyedSubfieldWriteGuard { inner: self.clone(), guard: Some(guard), + untracked: false, }) } @@ -355,6 +361,7 @@ where Some(KeyedSubfieldWriteGuard { inner: self.clone(), guard: Some(guard), + untracked: true, }) } } @@ -879,4 +886,41 @@ mod tests { assert_eq!(b_count.load(Ordering::Relaxed), 1); assert_eq!(c_count.load(Ordering::Relaxed), 1); } + + #[tokio::test] + async fn untracked_write_on_keyed_subfield_shouldnt_notify() { + _ = any_spawner::Executor::init_tokio(); + + let store = Store::new(data()); + assert_eq!(store.read_untracked().todos.len(), 3); + + // create an effect to read from the keyed subfield + let todos_count = Arc::new(AtomicUsize::new(0)); + Effect::new_sync({ + let todos_count = Arc::clone(&todos_count); + move || { + store.todos().track(); + todos_count.fetch_add(1, Ordering::Relaxed); + } + }); + + tick().await; + assert_eq!(todos_count.load(Ordering::Relaxed), 1); + + // writing to keyed subfield notifies the iterator + store.todos().write().push(Todo { + id: 13, + label: "D".into(), + }); + tick().await; + assert_eq!(todos_count.load(Ordering::Relaxed), 2); + + // but an untracked write doesn't + store.todos().write_untracked().push(Todo { + id: 14, + label: "E".into(), + }); + tick().await; + assert_eq!(todos_count.load(Ordering::Relaxed), 2); + } }