Skip to content

Conversation

@aritracodes-69
Copy link

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

SignalList maintains separate dependency tracking for:

  • Each individual list index
  • The list length
  • Operations are optimized to only invalidate affected subscribers

Key Features

  • Push/extend: Only invalidates length readers, not element readers
  • Element modification: Only invalidates readers of that specific index
  • Pop: Invalidates the removed element and length readers
  • Clear: Invalidates all readers (expected)
  • ⚠️ Insert/remove: Invalidates affected index onwards (due to index shifting)

Example Usage

use natrix::prelude::*;

let mut list = SignalList::new();
list.push(1);
list.push(2);
list.push(3);

// Reading list[0] only subscribes to changes at index 0
let first = list.get(0);

// Pushing doesn't invalidate element readers - only length readers
list.push(4);

// Modifying an element only notifies readers of that specific index
list[1] = 20; // Only readers of index 1 are notified

API

  • new(), with_capacity() - Construction
  • len(), is_empty() - Length queries (tracked)
  • push(), pop() - Add/remove from end
  • get(), get_mut() - Element access (tracked per-index)
  • insert(), remove() - Index manipulation
  • clear() - Remove all elements
  • extend() - Add multiple elements
  • iter(), iter_mut() - Iteration (not tracked)
  • Index operators list[i] - Tracked access

Testing

Comprehensive test suite verifies:

  • ✅ Push doesn't invalidate element readers
  • ✅ Element modifications are isolated to that index
  • ✅ Pop invalidates correctly (last element + length)
  • ✅ Clear invalidates all readers
  • ✅ Extend only affects length readers
  • ✅ Index operators work with tracking

Additional Changes

  • Fixed unstable let chains in mount.rs and signal.rs for stable Rust compatibility

Closes #30

- 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
@aritracodes-69
Copy link
Author

Screenshot from 2025-10-16 11-42-08 Tested

Copy link
Collaborator

@vivax3794 vivax3794 left a 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.

Comment on lines -150 to +157
#[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");
}
Copy link
Collaborator

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?

Copy link
Author

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.

Copy link
Collaborator

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/

Comment on lines -47 to +51
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}");
}
}
Copy link
Collaborator

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?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

Comment on lines +96 to +97
if first_use {
if let Err(err) = std::fs::remove_dir_all(&output_directory) {
Copy link
Collaborator

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?

Copy link
Author

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.

Comment on lines +135 to +146
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()
});
}
Copy link
Collaborator

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.

Suggested change
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()
});

Comment on lines -92 to +97
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) {
Copy link
Collaborator

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?

Copy link
Author

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.

Comment on lines +261 to +276
// 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");
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

Comment on lines +395 to +400
impl<T: 'static> State for SignalList<T> {
fn set(&mut self, new: Self) {
// Clear and rebuild
*self = new;
}
}
Copy link
Collaborator

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

Comment on lines +159 to +161
if index >= self.items.len() {
return None;
}
Copy link
Collaborator

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.

Comment on lines +182 to +184
if index >= self.items.len() {
return None;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Comment on lines +352 to +371
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}")
})
}
}
Copy link
Collaborator

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.

@vivax3794
Copy link
Collaborator

hey @aritracodes-69 you still working on this, or should I take over and fix up the pr?

@aritracodes-69
Copy link
Author

hey @aritracodes-69 you still working on this, or should I take over and fix up the pr?

You can take over it :)

@vivax3794 vivax3794 self-assigned this Oct 28, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SignalList

2 participants