Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8692371
fix unnessicary reruns after read signal coercion
ealmloff Apr 9, 2026
957f982
fix boxed memo
ealmloff Apr 9, 2026
97d61ee
more boxed tests
ealmloff Apr 9, 2026
a4d65b2
refactor: collapse ReadSignal into a single CopyValue
ealmloff May 11, 2026
f51d8be
Merge branch 'main' into fix-leaky-readsignal-subscriptions
ealmloff May 11, 2026
098b728
use oncecell, not option
ealmloff May 11, 2026
5e05279
add new_with_callback_in_owner
ealmloff May 12, 2026
bfbc451
pull out ForwardingContextState
ealmloff May 12, 2026
4a345be
remove option
ealmloff May 12, 2026
65f0cb6
pull out helpers
ealmloff May 12, 2026
e6f2020
use subscribe instead of add
ealmloff May 12, 2026
4b3fe1d
add regression test
ealmloff May 12, 2026
37a1f54
remove oncelock
ealmloff May 12, 2026
9889c03
run in forwarding context
ealmloff May 12, 2026
00629e9
clean up comments
ealmloff May 12, 2026
76ad925
tighten point_to
ealmloff May 12, 2026
1ca3c52
add some comments, remove new_with_callback_in_owner
ealmloff May 12, 2026
0119202
trim down diff
ealmloff May 12, 2026
ab4dc6d
remove sync_forwarding_to_value
ealmloff May 12, 2026
44ce5b8
use two subscribers
ealmloff May 12, 2026
72b31a9
add take_subscribers
ealmloff May 12, 2026
78eb89c
make point_to atomic
ealmloff May 12, 2026
6e0c21e
remove the ReactiveContext
ealmloff May 12, 2026
1f23197
fix tests
ealmloff May 12, 2026
81a3f8a
forwarding context
ealmloff May 13, 2026
aa3f886
add a doc example
ealmloff May 13, 2026
b905ee1
split copyvalue
ealmloff May 13, 2026
8581aef
final cleanup
ealmloff May 13, 2026
f79b192
fix reactive context owner
ealmloff May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions packages/core/src/reactive_context.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{Runtime, ScopeId, current_scope_id, scope_context::Scope, tasks::SchedulerMsg};
use futures_channel::mpsc::UnboundedReceiver;
use generational_box::{BorrowMutError, GenerationalBox, SyncStorage};
use generational_box::{BorrowMutError, GenerationalBox, Owner, SyncStorage};
use std::{
cell::RefCell,
collections::HashSet,
Expand Down Expand Up @@ -70,6 +70,20 @@ impl ReactiveContext {
pub fn new_with_callback(
callback: impl FnMut() + Send + Sync + 'static,
scope: ScopeId,
origin: &'static std::panic::Location<'static>,
) -> Self {
let owner = Runtime::current().scope_owner::<SyncStorage>(scope);
Self::new_with_callback_in_owner(callback, scope, owner, origin)
}

/// Create a new reactive context owned by the given owner.
///
/// This can be used when the reactive context is embedded in another owned value and should be
/// dropped with that value instead of with the component scope.
pub fn new_with_callback_in_owner(
callback: impl FnMut() + Send + Sync + 'static,
scope: ScopeId,
owner: Owner<SyncStorage>,
#[allow(unused)] origin: &'static std::panic::Location<'static>,
) -> Self {
let inner = Inner {
Expand All @@ -82,8 +96,6 @@ impl ReactiveContext {
scope: None,
};

let owner = Runtime::current().scope_owner(scope);

let self_ = Self {
scope,
inner: owner.insert(inner),
Expand Down Expand Up @@ -134,7 +146,17 @@ impl ReactiveContext {
pub fn clear_subscribers(&self) {
// The key type is mutable, but the hash is stable through mutations because we hash by pointer
#[allow(clippy::mutable_key_type)]
let old_subscribers = std::mem::take(&mut self.inner.write().subscribers);
let old_subscribers = match self.inner.try_write() {
Ok(mut inner) => std::mem::take(&mut inner.subscribers),
// If the context was dropped, it cannot actively unsubscribe itself;
// stale subscriber-list entries are cleaned lazily when they fail to mark dirty.
Err(BorrowMutError::Dropped(_)) => return,
Err(expect) => {
panic!(
"Expected to be able to write to reactive context to clear subscribers, but it failed with: {expect:?}"
);
}
};
for subscriber in old_subscribers {
subscriber.0.remove(self);
}
Expand Down
173 changes: 147 additions & 26 deletions packages/signals/src/boxed.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use std::{any::Any, ops::Deref};

use dioxus_core::{IntoAttributeValue, IntoDynNode, Subscribers};
use generational_box::{BorrowResult, Storage, SyncStorage, UnsyncStorage};
use dioxus_core::{
IntoAttributeValue, IntoDynNode, ReactiveContext, Subscribers, current_scope_id,
};
use generational_box::{BorrowResult, Owner, Storage, SyncStorage, UnsyncStorage};

use crate::{
CopyValue, Global, InitializeFromFunction, MappedMutSignal, MappedSignal, Memo, Readable,
Expand All @@ -15,9 +17,118 @@ use crate::{
)]
pub type ReadOnlySignal<T, S = UnsyncStorage> = ReadSignal<T, S>;

/// Wrapper subscriber state plus a reactive context that forwards updates from the current
/// readable source.
///
/// `ReadSignal` is a reactive proxy. A child component subscribes to this wrapper, and the
/// forwarding context subscribes to the readable source the wrapper currently points at. `point_to`
/// retargets only that source subscription; wrapper subscribers stay attached to this
/// `ForwardingContext`.
///
/// Running source reads under this context preserves normal `Readable` subscription behavior for
/// signals, stores, and memos. When the source changes, this context marks wrapper subscribers
/// dirty without moving or clearing direct subscriptions made outside the wrapper.
///
/// # Example
///
/// ```rust,ignore
/// fn app() -> Element {
/// let mut use_b = use_signal(|| false);
/// let signal_a = use_signal(|| 0);
/// let signal_b = use_signal(|| 0);
///
/// use_effect(move || {
/// signal_a();
/// // This effect's context subscribes directly to signal_a. It is not a
/// // wrapper-level `ReadSignal` subscriber, so retargeting a child prop
/// // must not move this subscription to signal_b.
/// });
///
/// let child_signal = if use_b() { signal_b } else { signal_a };
/// // When signal_a and signal_b currently hold equal values, props can be
/// // memoized in place:
/// //
/// // old ReadSignal(signal_a).point_to(new ReadSignal(signal_b))
/// //
/// // That swap should keep wrapper subscribers attached to the existing
/// // wrapper and retarget only its source subscription.
/// rsx! { Child { sig: child_signal } }
/// }
///
/// #[component]
/// fn Child(sig: ReadSignal<i32>) -> Element {
/// rsx! {
/// // This read subscribes the child to the ReadSignal wrapper. The
/// // wrapper's forwarding context subscribes to the current source.
/// "{sig}"
/// }
/// }
/// ```
#[doc(hidden)]
pub struct ForwardingContext {
subscribers: Subscribers,
forwarding_context: ReactiveContext,
_owner: Owner<SyncStorage>,
}

impl ForwardingContext {
fn new(wrapped_subscribers: Subscribers) -> Self {
let subscribers = Subscribers::new();
let subscribers_to_notify = subscribers.clone();
let owner = Owner::<SyncStorage>::default();
let forwarding_context = ReactiveContext::new_with_callback_in_owner(
move || mark_subscribers_dirty(&subscribers_to_notify),
current_scope_id(),
owner.clone(),
std::panic::Location::caller(),
);
forwarding_context.subscribe(wrapped_subscribers);

Self {
subscribers,
forwarding_context,
_owner: owner,
}
}

fn subscribers(&self) -> Subscribers {
self.subscribers.clone()
}

fn run_in<O>(&self, f: impl FnOnce() -> O) -> O {
self.forwarding_context.run_in(f)
}

fn retarget_source(&self, wrapped_subscribers: Subscribers) {
self.forwarding_context.clear_subscribers();
self.forwarding_context.subscribe(wrapped_subscribers);
}

fn mark_dirty(&self) {
mark_subscribers_dirty(&self.subscribers);
}
}

impl Drop for ForwardingContext {
fn drop(&mut self) {
self.forwarding_context.clear_subscribers();
}
}

fn mark_subscribers_dirty(subscribers: &Subscribers) {
let mut subscribers_to_notify = Vec::new();
subscribers.visit(|subscriber| subscribers_to_notify.push(*subscriber));
for subscriber in subscribers_to_notify {
if !subscriber.mark_dirty() {
subscribers.remove(&subscriber);
}
}
}

/// A boxed version of [Readable] that can be used to store any readable type.
pub struct ReadSignal<T: ?Sized, S: BoxedSignalStorage<T> = UnsyncStorage> {
value: CopyValue<Box<S::DynReadable<sealed::SealedToken>>, S>,
forwarding: CopyValue<ForwardingContext, S>,
}

impl<T: ?Sized + 'static> ReadSignal<T> {
Expand All @@ -34,36 +145,42 @@ impl<T: ?Sized + 'static, S: BoxedSignalStorage<T>> ReadSignal<T, S> {
S: CreateBoxedSignalStorage<R>,
R: Readable<Target = T>,
{
let value = S::new_readable(value, sealed::SealedToken);
let subscribers = ForwardingContext::new(value.subscribers());
Self {
value: CopyValue::new_maybe_sync(S::new_readable(value, sealed::SealedToken)),
value: CopyValue::new_maybe_sync(value),
forwarding: CopyValue::new_maybe_sync(subscribers),
}
}

/// Point to another [ReadSignal]. This will subscribe the other [ReadSignal] to all subscribers of this [ReadSignal].
/// Point to another [ReadSignal]. Wrapper-level subscribers stay attached to this wrapper;
/// subscribers attached directly to the underlying readable are left alone.
pub fn point_to(&self, other: Self) -> BorrowResult {
let this_subscribers = self.subscribers();
let mut this_subscribers_vec = Vec::new();
// Note we don't subscribe directly in the visit closure to avoid a deadlock when pointing to self
this_subscribers.visit(|subscriber| this_subscribers_vec.push(*subscriber));
let other_subscribers = other.subscribers();
for subscriber in this_subscribers_vec {
subscriber.subscribe(other_subscribers.clone());
if self.forwarding == other.forwarding {
return Ok(());
}
self.value.point_to(other.value)?;

let forwarding = match self.forwarding.try_peek_unchecked() {
Ok(forwarding) => forwarding,
Err(_) => return Ok(()),
};

let new_value = other.value;
let new_source_subscribers = new_value.try_peek_unchecked()?.subscribers();

// Keep `other` usable; rsx clones can retarget multiple props from it.
self.value.point_to(new_value)?;

forwarding.retarget_source(new_source_subscribers);
Ok(())
}

#[doc(hidden)]
/// This is only used by the `props` macro.
/// Mark any readers of the signal as dirty
pub fn mark_dirty(&mut self) {
let subscribers = self.subscribers();
let mut this_subscribers_vec = Vec::new();
subscribers.visit(|subscriber| this_subscribers_vec.push(*subscriber));
for subscriber in this_subscribers_vec {
subscribers.remove(&subscriber);
subscriber.mark_dirty();
}
let forwarding = self.forwarding.try_peek_unchecked().unwrap();
forwarding.mark_dirty();
}
}

Expand All @@ -77,7 +194,7 @@ impl<T: ?Sized, S: BoxedSignalStorage<T>> Copy for ReadSignal<T, S> {}

impl<T: ?Sized, S: BoxedSignalStorage<T>> PartialEq for ReadSignal<T, S> {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
self.forwarding == other.forwarding
}
}

Expand Down Expand Up @@ -131,7 +248,12 @@ impl<T: ?Sized, S: BoxedSignalStorage<T>> Readable for ReadSignal<T, S> {
where
T: 'static,
{
self.value.try_peek_unchecked()?.try_read_unchecked()
let forwarding = self.forwarding.try_peek_unchecked()?;
let wrapped = self.value.try_peek_unchecked()?;
if let Some(reactive_context) = ReactiveContext::current() {
reactive_context.subscribe(forwarding.subscribers());
}
forwarding.run_in(|| wrapped.try_read_unchecked())
}

#[track_caller]
Expand All @@ -146,7 +268,8 @@ impl<T: ?Sized, S: BoxedSignalStorage<T>> Readable for ReadSignal<T, S> {
where
T: 'static,
{
self.value.try_peek_unchecked().unwrap().subscribers()
let forwarding = self.forwarding.try_peek_unchecked().unwrap();
forwarding.subscribers()
}
}

Expand Down Expand Up @@ -386,10 +509,7 @@ impl<T: ?Sized, S: BoxedSignalStorage<T>> Writable for WriteSignal<T, S> {
where
T: 'static,
{
self.value
.try_peek_unchecked()
.unwrap()
.try_write_unchecked()
self.value.try_peek_unchecked()?.try_write_unchecked()
}
}

Expand Down Expand Up @@ -442,6 +562,7 @@ where
pub trait BoxedSignalStorage<T: ?Sized>:
Storage<Box<Self::DynReadable<sealed::SealedToken>>>
+ Storage<Box<Self::DynWritable<sealed::SealedToken>>>
+ Storage<ForwardingContext>
+ sealed::Sealed
+ 'static
{
Expand Down
29 changes: 29 additions & 0 deletions packages/signals/tests/memo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,32 @@ fn memos_sync_rerun_after_unrelated_write() {
dom.render_immediate(&mut NoOpMutations);
assert!(PASSED.load(Ordering::SeqCst));
}

// Boxed ReadSignal wrappers should read through Memo::try_read_unchecked.
#[test]
fn boxed_memo_reads_recompute_dirty_memos() {
let boxed_value_after_write = Rc::new(RefCell::new(None));
let mut dom = VirtualDom::new_with_props(
{
let boxed_value_after_write = boxed_value_after_write.clone();
move |boxed_value_after_write: Rc<RefCell<Option<i32>>>| {
let mut signal = use_signal(|| 0);
#[allow(clippy::redundant_closure)]
let memo = use_memo(move || signal());
let boxed = ReadSignal::from(memo);

assert_eq!(boxed(), 0);
signal.set(1);
*boxed_value_after_write.borrow_mut() = Some(boxed());

rsx! {
div {}
}
}
},
boxed_value_after_write.clone(),
);

dom.rebuild_in_place();
assert_eq!(*boxed_value_after_write.borrow(), Some(1));
}
Loading
Loading