use alloc::boxed::Box;
use core::panic::Location;
use crate::NodeId;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ReactorId(pub(crate) u64);
impl ReactorId {
pub const fn get(self) -> u64 {
self.0
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct InvalidationCause {
pub node: NodeId,
pub version: u64,
pub node_origin: &'static Location<'static>,
pub write_origin: &'static Location<'static>,
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InvalidationLevel {
Check,
Dirty,
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DiagnosticEvent {
ReactiveWrite {
reactor: ReactorId,
cause: InvalidationCause,
},
EffectInvalidated {
reactor: ReactorId,
effect: NodeId,
effect_origin: &'static Location<'static>,
cause: InvalidationCause,
level: InvalidationLevel,
},
EffectScheduled {
reactor: ReactorId,
effect: NodeId,
effect_origin: &'static Location<'static>,
queued: bool,
flush_epoch: u64,
},
EffectRunStarted {
reactor: ReactorId,
effect: NodeId,
effect_origin: &'static Location<'static>,
flush_epoch: u64,
},
EffectRunFinished {
reactor: ReactorId,
effect: NodeId,
flush_epoch: u64,
},
EffectRunSkipped {
reactor: ReactorId,
effect: NodeId,
flush_epoch: u64,
},
EffectDisposed {
reactor: ReactorId,
effect: NodeId,
},
FlushStarted {
reactor: ReactorId,
flush_epoch: u64,
pending_jobs: usize,
},
FlushFinished {
reactor: ReactorId,
flush_epoch: u64,
remaining_jobs: usize,
},
}
#[must_use = "dropping the subscription disables reactive diagnostics"]
pub struct DiagnosticSubscription {
unsubscribe: Option<Box<dyn FnOnce()>>,
}
impl DiagnosticSubscription {
pub(crate) fn new(unsubscribe: impl FnOnce() + 'static) -> Self {
Self {
unsubscribe: Some(Box::new(unsubscribe)),
}
}
}
impl Drop for DiagnosticSubscription {
fn drop(&mut self) {
if let Some(unsubscribe) = self.unsubscribe.take() {
unsubscribe();
}
}
}
#[cfg(test)]
mod tests {
use alloc::rc::Rc;
use core::cell::RefCell;
use crate::{
DiagnosticEvent, InvalidationLevel, Reactor, current, memo_in, signal, signal_in, source_in,
};
#[test]
fn diagnostics_preserve_root_write_through_computed_dependencies() {
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 source = signal_in(&reactor, 1_u32);
let doubled = memo_in(&reactor, {
let source = source.clone();
move || source.get() * 2
});
let _effect = reactor.effect({
let doubled = doubled.clone();
move || {
let _ = doubled.get();
}
});
reactor.flush_now();
events.borrow_mut().clear();
source.set(2);
reactor.flush_now();
let events = events.borrow();
let write = events.iter().find_map(|event| match event {
DiagnosticEvent::ReactiveWrite { cause, .. } => Some(*cause),
_ => None,
});
let write = write.expect("source write should be diagnosed");
assert!(
events.iter().any(|event| matches!(
event,
DiagnosticEvent::EffectInvalidated {
cause,
level: InvalidationLevel::Check,
..
} if *cause == write
)),
"the effect should retain the source write through the memo"
);
assert!(
events
.iter()
.any(|event| matches!(event, DiagnosticEvent::EffectRunStarted { .. }))
);
assert!(
events
.iter()
.any(|event| matches!(event, DiagnosticEvent::EffectRunFinished { .. }))
);
}
#[test]
fn effect_schedule_events_report_coalesced_direct_writes() {
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 first = signal_in(&reactor, 0_u32);
let second = signal_in(&reactor, 0_u32);
let _effect = reactor.effect({
let first = first.clone();
let second = second.clone();
move || {
let _ = (first.get(), second.get());
}
});
reactor.flush_now();
events.borrow_mut().clear();
first.set(1);
second.set(1);
let queued = events
.borrow()
.iter()
.filter_map(|event| match event {
DiagnosticEvent::EffectScheduled { queued, .. } => Some(*queued),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(queued, [true, false]);
}
#[test]
fn subscription_keeps_an_empty_default_reactor_alive() {
let events = Rc::new(RefCell::new(Vec::new()));
let subscription = current().subscribe_diagnostics({
let events = Rc::clone(&events);
move |event| events.borrow_mut().push(event)
});
let value = signal(0_u32);
value.set(1);
assert!(
events
.borrow()
.iter()
.any(|event| matches!(event, DiagnosticEvent::ReactiveWrite { .. }))
);
drop(subscription);
}
#[test]
fn disposing_an_effect_is_reported() {
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 effect = reactor.effect(|| {});
reactor.flush_now();
let effect_id = events
.borrow()
.iter()
.find_map(|event| match event {
DiagnosticEvent::EffectRunStarted { effect, .. } => Some(*effect),
_ => None,
})
.expect("initial effect run should be diagnosed");
events.borrow_mut().clear();
effect.dispose();
assert!(events.borrow().iter().any(|event| matches!(
event,
DiagnosticEvent::EffectDisposed { effect, .. } if *effect == effect_id
)));
}
#[test]
fn diagnostics_do_not_change_stale_node_trigger_behavior() {
let reactor = Reactor::new();
let stale_id = {
let source = source_in(&reactor);
source.id()
};
reactor.trigger(stale_id);
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)
});
reactor.trigger(stale_id);
assert!(
events.borrow().is_empty(),
"a stale node remains a silent no-op when diagnostics are active"
);
}
#[test]
fn dropping_the_subscription_stops_delivery_and_disables_diagnostics() {
let reactor = Reactor::new();
let source = source_in(&reactor);
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)
});
assert!(reactor.diagnostics_enabled());
source.trigger();
assert!(!events.borrow().is_empty());
events.borrow_mut().clear();
drop(subscription);
assert!(!reactor.diagnostics_enabled());
source.trigger();
assert!(
events.borrow().is_empty(),
"dropping the subscription must stop all later delivery"
);
}
#[test]
fn flush_boundaries_and_verified_skips_are_reported() {
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 source = signal_in(&reactor, 1_u32);
let parity = memo_in(&reactor, {
let source = source.clone();
move || source.get() % 2
});
let _effect = reactor.effect({
let parity = parity.clone();
move || {
let _ = parity.get();
}
});
reactor.flush_now();
events.borrow_mut().clear();
source.set(3);
reactor.flush_now();
let events = events.borrow();
assert!(
events
.iter()
.any(|event| matches!(event, DiagnosticEvent::FlushStarted { .. }))
);
assert!(
events
.iter()
.any(|event| matches!(event, DiagnosticEvent::EffectRunSkipped { .. }))
);
assert!(
events
.iter()
.any(|event| matches!(event, DiagnosticEvent::FlushFinished { .. }))
);
}
#[test]
fn a_nested_flush_closes_its_own_epoch() {
let reactor = Reactor::new();
let boundaries = Rc::new(RefCell::new(Vec::new()));
let _subscription = reactor.subscribe_diagnostics({
let boundaries = Rc::clone(&boundaries);
move |event| match event {
DiagnosticEvent::FlushStarted { flush_epoch, .. } => {
boundaries.borrow_mut().push(("start", flush_epoch));
}
DiagnosticEvent::FlushFinished { flush_epoch, .. } => {
boundaries.borrow_mut().push(("finish", flush_epoch));
}
_ => {}
}
});
reactor.schedule({
let reactor = reactor.clone();
move || reactor.flush_now()
});
reactor.flush_now();
assert_eq!(
&*boundaries.borrow(),
&[("start", 1), ("start", 2), ("finish", 2), ("finish", 1)],
"each flush must close the epoch it opened"
);
}
}