AkurAI Build
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);
    }
}