use alloc::boxed::Box;
use alloc::rc::{Rc, Weak};
use core::cell::{Cell, RefCell};
use std::panic::{AssertUnwindSafe, catch_unwind};
use crate::reactor::{Mark, ObserverHook, State};
use crate::scope::{
OwnedDisposable, OwnerFrame, adopt_into_current, build_error_info, error_handler_for,
with_owner,
};
use crate::{
DiagnosticEvent, InvalidationCause, NodeId, NodeKind, Reactor, ReactorId, current,
trace_targets,
};
const MAX_RUNS_PER_FLUSH: u32 = 100;
#[track_caller]
pub fn effect(f: impl Fn() + 'static) -> EffectHandle {
current().effect(f)
}
#[track_caller]
pub fn effect_in(reactor: &Reactor, f: impl Fn() + 'static) -> EffectHandle {
reactor.effect(f)
}
#[track_caller]
pub fn effect_with(
scheduler: impl EffectScheduler + 'static,
f: impl Fn() + 'static,
) -> EffectHandle {
current().effect_with(scheduler, f)
}
#[track_caller]
pub fn effect_with_in(
reactor: &Reactor,
scheduler: impl EffectScheduler + 'static,
f: impl Fn() + 'static,
) -> EffectHandle {
reactor.effect_with(scheduler, f)
}
pub trait EffectScheduler {
fn schedule(&self, ready: EffectRun);
}
impl<F: Fn(EffectRun)> EffectScheduler for F {
fn schedule(&self, ready: EffectRun) {
self(ready);
}
}
pub struct EffectRun {
effect: Weak<EffectInner>,
}
impl EffectRun {
pub fn run(mut self) {
let Some(effect) = self.effect.upgrade() else {
return;
};
self.effect = Weak::new();
if effect.reactor.in_flush() {
effect.run_scheduled();
return;
}
let reactor = effect.reactor.clone();
struct Guard<'a>(&'a Reactor);
impl Drop for Guard<'_> {
fn drop(&mut self) {
self.0.end_flush();
}
}
let _guard = Guard(&reactor);
reactor.begin_flush();
effect.run_scheduled();
}
pub fn id(&self) -> Option<NodeId> {
self.effect.upgrade().map(|effect| effect.id)
}
pub fn is_stale(&self) -> bool {
self.effect
.upgrade()
.is_none_or(|effect| effect.disposed.get())
}
}
impl Drop for EffectRun {
fn drop(&mut self) {
if let Some(effect) = self.effect.upgrade() {
effect.unlatch_scheduled();
}
}
}
impl core::fmt::Debug for EffectRun {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("EffectRun")
.field("id", &self.id())
.field("stale", &self.is_stale())
.finish()
}
}
#[derive(Clone)]
#[must_use = "an unowned effect is disposed when its handle is dropped; hold the handle, leak it, or create the effect inside a scope"]
pub struct EffectHandle {
inner: Rc<EffectInner>,
}
impl Reactor {
#[track_caller]
pub fn effect(&self, f: impl Fn() + 'static) -> EffectHandle {
EffectHandle::new(self.clone(), None, f)
}
#[track_caller]
pub fn effect_with(
&self,
scheduler: impl EffectScheduler + 'static,
f: impl Fn() + 'static,
) -> EffectHandle {
EffectHandle::new(self.clone(), Some(Rc::new(scheduler)), f)
}
}
impl EffectHandle {
#[track_caller]
fn new(
reactor: Reactor,
scheduler: Option<Rc<dyn EffectScheduler>>,
effect: impl Fn() + 'static,
) -> Self {
let id = reactor.allocate_node(NodeKind::Effect);
let inner = Rc::new(EffectInner {
reactor: reactor.clone(),
id,
effect: Box::new(effect),
scheduler,
state: Cell::new(State::Dirty),
scheduled: Cell::new(false),
rerun_after_current: Cell::new(false),
running: Cell::new(false),
disposed: Cell::new(false),
self_ref: RefCell::new(Weak::new()),
owner: OwnerFrame::new(),
last_drain_epoch: Cell::new(u64::MAX),
runs_this_drain: Cell::new(0),
});
*inner.self_ref.borrow_mut() = Rc::downgrade(&inner);
tracing::debug!(
target: trace_targets::EFFECT,
event = "create_effect",
node_id = id.0,
"created reactive effect"
);
let observer: Rc<dyn ObserverHook> = inner.clone();
reactor.register_observer(id, observer);
let owned: Rc<dyn OwnedDisposable> = inner.clone();
let _ = adopt_into_current(owned);
inner.schedule();
Self { inner }
}
pub fn leak(self) {
core::mem::forget(self);
}
pub fn dispose(&self) {
self.inner.dispose();
}
pub fn is_disposed(&self) -> bool {
self.inner.disposed.get()
}
pub fn id(&self) -> NodeId {
self.inner.id
}
pub fn reactor_id(&self) -> ReactorId {
self.inner.reactor.id()
}
}
impl core::fmt::Debug for EffectHandle {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("EffectHandle")
.field("id", &self.inner.id)
.field("disposed", &self.inner.disposed.get())
.finish()
}
}
struct EffectInner {
reactor: Reactor,
id: NodeId,
effect: Box<dyn Fn() + 'static>,
scheduler: Option<Rc<dyn EffectScheduler>>,
state: Cell<State>,
scheduled: Cell<bool>,
rerun_after_current: Cell<bool>,
running: Cell<bool>,
disposed: Cell<bool>,
self_ref: RefCell<Weak<EffectInner>>,
owner: Rc<OwnerFrame>,
last_drain_epoch: Cell<u64>,
runs_this_drain: Cell<u32>,
}
impl EffectInner {
fn latch_scheduled(&self) -> bool {
let already = self.scheduled.replace(true);
if !already {
self.reactor.counters().effect_queued();
}
already
}
fn unlatch_scheduled(&self) {
if self.scheduled.replace(false) {
self.reactor.counters().effect_unqueued();
}
}
fn schedule(&self) {
let skipped = self.disposed.get() || self.latch_scheduled();
self.reactor.record_flush(|stats| {
if skipped {
stats.effects_coalesced = stats.effects_coalesced.saturating_add(1);
} else {
stats.effects_queued = stats.effects_queued.saturating_add(1);
}
});
if self.reactor.diagnostics_enabled()
&& let Some(effect_origin) = self.reactor.node_origin(self.id)
{
self.reactor
.emit_diagnostic(DiagnosticEvent::EffectScheduled {
reactor: self.reactor.diagnostic_id(),
effect: self.id,
effect_origin,
queued: !skipped,
flush_epoch: self.reactor.flush_epoch(),
});
}
if skipped {
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::EFFECT,
event = "schedule_effect",
node_id = self.id.0,
queued = false,
disposed = self.disposed.get(),
already_scheduled = self.scheduled.get(),
"effect scheduling skipped"
);
return;
}
let weak = self.self_ref.borrow().clone();
if let Some(scheduler) = &self.scheduler {
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::EFFECT,
event = "schedule_effect",
node_id = self.id.0,
queued = true,
external = true,
"handed ready effect to a consumer scheduler"
);
scheduler.schedule(EffectRun { effect: weak });
return;
}
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::EFFECT,
event = "schedule_effect",
node_id = self.id.0,
queued = true,
external = false,
"queued effect for microtask flush"
);
let reactor = self.reactor.clone();
reactor.schedule(move || {
let Some(inner) = weak.upgrade() else {
return;
};
inner.run_scheduled();
});
}
fn run_scheduled(self: &Rc<Self>) {
let Some(handler) = error_handler_for(&self.owner) else {
self.run_scheduled_inner();
return;
};
let Err(payload) = catch_unwind(AssertUnwindSafe(|| self.run_scheduled_inner())) else {
return;
};
let origin = self.reactor.node_origin(self.id);
tracing::warn!(
target: trace_targets::EFFECT,
event = "effect_panicked",
node_id = self.id.0,
"effect panicked and was disposed by an enclosing error boundary"
);
self.dispose();
handler(build_error_info(payload, self.id, origin));
}
fn run_scheduled_inner(self: &Rc<Self>) {
if self.disposed.get() {
self.unlatch_scheduled();
return;
}
self.unlatch_scheduled();
if self.running.get() || self.reactor.is_computation_active(self.id) {
self.rerun_after_current.set(true);
return;
}
let state = self.state.get();
self.state.set(State::Clean);
let should_run = match state {
State::Dirty => true,
State::Check => {
struct RecoverOnUnwind<'a> {
inner: &'a EffectInner,
armed: bool,
}
impl Drop for RecoverOnUnwind<'_> {
fn drop(&mut self) {
if self.armed && !self.inner.disposed.get() {
self.inner.state.set(State::Dirty);
self.inner.schedule();
}
}
}
let mut guard = RecoverOnUnwind {
inner: self,
armed: true,
};
let changed = self.reactor.dependencies_changed(self.id);
guard.armed = false;
changed
}
State::Clean => false,
};
if self.disposed.get() {
return;
}
if !should_run {
if self.reactor.diagnostics_enabled() {
self.reactor
.emit_diagnostic(DiagnosticEvent::EffectRunSkipped {
reactor: self.reactor.diagnostic_id(),
effect: self.id,
flush_epoch: self.reactor.flush_epoch(),
});
}
self.reactor.record_flush(|stats| {
stats.effects_skipped = stats.effects_skipped.saturating_add(1);
});
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::EFFECT,
event = "skip_effect",
node_id = self.id.0,
"skipping effect run; no dependency actually changed"
);
return;
}
self.check_divergence();
let _span = tracing::debug_span!(
target: trace_targets::EFFECT,
"effect.run",
node_id = self.id.0
)
.entered();
let reactor_id = self.reactor.diagnostic_id();
let flush_epoch = self.reactor.flush_epoch();
let diagnostics_enabled = self.reactor.diagnostics_enabled();
if diagnostics_enabled && let Some(effect_origin) = self.reactor.node_origin(self.id) {
self.reactor
.emit_diagnostic(DiagnosticEvent::EffectRunStarted {
reactor: reactor_id,
effect: self.id,
effect_origin,
flush_epoch,
});
}
self.reactor
.record_flush(|stats| stats.effects_run = stats.effects_run.saturating_add(1));
struct DiagnosticRunGuard<'a> {
reactor: &'a Reactor,
reactor_id: crate::ReactorId,
effect: NodeId,
flush_epoch: u64,
enabled: bool,
}
impl Drop for DiagnosticRunGuard<'_> {
fn drop(&mut self) {
if self.enabled {
self.reactor
.emit_diagnostic(DiagnosticEvent::EffectRunFinished {
reactor: self.reactor_id,
effect: self.effect,
flush_epoch: self.flush_epoch,
});
}
}
}
let _diagnostic_guard = DiagnosticRunGuard {
reactor: &self.reactor,
reactor_id,
effect: self.id,
flush_epoch,
enabled: diagnostics_enabled,
};
struct RunWindow<'a>(&'a Cell<bool>);
impl Drop for RunWindow<'_> {
fn drop(&mut self) {
self.0.set(false);
}
}
self.running.set(true);
let _run_window = RunWindow(&self.running);
self.owner.reset();
if self.disposed.get() {
return;
}
with_owner(&self.owner, || {
self.reactor.run_in_context(self.id, || (self.effect)())
});
if self.rerun_after_current.replace(false) && !self.disposed.get() {
self.schedule();
}
}
fn check_divergence(&self) {
let epoch = self.reactor.drain_epoch();
if self.last_drain_epoch.get() != epoch {
self.last_drain_epoch.set(epoch);
self.runs_this_drain.set(1);
return;
}
let runs = self.runs_this_drain.get().saturating_add(1);
self.runs_this_drain.set(runs);
if runs > MAX_RUNS_PER_FLUSH {
let origin = self
.reactor
.node_origin(self.id)
.map(|location| location.to_string())
.unwrap_or_else(|| "<unknown>".into());
panic!(
"adaptite: effect created at {origin} ran more than {MAX_RUNS_PER_FLUSH} times \
in a single drain; this suggests a divergent reactive feedback loop (the effect \
writes state it depends on without converging)"
);
}
}
fn dispose(&self) {
if self.disposed.replace(true) {
return;
}
tracing::debug!(
target: trace_targets::EFFECT,
event = "dispose_effect",
node_id = self.id.0,
"disposed reactive effect"
);
if self.reactor.diagnostics_enabled() {
self.reactor
.emit_diagnostic(DiagnosticEvent::EffectDisposed {
reactor: self.reactor.diagnostic_id(),
effect: self.id,
});
}
self.reactor.record_flush(|stats| {
stats.effects_disposed = stats.effects_disposed.saturating_add(1);
});
self.unlatch_scheduled();
struct UnhookOnUnwind<'a> {
reactor: &'a Reactor,
id: NodeId,
}
impl Drop for UnhookOnUnwind<'_> {
fn drop(&mut self) {
self.reactor.unregister_observer(self.id);
self.reactor.dispose(self.id);
}
}
let _unhook = UnhookOnUnwind {
reactor: &self.reactor,
id: self.id,
};
self.owner.dispose();
}
}
impl OwnedDisposable for EffectInner {
fn dispose_owned(&self) {
self.dispose();
}
fn is_disposed_owned(&self) -> bool {
self.disposed.get()
}
}
impl ObserverHook for EffectInner {
fn mark(&self, mark: Mark, cause: Option<InvalidationCause>) {
if let Some(cause) = cause
&& let Some(effect_origin) = self.reactor.node_origin(self.id)
{
self.reactor
.emit_diagnostic(DiagnosticEvent::EffectInvalidated {
reactor: self.reactor.diagnostic_id(),
effect: self.id,
effect_origin,
cause,
level: mark.into(),
});
}
let target = State::from(mark);
if self.state.get() < target {
self.state.set(target);
}
self.schedule();
}
fn state(&self) -> State {
self.state.get()
}
}
impl Drop for EffectInner {
fn drop(&mut self) {
self.dispose();
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_panicking_flush_started_subscriber_does_not_strand_a_scheduled_run() {
let reactor = Reactor::new();
let pending: std::rc::Rc<std::cell::RefCell<Vec<crate::EffectRun>>> =
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let signal = reactor.signal(0_u32);
let handle = reactor.effect_with(
{
let pending = std::rc::Rc::clone(&pending);
move |run: crate::EffectRun| pending.borrow_mut().push(run)
},
{
let signal = signal.clone();
move || {
let _ = signal.get();
}
},
);
for run in pending.borrow_mut().drain(..).collect::<Vec<_>>() {
run.run();
}
signal.set(1);
reactor.flush_now();
let queued: Vec<_> = pending.borrow_mut().drain(..).collect();
assert_eq!(queued.len(), 1, "the scheduler received the ready run");
let boom = std::cell::Cell::new(true);
let sub = reactor.subscribe_diagnostics(move |event| {
if matches!(event, crate::DiagnosticEvent::FlushStarted { .. }) && boom.replace(false) {
panic!("subscriber panicked");
}
});
for run in queued {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run.run()));
}
drop(sub);
assert!(
!reactor.in_flush(),
"flush_depth was stranded at 1 by the panic"
);
let _ = handle;
}
use std::cell::{Cell as Counter, RefCell};
use std::rc::Rc;
use runite::{queue_macrotask, run, spawn, yield_now};
use crate::{Reactor, signal_in};
use super::{EffectHandle, EffectRun};
#[derive(Clone, Default)]
struct Lane(Rc<RefCell<Vec<EffectRun>>>);
impl Lane {
fn scheduler(&self) -> impl Fn(EffectRun) + 'static {
let queue = Rc::clone(&self.0);
move |ready| queue.borrow_mut().push(ready)
}
fn len(&self) -> usize {
self.0.borrow().len()
}
fn drain(&self, reactor: &Reactor) {
reactor.external_flush(|| {
let ready = core::mem::take(&mut *self.0.borrow_mut());
for run in ready {
run.run();
}
});
}
}
#[test]
fn effect_identity_is_the_same_from_the_handle_the_lane_and_the_diagnostics() {
use crate::DiagnosticEvent;
let reactor = Reactor::new();
let events = Rc::new(RefCell::new(Vec::new()));
let _subscription = reactor.subscribe_diagnostics({
let events = Rc::clone(&events);
move |event| events.borrow_mut().push(event)
});
let lane = Lane::default();
let value = signal_in(&reactor, 1);
let effect = reactor.effect_with(lane.scheduler(), {
let value = value.clone();
move || {
let _ = value.get();
}
});
let id = effect.id();
assert_eq!(effect.reactor_id(), reactor.id());
let queued = lane.0.borrow().first().and_then(|run| run.id());
assert_eq!(queued, Some(id), "the lane sees the same node");
lane.drain(&reactor);
assert_eq!(effect.id(), id, "running does not change identity");
value.set(2);
lane.drain(&reactor);
effect.dispose();
assert_eq!(
effect.id(),
id,
"a disposed effect keeps its id; ids are never reused"
);
let effect_events = events
.borrow()
.iter()
.filter_map(|event| match event {
DiagnosticEvent::EffectScheduled { effect, .. }
| DiagnosticEvent::EffectRunStarted { effect, .. }
| DiagnosticEvent::EffectRunFinished { effect, .. }
| DiagnosticEvent::EffectDisposed { effect, .. } => Some(*effect),
_ => None,
})
.collect::<Vec<_>>();
assert!(!effect_events.is_empty());
assert!(effect_events.iter().all(|reported| *reported == id));
}
#[test]
fn a_custom_scheduler_owns_when_the_effect_runs() {
let reactor = Reactor::new();
let lane = Lane::default();
let value = signal_in(&reactor, 1);
let seen = Rc::new(RefCell::new(Vec::new()));
let effect = reactor.effect_with(lane.scheduler(), {
let value = value.clone();
let seen = Rc::clone(&seen);
move || seen.borrow_mut().push(value.get())
});
assert_eq!(lane.len(), 1);
reactor.flush_now();
assert!(seen.borrow().is_empty(), "the reactor lane must not run it");
lane.drain(&reactor);
assert_eq!(*seen.borrow(), [1]);
value.set(2);
assert_eq!(lane.len(), 1, "an invalidation re-enters the scheduler");
assert_eq!(*seen.borrow(), [1], "and still does not run inline");
lane.drain(&reactor);
assert_eq!(*seen.borrow(), [1, 2]);
effect.dispose();
}
#[test]
fn scheduled_runs_coalesce_between_drains() {
let reactor = Reactor::new();
let lane = Lane::default();
let value = signal_in(&reactor, 0);
let runs = Rc::new(Counter::new(0));
let effect = reactor.effect_with(lane.scheduler(), {
let value = value.clone();
let runs = Rc::clone(&runs);
move || {
value.get();
runs.set(runs.get() + 1);
}
});
lane.drain(&reactor);
assert_eq!(runs.get(), 1);
for next in 1..=5 {
value.set(next);
}
assert_eq!(
lane.len(),
1,
"repeat invalidations coalesce into the pending run"
);
lane.drain(&reactor);
assert_eq!(runs.get(), 2, "the effect observes only the final value");
effect.dispose();
}
#[test]
fn separate_lanes_run_in_the_order_the_consumer_drains_them() {
let reactor = Reactor::new();
let (state, render) = (Lane::default(), Lane::default());
let value = signal_in(&reactor, 1);
let order = Rc::new(RefCell::new(Vec::new()));
let write_dom = reactor.effect_with(render.scheduler(), {
let value = value.clone();
let order = Rc::clone(&order);
move || {
value.get();
order.borrow_mut().push("render");
}
});
let propagate = reactor.effect_with(state.scheduler(), {
let value = value.clone();
let order = Rc::clone(&order);
move || {
value.get();
order.borrow_mut().push("state");
}
});
state.drain(&reactor);
render.drain(&reactor);
assert_eq!(*order.borrow(), ["state", "render"]);
write_dom.dispose();
propagate.dispose();
}
#[test]
fn discarding_a_run_does_not_starve_the_effect_forever() {
let reactor = Reactor::new();
let lane = Lane::default();
let value = signal_in(&reactor, 1);
let seen = Rc::new(RefCell::new(Vec::new()));
let effect = reactor.effect_with(lane.scheduler(), {
let value = value.clone();
let seen = Rc::clone(&seen);
move || seen.borrow_mut().push(value.get())
});
lane.drain(&reactor);
assert_eq!(*seen.borrow(), [1], "the dependency edge is now recorded");
value.set(2);
assert_eq!(lane.len(), 1);
lane.0.borrow_mut().clear();
assert_eq!(*seen.borrow(), [1], "the discarded run never executed");
value.set(3);
assert_eq!(
lane.len(),
1,
"a discarded run must leave the effect schedulable"
);
lane.drain(&reactor);
assert_eq!(
*seen.borrow(),
[1, 3],
"the effect resumes at the current value; the skipped one is not replayed"
);
effect.dispose();
}
#[test]
fn running_a_disposed_effect_is_a_no_op() {
let reactor = Reactor::new();
let lane = Lane::default();
let runs = Rc::new(Counter::new(0));
let effect = reactor.effect_with(lane.scheduler(), {
let runs = Rc::clone(&runs);
move || runs.set(runs.get() + 1)
});
assert_eq!(lane.len(), 1);
effect.dispose();
let ready = lane.0.borrow_mut().pop().expect("a run was queued");
assert!(ready.is_stale(), "a disposed effect reports its run stale");
ready.run();
assert_eq!(runs.get(), 0);
}
#[test]
fn external_flush_gives_a_whole_drain_one_epoch() {
let reactor = Reactor::new();
let lane = Lane::default();
let epochs = Rc::new(RefCell::new(Vec::new()));
for _ in 0..2 {
reactor
.effect_with(lane.scheduler(), {
let reactor = reactor.clone();
let epochs = Rc::clone(&epochs);
move || epochs.borrow_mut().push(reactor.flush_epoch())
})
.leak();
}
lane.drain(&reactor);
let observed = epochs.borrow().clone();
assert_eq!(observed.len(), 2);
assert_eq!(
observed[0], observed[1],
"one drain is one flush: {observed:?}"
);
epochs.borrow_mut().clear();
lane.drain(&reactor);
assert!(epochs.borrow().is_empty(), "nothing was invalidated");
}
#[test]
fn a_run_outside_any_flush_opens_its_own() {
let reactor = Reactor::new();
let lane = Lane::default();
let epochs = Rc::new(RefCell::new(Vec::new()));
let value = signal_in(&reactor, 0);
let effect = reactor.effect_with(lane.scheduler(), {
let reactor = reactor.clone();
let value = value.clone();
let epochs = Rc::clone(&epochs);
move || {
value.get();
epochs.borrow_mut().push(reactor.flush_epoch());
}
});
lane.0.borrow_mut().pop().expect("initial run queued").run();
value.set(1);
lane.0.borrow_mut().pop().expect("rerun queued").run();
let observed = epochs.borrow().clone();
assert_eq!(observed.len(), 2);
assert_ne!(
observed[0], observed[1],
"each bare run is its own flush: {observed:?}"
);
effect.dispose();
}
#[test]
fn effects_flush_through_microtasks_and_coalesce() {
let seen = Rc::new(RefCell::new(Vec::new()));
let handle_slot = Rc::new(RefCell::new(None::<EffectHandle>));
queue_macrotask({
let seen = Rc::clone(&seen);
let handle_slot = Rc::clone(&handle_slot);
move || {
let reactor = Reactor::new();
let source = signal_in(&reactor, 0usize);
let effect = reactor.effect({
let seen = Rc::clone(&seen);
let source = source.clone();
move || seen.borrow_mut().push(source.get())
});
source.set(1);
source.set(2);
assert!(seen.borrow().is_empty(), "effect should not run inline");
*handle_slot.borrow_mut() = Some(effect);
}
});
run();
assert_eq!(&*seen.borrow(), &[2]);
let reruns = Rc::new(Counter::new(0usize));
queue_macrotask({
let reruns = Rc::clone(&reruns);
let seen = Rc::clone(&seen);
let handle_slot = Rc::clone(&handle_slot);
move || {
let reactor = Reactor::new();
let source = signal_in(&reactor, 2usize);
let effect = reactor.effect({
let reruns = Rc::clone(&reruns);
let seen = Rc::clone(&seen);
let source = source.clone();
move || {
reruns.set(reruns.get() + 1);
seen.borrow_mut().push(source.get());
}
});
source.set(3);
source.set(4);
*handle_slot.borrow_mut() = Some(effect);
}
});
run();
assert_eq!(reruns.get(), 1);
}
#[test]
fn effects_rerun_after_async_future_updates_a_dependency() {
let seen = Rc::new(RefCell::new(Vec::new()));
let handle_slot = Rc::new(RefCell::new(None::<EffectHandle>));
queue_macrotask({
let seen = Rc::clone(&seen);
let handle_slot = Rc::clone(&handle_slot);
move || {
let reactor = Reactor::new();
let source = signal_in(&reactor, 0usize);
let effect = reactor.effect({
let seen = Rc::clone(&seen);
let source = source.clone();
move || seen.borrow_mut().push(source.get())
});
*handle_slot.borrow_mut() = Some(effect);
std::mem::drop(spawn({
let source = source.clone();
async move {
yield_now().await;
let _ = source.set(1);
}
}));
}
});
run();
assert_eq!(&*seen.borrow(), &[0, 1]);
}
#[test]
fn effect_recovers_after_a_panicking_dependency_verification() {
use std::panic::{AssertUnwindSafe, catch_unwind};
use crate::memo_in;
let seen = Rc::new(RefCell::new(Vec::new()));
queue_macrotask({
let seen = Rc::clone(&seen);
move || {
let reactor = Reactor::new();
let source = signal_in(&reactor, 1i32);
let doubled = memo_in(&reactor, {
let source = source.clone();
move || {
let value = source.get();
assert!(value != 13, "unlucky number");
value * 2
}
});
let effect = reactor.effect({
let doubled = doubled.clone();
let seen = Rc::clone(&seen);
move || seen.borrow_mut().push(doubled.get())
});
reactor.flush_now();
assert_eq!(&*seen.borrow(), &[2]);
source.set(13);
let result = catch_unwind(AssertUnwindSafe(|| reactor.flush_now()));
assert!(result.is_err(), "verification should propagate the panic");
source.set(7);
reactor.flush_now();
assert_eq!(&*seen.borrow(), &[2, 14], "the effect must recover");
effect.leak();
}
});
run();
}
#[test]
fn comparator_reads_do_not_become_effect_dependencies() {
use crate::memo_by_in;
let runs = Rc::new(Counter::new(0usize));
queue_macrotask({
let runs = Rc::clone(&runs);
move || {
let reactor = Reactor::new();
let tuning = signal_in(&reactor, 0i32);
let source = signal_in(&reactor, 1i32);
let value = memo_by_in(
&reactor,
{
let tuning = tuning.clone();
move |old: &i32, new: &i32| {
let _ = tuning.get();
old == new
}
},
{
let source = source.clone();
move || source.get()
},
);
let effect = reactor.effect({
let value = value.clone();
let source = source.clone();
let runs = Rc::clone(&runs);
move || {
runs.set(runs.get() + 1);
let _ = source.get();
let _ = value.get();
}
});
reactor.flush_now();
assert_eq!(runs.get(), 1);
source.set(2);
reactor.flush_now();
assert_eq!(runs.get(), 2);
tuning.set(99);
reactor.flush_now();
assert_eq!(runs.get(), 2, "comparator reads must not be tracked");
effect.leak();
}
});
run();
}
#[test]
fn disposing_a_queued_effect_prevents_its_run() {
let b_runs = Rc::new(Counter::new(0usize));
let keep_alive = Rc::new(RefCell::new(None::<EffectHandle>));
queue_macrotask({
let b_runs = Rc::clone(&b_runs);
let keep_alive = Rc::clone(&keep_alive);
move || {
let reactor = Reactor::new();
let trigger_a = signal_in(&reactor, 0usize);
let trigger_b = signal_in(&reactor, 0usize);
let b_slot = Rc::new(RefCell::new(None::<EffectHandle>));
let b = reactor.effect({
let b_runs = Rc::clone(&b_runs);
let trigger_b = trigger_b.clone();
move || {
let _ = trigger_b.get();
b_runs.set(b_runs.get() + 1);
}
});
*b_slot.borrow_mut() = Some(b);
let a = reactor.effect({
let trigger_a = trigger_a.clone();
let b_slot = Rc::clone(&b_slot);
move || {
if trigger_a.get() == 1
&& let Some(b) = b_slot.borrow().as_ref()
{
b.dispose();
}
}
});
*keep_alive.borrow_mut() = Some(a);
runite::queue_macrotask(move || {
trigger_a.set(1);
trigger_b.set(1);
});
}
});
run();
assert_eq!(
b_runs.get(),
1,
"an effect disposed while queued must not run"
);
}
#[test]
fn effect_writes_propagate_to_other_effects_in_the_same_flush() {
let seen = Rc::new(RefCell::new(Vec::new()));
let keep_alive = Rc::new(RefCell::new(Vec::<EffectHandle>::new()));
queue_macrotask({
let seen = Rc::clone(&seen);
let keep_alive = Rc::clone(&keep_alive);
move || {
let reactor = Reactor::new();
let input = signal_in(&reactor, 0usize);
let mirrored = signal_in(&reactor, 0usize);
let mirror = reactor.effect({
let input = input.clone();
let mirrored = mirrored.clone();
move || {
let _ = mirrored.set(input.get());
}
});
let observe = reactor.effect({
let mirrored = mirrored.clone();
let seen = Rc::clone(&seen);
move || seen.borrow_mut().push(mirrored.get())
});
keep_alive.borrow_mut().extend([mirror, observe]);
runite::queue_macrotask(move || {
input.set(5);
});
}
});
run();
assert_eq!(
&*seen.borrow(),
&[0, 5],
"the observer must settle on the mirrored value within the flush"
);
}
#[test]
fn untracked_and_peeked_reads_do_not_create_dependencies() {
let seen = Rc::new(RefCell::new(Vec::new()));
let handle_slot = Rc::new(RefCell::new(None::<EffectHandle>));
queue_macrotask({
let seen = Rc::clone(&seen);
let handle_slot = Rc::clone(&handle_slot);
move || {
let reactor = Reactor::new();
let tracked = signal_in(&reactor, 1usize);
let untracked_via_fn = signal_in(&reactor, 10usize);
let untracked_via_peek = signal_in(&reactor, 100usize);
let effect = reactor.effect({
let seen = Rc::clone(&seen);
let tracked = tracked.clone();
let untracked_via_fn = untracked_via_fn.clone();
let untracked_via_peek = untracked_via_peek.clone();
move || {
let total = tracked.get()
+ crate::untrack(|| untracked_via_fn.get())
+ untracked_via_peek.peek();
seen.borrow_mut().push(total);
}
});
*handle_slot.borrow_mut() = Some(effect);
runite::queue_macrotask({
let untracked_via_fn = untracked_via_fn.clone();
let untracked_via_peek = untracked_via_peek.clone();
let tracked = tracked.clone();
move || {
untracked_via_fn.set(20);
untracked_via_peek.set(200);
runite::queue_macrotask(move || {
tracked.set(2);
});
}
});
}
});
run();
assert_eq!(&*seen.borrow(), &[111, 222]);
}
#[test]
fn convergent_feedback_loops_settle() {
let seen = Rc::new(RefCell::new(Vec::new()));
let handle_slot = Rc::new(RefCell::new(None::<EffectHandle>));
queue_macrotask({
let seen = Rc::clone(&seen);
let handle_slot = Rc::clone(&handle_slot);
move || {
let reactor = Reactor::new();
let value = signal_in(&reactor, 5i64);
let effect = reactor.effect({
let value = value.clone();
let seen = Rc::clone(&seen);
move || {
let current = value.get();
seen.borrow_mut().push(current);
if current > 10 {
value.set(10);
}
}
});
value.set(25);
*handle_slot.borrow_mut() = Some(effect);
}
});
run();
assert_eq!(&*seen.borrow(), &[25, 10]);
}
#[test]
fn divergent_feedback_loops_panic_instead_of_hanging() {
use std::panic::{AssertUnwindSafe, catch_unwind};
let handle_slot = Rc::new(RefCell::new(None::<EffectHandle>));
let panic_message = Rc::new(RefCell::new(None::<String>));
let creation_line = Rc::new(Counter::new(0u32));
queue_macrotask({
let handle_slot = Rc::clone(&handle_slot);
let panic_message = Rc::clone(&panic_message);
let creation_line = Rc::clone(&creation_line);
move || {
let reactor = Reactor::new();
let counter = signal_in(&reactor, 0u64);
creation_line.set(line!() + 1);
let effect = reactor.effect({
let counter = counter.clone();
move || {
let next = counter.get() + 1;
counter.set(next);
}
});
*handle_slot.borrow_mut() = Some(effect);
let result = catch_unwind(AssertUnwindSafe(|| reactor.flush_now()));
let panic = result.expect_err("divergent loop should panic");
*panic_message.borrow_mut() = panic.downcast_ref::<String>().cloned();
}
});
run();
let message = panic_message.borrow();
let message = message
.as_ref()
.expect("panic payload should be a formatted string");
assert!(
message.contains("divergent reactive feedback loop"),
"panic should describe the divergence, got: {message}"
);
let site = format!("effect.rs:{}:", creation_line.get());
assert!(
message.contains(&site),
"panic should name the effect's creation site ({site}), got: {message}"
);
}
}