-
Notifications
You must be signed in to change notification settings - Fork 8
Implement SignalList with fine-grained reactivity (#30) #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- Add SignalList<T> with per-index dependency tracking - Push operations only invalidate length readers - Element modifications only invalidate that specific index - Includes comprehensive test suite - Fixes unstable let chains for stable Rust compatibility Closes Serpent-Tools#30
vivax3794
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really nice work! Just a few comments.
I really do love this implementation, but thinking about it we should have something analogous to ProjectableSignal as well, like if we want a vec over State structs we want to delegate the per index tracking to the elements themself.
So honestly unsure wether that should be a dedicated type, or if we should instead just have SignalList work that way and then provide helpers for SignalVec<Signal<T>>, what do you think?
also btw, did you run clippy on this code? should have caught some of the warnings we dont allow.
| #[must_use] | ||
| pub fn project_signal(self) -> T::Projected<'s> { | ||
| if let Ref::Read(this) = &self | ||
| && let Some(hook) = core::statics::current_hook() | ||
| { | ||
| if let Ok(mut deps) = this.deps.try_borrow_mut() { | ||
| deps.insert(hook); | ||
| } else { | ||
| log_or_panic!("Deps list overlapping borrow"); | ||
| if let Ref::Read(this) = &self { | ||
| if let Some(hook) = core::statics::current_hook() { | ||
| if let Ok(mut deps) = this.deps.try_borrow_mut() { | ||
| deps.insert(hook); | ||
| } else { | ||
| log_or_panic!("Deps list overlapping borrow"); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did you modify this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These files used unstable let chains (if let x && let y) which don't compile on stable Rust. I refactored them to nested if statements for compatibility.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let chains have been stable since 1.88, https://releases.rs/docs/1.88.0/
| if cfg!(target_arch = "wasm32") | ||
| && let Err(err) = console_log::init_with_level(log::Level::Trace) | ||
| { | ||
| if cfg!(target_arch = "wasm32") { | ||
| if let Err(err) = console_log::init_with_level(log::Level::Trace) { | ||
| crate::error_handling::log_or_panic!("Failed to create logger: {err}"); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did you modify this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as above
| if first_use { | ||
| if let Err(err) = std::fs::remove_dir_all(&output_directory) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did you modify this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same reason - it had an unstable let chain at line 96 that prevented compilation on stable Rust.
| if let Some(mut last_deps) = deps.pop() { | ||
| // Invalidate both the removed element and length | ||
| core::statics::reg_dirty_list(|| last_deps.create_iter_and_clear()); | ||
| core::statics::reg_dirty_list(|| { | ||
| self.len_deps.get_mut().create_iter_and_clear() | ||
| }); | ||
| } else { | ||
| // Only invalidate length if no element deps to remove | ||
| core::statics::reg_dirty_list(|| { | ||
| self.len_deps.get_mut().create_iter_and_clear() | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You cold just move the len one to outside the condition.
| if let Some(mut last_deps) = deps.pop() { | |
| // Invalidate both the removed element and length | |
| core::statics::reg_dirty_list(|| last_deps.create_iter_and_clear()); | |
| core::statics::reg_dirty_list(|| { | |
| self.len_deps.get_mut().create_iter_and_clear() | |
| }); | |
| } else { | |
| // Only invalidate length if no element deps to remove | |
| core::statics::reg_dirty_list(|| { | |
| self.len_deps.get_mut().create_iter_and_clear() | |
| }); | |
| } | |
| if let Some(mut last_deps) = deps.pop() { | |
| core::statics::reg_dirty_list(|| last_deps.create_iter_and_clear()); | |
| } | |
| core::statics::reg_dirty_list(|| { | |
| self.len_deps.get_mut().create_iter_and_clear() | |
| }); |
| clippy::expect_used, | ||
| reason = "We should have write permission to target/" | ||
| )] | ||
| { | ||
| if first_use && let Err(err) = std::fs::remove_dir_all(&output_directory) { | ||
| clippy::expect_used, | ||
| reason = "We should have write permission to target/" | ||
| )] | ||
| { | ||
| if first_use { | ||
| if let Err(err) = std::fs::remove_dir_all(&output_directory) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why did you modify this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added SignalList to the prelude module (similar to how Signal is exported) so users can access it with use natrix::prelude::*; instead of importing it explicitly.
However, I'm happy to remove this if you prefer users to import SignalList explicitly! Just let me know your preference for the prelude export policy.
| // Remove the dep list for this index and invalidate it immediately | ||
| if let Ok(mut deps) = self.deps.try_borrow_mut() { | ||
| let mut removed_deps = deps.remove(index); | ||
| core::statics::reg_dirty_list(|| removed_deps.create_iter_and_clear()); | ||
| } else { | ||
| log_or_panic!("Deps list already borrowed during remove"); | ||
| } | ||
|
|
||
| // Invalidate readers from this index onwards | ||
| if let Ok(mut deps) = self.deps.try_borrow_mut() { | ||
| for dep_list in deps.iter_mut().skip(index) { | ||
| core::statics::reg_dirty_list(|| dep_list.create_iter_and_clear()); | ||
| } | ||
| } else { | ||
| log_or_panic!("Deps list already borrowed during remove invalidation"); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as above.
| impl<T: 'static> State for SignalList<T> { | ||
| fn set(&mut self, new: Self) { | ||
| // Clear and rebuild | ||
| *self = new; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Set should move the data from new into self, and mark the deps of self as dirty, effectively .clear + .extend
| if index >= self.items.len() { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefer using self.items.get directly instead, avoids the panic-y item indexing below.
| if index >= self.items.len() { | ||
| return None; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See above
| impl<T> Index<usize> for SignalList<T> { | ||
| type Output = T; | ||
|
|
||
| #[inline] | ||
| fn index(&self, index: usize) -> &Self::Output { | ||
| self.get(index).unwrap_or_else(|| { | ||
| panic!("Index {index} out of bounds for SignalList of length {}", self.items.len()) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| impl<T> IndexMut<usize> for SignalList<T> { | ||
| #[inline] | ||
| fn index_mut(&mut self, index: usize) -> &mut Self::Output { | ||
| let len = self.items.len(); | ||
| self.get_mut(index).unwrap_or_else(|| { | ||
| panic!("Index {index} out of bounds for SignalList of length {len}") | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These will likely need some #[expect] annotations to make clippy happy as we generally dont allow panics.
honestly one part of me kinda dont wanna expose these index apis, but I know from a ergonomics standpoint we should.
|
hey @aritracodes-69 you still working on this, or should I take over and fix up the pr? |
You can take over it :) |

Summary
Implements
SignalList<T>to provide fine-grained reactive list operations where individual element access is tracked separately, solving issue #30.Problem
Previously, operations like pushing to a list would invalidate all readers, even those reading unrelated elements at different indices. This caused unnecessary re-renders and poor performance.
Solution
SignalListmaintains separate dependency tracking for:Key Features
Example Usage
API
new(),with_capacity()- Constructionlen(),is_empty()- Length queries (tracked)push(),pop()- Add/remove from endget(),get_mut()- Element access (tracked per-index)insert(),remove()- Index manipulationclear()- Remove all elementsextend()- Add multiple elementsiter(),iter_mut()- Iteration (not tracked)list[i]- Tracked accessTesting
Comprehensive test suite verifies:
Additional Changes
letchains inmount.rsandsignal.rsfor stable Rust compatibilityCloses #30