Menu
Public repository / master
AkurAI-Framework / crates/ui/src/reactive.rs
Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson
//! Fine-grained reactivity: signals, effects, memos, scopes and context.
//!
//! The model is the one Solid and Leptos use. A [`Signal`] is a value plus the
//! set of effects that read it. An [`effect`] runs immediately and re-runs
//! whenever a signal it read during its last run changes. Dependencies are
//! tracked on read, so there are no dependency arrays to maintain.
//!
//! Every signal, effect and cleanup belongs to a **scope**. Disposing a scope
//! disposes everything it owns, which is how conditional and list rendering
//! tear down the reactive work of rows that leave the screen. Each effect run
//! gets a fresh child scope, disposed before the next run, so anything an
//! effect creates (nested effects, listeners) lives exactly one run.
//!
//! The runtime is single-threaded (thread-local), like the browser it targets.
use std::any::{Any, TypeId};
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::marker::PhantomData;
use std::rc::Rc;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
struct Key {
idx: u32,
generation: u32,
}
/// A generational slab: stale keys never alias a reused slot.
struct Arena<T> {
slots: Vec<(u32, Option<T>)>,
free: Vec<u32>,
}
impl<T> Arena<T> {
const fn new() -> Self {
Arena {
slots: Vec::new(),
free: Vec::new(),
}
}
fn insert(&mut self, value: T) -> Key {
if let Some(idx) = self.free.pop() {
let slot = &mut self.slots[idx as usize];
slot.0 += 1;
slot.1 = Some(value);
return Key {
idx,
generation: slot.0,
};
}
self.slots.push((0, Some(value)));
Key {
idx: (self.slots.len() - 1) as u32,
generation: 0,
}
}
fn get(&self, key: Key) -> Option<&T> {
match self.slots.get(key.idx as usize) {
Some((generation, Some(v))) if *generation == key.generation => Some(v),
_ => None,
}
}
fn get_mut(&mut self, key: Key) -> Option<&mut T> {
match self.slots.get_mut(key.idx as usize) {
Some((generation, Some(v))) if *generation == key.generation => Some(v),
_ => None,
}
}
fn remove(&mut self, key: Key) -> Option<T> {
let slot = self.slots.get_mut(key.idx as usize)?;
if slot.0 != key.generation || slot.1.is_none() {
return None;
}
self.free.push(key.idx);
slot.1.take()
}
}
type Value = Rc<RefCell<Box<dyn Any>>>;
type EffectFn = Rc<RefCell<dyn FnMut()>>;
struct SignalSlot {
value: Value,
subscribers: Vec<Key>,
}
struct EffectSlot {
run: EffectFn,
sources: Vec<Key>,
run_scope: Key,
}
#[derive(Default)]
struct ScopeSlot {
parent: Option<Key>,
children: Vec<Key>,
signals: Vec<Key>,
effects: Vec<Key>,
cleanups: Vec<Box<dyn FnOnce()>>,
contexts: Vec<(TypeId, Rc<dyn Any>)>,
}
struct Runtime {
signals: RefCell<Arena<SignalSlot>>,
effects: RefCell<Arena<EffectSlot>>,
scopes: RefCell<Arena<ScopeSlot>>,
observer: Cell<Option<Key>>,
owner: Cell<Option<Key>>,
batch_depth: Cell<u32>,
flushing: Cell<bool>,
queue: RefCell<VecDeque<Key>>,
}
thread_local! {
static RT: Runtime = const {
Runtime {
signals: RefCell::new(Arena::new()),
effects: RefCell::new(Arena::new()),
scopes: RefCell::new(Arena::new()),
observer: Cell::new(None),
owner: Cell::new(None),
batch_depth: Cell::new(0),
flushing: Cell::new(false),
queue: RefCell::new(VecDeque::new()),
}
};
}
// ── Scopes ──────────────────────────────────────────────────────────────────
/// A handle to an ownership scope. Disposing it disposes every signal, effect,
/// child scope and cleanup created while it was the owner.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Scope(Key);
impl Scope {
/// Dispose the scope and everything it owns. Idempotent.
pub fn dispose(self) {
dispose_scope(self.0);
}
/// Run `f` with this scope as the owner of anything it creates.
pub fn run<R>(self, f: impl FnOnce() -> R) -> R {
with_owner(Some(self.0), f)
}
}
fn new_scope(parent: Option<Key>) -> Key {
let key = RT.with(|rt| {
rt.scopes.borrow_mut().insert(ScopeSlot {
parent,
..ScopeSlot::default()
})
});
if let Some(p) = parent {
RT.with(|rt| {
if let Some(slot) = rt.scopes.borrow_mut().get_mut(p) {
slot.children.push(key);
}
});
}
key
}
fn with_owner<R>(owner: Option<Key>, f: impl FnOnce() -> R) -> R {
let prev = RT.with(|rt| rt.owner.replace(owner));
let out = f();
RT.with(|rt| rt.owner.set(prev));
out
}
/// Create a detached root scope, run `f` inside it and return both.
pub fn create_root<R>(f: impl FnOnce() -> R) -> (R, Scope) {
let key = new_scope(None);
(with_owner(Some(key), f), Scope(key))
}
/// Create a scope owned by the current scope, run `f` inside it, return both.
pub fn child_scope<R>(f: impl FnOnce() -> R) -> (R, Scope) {
let key = new_scope(current_owner_key());
(with_owner(Some(key), f), Scope(key))
}
/// The scope that currently owns new reactive work, if any.
pub fn current_scope() -> Option<Scope> {
current_owner_key().map(Scope)
}
fn current_owner_key() -> Option<Key> {
RT.with(|rt| rt.owner.get())
}
/// Register `f` to run when the current scope is disposed (or, inside an
/// effect, before the effect's next run).
pub fn on_cleanup(f: impl FnOnce() + 'static) {
let Some(owner) = current_owner_key() else {
return;
};
RT.with(|rt| {
if let Some(slot) = rt.scopes.borrow_mut().get_mut(owner) {
slot.cleanups.push(Box::new(f));
}
});
}
fn dispose_scope(key: Key) {
dispose_contents(key);
let parent = RT.with(|rt| rt.scopes.borrow_mut().remove(key).and_then(|s| s.parent));
if let Some(p) = parent {
RT.with(|rt| {
if let Some(slot) = rt.scopes.borrow_mut().get_mut(p) {
slot.children.retain(|c| *c != key);
}
});
}
}
fn dispose_contents(key: Key) {
let taken = RT.with(|rt| {
rt.scopes.borrow_mut().get_mut(key).map(|s| {
(
std::mem::take(&mut s.children),
std::mem::take(&mut s.effects),
std::mem::take(&mut s.signals),
std::mem::take(&mut s.cleanups),
std::mem::take(&mut s.contexts),
)
})
});
let Some((children, effects, signals, cleanups, _contexts)) = taken else {
return;
};
for child in children {
dispose_scope(child);
}
for effect in effects {
dispose_effect(effect);
}
RT.with(|rt| {
let mut arena = rt.signals.borrow_mut();
for s in signals {
arena.remove(s);
}
});
for cleanup in cleanups.into_iter().rev() {
cleanup();
}
}
// ── Context ─────────────────────────────────────────────────────────────────
/// Make `value` available to every descendant of the current scope.
pub fn provide_context<T: 'static>(value: T) {
let Some(owner) = current_owner_key() else {
return;
};
RT.with(|rt| {
if let Some(slot) = rt.scopes.borrow_mut().get_mut(owner) {
slot.contexts.retain(|(t, _)| *t != TypeId::of::<T>());
slot.contexts.push((TypeId::of::<T>(), Rc::new(value)));
}
});
}
/// Look up the nearest provided value of type `T`, walking up the scopes.
pub fn use_context<T: Clone + 'static>() -> Option<T> {
let mut cursor = current_owner_key();
while let Some(key) = cursor {
let (found, parent) = RT.with(|rt| {
let scopes = rt.scopes.borrow();
match scopes.get(key) {
Some(slot) => (
slot.contexts
.iter()
.find(|(t, _)| *t == TypeId::of::<T>())
.and_then(|(_, v)| v.downcast_ref::<T>().cloned()),
slot.parent,
),
None => (None, None),
}
});
if found.is_some() {
return found;
}
cursor = parent;
}
None
}
/// Like [`use_context`] but panics with a clear message when missing.
pub fn expect_context<T: Clone + 'static>() -> T {
use_context::<T>().unwrap_or_else(|| {
panic!(
"akurai-ui: no context of type {} provided",
std::any::type_name::<T>()
)
})
}
// ── Signals ─────────────────────────────────────────────────────────────────
/// A reactive cell. `Copy`, so closures can capture it freely.
pub struct Signal<T> {
key: Key,
_type: PhantomData<fn() -> T>,
}
impl<T> Clone for Signal<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for Signal<T> {}
impl<T> PartialEq for Signal<T> {
fn eq(&self, other: &Self) -> bool {
self.key == other.key
}
}
impl<T> std::fmt::Debug for Signal<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Signal({}v{})", self.key.idx, self.key.generation)
}
}
/// Create a signal owned by the current scope.
pub fn signal<T: 'static>(initial: T) -> Signal<T> {
let key = RT.with(|rt| {
rt.signals.borrow_mut().insert(SignalSlot {
value: Rc::new(RefCell::new(Box::new(initial))),
subscribers: Vec::new(),
})
});
if let Some(owner) = current_owner_key() {
RT.with(|rt| {
if let Some(slot) = rt.scopes.borrow_mut().get_mut(owner) {
slot.signals.push(key);
}
});
}
Signal {
key,
_type: PhantomData,
}
}
impl<T: 'static> Signal<T> {
fn cell(&self) -> Option<Value> {
RT.with(|rt| rt.signals.borrow().get(self.key).map(|s| s.value.clone()))
}
fn track(&self) {
RT.with(|rt| {
let Some(effect) = rt.observer.get() else {
return;
};
if let Some(slot) = rt.signals.borrow_mut().get_mut(self.key) {
if !slot.subscribers.contains(&effect) {
slot.subscribers.push(effect);
}
}
if let Some(slot) = rt.effects.borrow_mut().get_mut(effect) {
if !slot.sources.contains(&self.key) {
slot.sources.push(self.key);
}
}
});
}
/// Whether the signal's scope is still alive.
pub fn is_alive(&self) -> bool {
self.cell().is_some()
}
/// Read through a reference, subscribing the running effect. Returns `None`
/// if the signal was disposed.
pub fn try_with<R>(&self, f: impl FnOnce(&T) -> R) -> Option<R> {
self.track();
let cell = self.cell()?;
let guard = cell.borrow();
guard.downcast_ref::<T>().map(f)
}
/// Read through a reference, subscribing the running effect.
pub fn with<R>(&self, f: impl FnOnce(&T) -> R) -> R {
self.try_with(f)
.expect("akurai-ui: read of a disposed signal")
}
/// Read through a reference without subscribing.
pub fn with_untracked<R>(&self, f: impl FnOnce(&T) -> R) -> R {
untrack(|| self.with(f))
}
/// Clone the value out, subscribing the running effect.
pub fn get(&self) -> T
where
T: Clone,
{
self.with(T::clone)
}
/// Clone the value out without subscribing.
pub fn get_untracked(&self) -> T
where
T: Clone,
{
self.with_untracked(T::clone)
}
/// Replace the value and notify subscribers. A no-op on a disposed signal,
/// so late callbacks (a fetch finishing after navigation) are harmless.
pub fn set(&self, value: T) {
self.update(move |v| *v = value);
}
/// Replace the value only if it differs, notifying only on change.
pub fn set_if_changed(&self, value: T)
where
T: PartialEq,
{
let same = self
.cell()
.map(|c| c.borrow().downcast_ref::<T>() == Some(&value))
.unwrap_or(true);
if !same {
self.set(value);
}
}
/// Mutate in place and notify subscribers.
pub fn update(&self, f: impl FnOnce(&mut T)) {
let Some(cell) = self.cell() else {
return;
};
{
let mut guard = cell.borrow_mut();
if let Some(v) = guard.downcast_mut::<T>() {
f(v);
}
}
self.notify();
}
fn notify(&self) {
let subscribers = RT.with(|rt| {
rt.signals
.borrow()
.get(self.key)
.map(|s| s.subscribers.clone())
.unwrap_or_default()
});
RT.with(|rt| {
let mut queue = rt.queue.borrow_mut();
for e in subscribers {
if !queue.contains(&e) {
queue.push_back(e);
}
}
});
maybe_flush();
}
}
// ── Effects ─────────────────────────────────────────────────────────────────
/// Run `f` now and again whenever a signal it read changes.
pub fn effect(f: impl FnMut() + 'static) {
let owner = current_owner_key();
let run_scope = new_scope(owner);
let key = RT.with(|rt| {
rt.effects.borrow_mut().insert(EffectSlot {
run: Rc::new(RefCell::new(f)),
sources: Vec::new(),
run_scope,
})
});
if let Some(owner) = owner {
RT.with(|rt| {
if let Some(slot) = rt.scopes.borrow_mut().get_mut(owner) {
slot.effects.push(key);
}
});
}
run_effect(key);
maybe_flush();
}
fn unsubscribe(effect: Key) -> Option<Key> {
let (sources, run_scope) = RT.with(|rt| {
rt.effects
.borrow_mut()
.get_mut(effect)
.map(|s| (std::mem::take(&mut s.sources), s.run_scope))
})?;
RT.with(|rt| {
let mut signals = rt.signals.borrow_mut();
for s in sources {
if let Some(slot) = signals.get_mut(s) {
slot.subscribers.retain(|e| *e != effect);
}
}
});
Some(run_scope)
}
fn dispose_effect(effect: Key) {
if let Some(run_scope) = unsubscribe(effect) {
dispose_scope(run_scope);
}
RT.with(|rt| rt.effects.borrow_mut().remove(effect));
}
fn run_effect(effect: Key) {
let Some(run_scope) = unsubscribe(effect) else {
return;
};
dispose_contents(run_scope);
let Some(run) = RT.with(|rt| rt.effects.borrow().get(effect).map(|s| s.run.clone())) else {
return;
};
RT.with(|rt| rt.batch_depth.set(rt.batch_depth.get() + 1));
let prev_observer = RT.with(|rt| rt.observer.replace(Some(effect)));
with_owner(Some(run_scope), || (run.borrow_mut())());
RT.with(|rt| {
rt.observer.set(prev_observer);
rt.batch_depth.set(rt.batch_depth.get() - 1);
});
}
fn maybe_flush() {
let idle = RT.with(|rt| rt.batch_depth.get() == 0 && !rt.flushing.get());
if !idle {
return;
}
RT.with(|rt| rt.flushing.set(true));
while let Some(next) = RT.with(|rt| rt.queue.borrow_mut().pop_front()) {
run_effect(next);
}
RT.with(|rt| rt.flushing.set(false));
}
/// Group several writes so dependent effects run once, after `f`.
pub fn batch<R>(f: impl FnOnce() -> R) -> R {
RT.with(|rt| rt.batch_depth.set(rt.batch_depth.get() + 1));
let out = f();
RT.with(|rt| rt.batch_depth.set(rt.batch_depth.get() - 1));
maybe_flush();
out
}
/// Run `f` without subscribing the surrounding effect to what it reads.
pub fn untrack<R>(f: impl FnOnce() -> R) -> R {
let prev = RT.with(|rt| rt.observer.replace(None));
let out = f();
RT.with(|rt| rt.observer.set(prev));
out
}
/// A derived value that only notifies when its result actually changes.
pub fn memo<T: Clone + PartialEq + 'static>(f: impl Fn() -> T + 'static) -> Signal<T> {
let out = signal(untrack(&f));
effect(move || {
let next = f();
untrack(|| out.set_if_changed(next));
});
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effect_reruns_on_change_only_for_read_signals() {
create_root(|| {
let a = signal(1);
let b = signal(10);
let seen = Rc::new(RefCell::new(Vec::new()));
let log = seen.clone();
effect(move || log.borrow_mut().push(a.get()));
a.set(2);
b.set(11);
assert_eq!(*seen.borrow(), vec![1, 2]);
});
}
#[test]
fn dependencies_are_recomputed_each_run() {
create_root(|| {
let flag = signal(true);
let a = signal("a");
let b = signal("b");
let runs = Rc::new(Cell::new(0));
let r = runs.clone();
effect(move || {
r.set(r.get() + 1);
if flag.get() {
a.get();
} else {
b.get();
}
});
flag.set(false);
a.set("a2");
assert_eq!(runs.get(), 2, "a is no longer a dependency");
b.set("b2");
assert_eq!(runs.get(), 3);
});
}
#[test]
fn batch_coalesces_runs() {
create_root(|| {
let a = signal(0);
let b = signal(0);
let runs = Rc::new(Cell::new(0));
let r = runs.clone();
effect(move || {
a.get();
b.get();
r.set(r.get() + 1);
});
batch(|| {
a.set(1);
b.set(1);
});
assert_eq!(runs.get(), 2);
});
}
#[test]
fn effect_writing_its_own_source_does_not_recurse_forever() {
create_root(|| {
let n = signal(0);
effect(move || {
if n.get() < 3 {
n.set(n.get_untracked() + 1);
}
});
assert_eq!(n.get_untracked(), 3);
});
}
#[test]
fn disposing_a_scope_stops_effects_and_runs_cleanups() {
let ((), root) = create_root(|| {});
let source = root.run(|| signal(0));
let runs = Rc::new(Cell::new(0));
let cleaned = Rc::new(Cell::new(false));
let ((), child) = root.run(|| {
child_scope(|| {
let r = runs.clone();
let c = cleaned.clone();
effect(move || {
source.get();
r.set(r.get() + 1);
});
on_cleanup(move || c.set(true));
})
});
child.dispose();
source.set(1);
assert_eq!(runs.get(), 1);
assert!(cleaned.get());
root.dispose();
assert!(!source.is_alive());
}
#[test]
fn nested_effects_are_disposed_on_rerun() {
create_root(|| {
let outer = signal(0);
let inner = signal(0);
let inner_runs = Rc::new(Cell::new(0));
let r = inner_runs.clone();
effect(move || {
outer.get();
let r = r.clone();
effect(move || {
inner.get();
r.set(r.get() + 1);
});
});
outer.set(1);
assert_eq!(inner_runs.get(), 2);
inner.set(1);
assert_eq!(inner_runs.get(), 3, "only one live inner effect");
});
}
#[test]
fn memo_notifies_only_on_change() {
create_root(|| {
let n = signal(1);
let even = memo(move || n.get() % 2 == 0);
let runs = Rc::new(Cell::new(0));
let r = runs.clone();
effect(move || {
even.get();
r.set(r.get() + 1);
});
n.set(3);
assert_eq!(runs.get(), 1);
n.set(4);
assert_eq!(runs.get(), 2);
});
}
#[test]
fn context_is_inherited_by_child_scopes() {
create_root(|| {
provide_context(42u32);
let (found, _) = child_scope(|| child_scope(use_context::<u32>).0);
assert_eq!(found, Some(42));
assert_eq!(use_context::<String>(), None);
});
}
#[test]
fn writes_to_disposed_signals_are_ignored() {
let (s, root) = create_root(|| signal(1));
root.dispose();
s.set(2);
assert_eq!(s.try_with(|v| *v), None);
}
}