Skip to main content

resuma/core/
signal.rs

1//! Fine-grained reactive primitives.
2//!
3//! Signals are the unit of reactivity. They have a stable id assigned by the
4//! current `RenderContext` so that the SSR pass can serialize them and the
5//! client runtime can match them up by id.
6
7use std::sync::Arc;
8
9use parking_lot::RwLock;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use super::context::current_context;
14
15/// Globally unique id of a reactive primitive within a single render pass.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17pub struct SignalId(pub u32);
18
19impl std::fmt::Display for SignalId {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        write!(f, "s{}", self.0)
22    }
23}
24
25/// Inner state shared by every clone of a `Signal<T>`.
26struct SignalInner<T> {
27    id: SignalId,
28    value: RwLock<T>,
29    /// Subscribers tracked during effect execution. On the server we only
30    /// care about *which* effects depend on this signal — actual notification
31    /// is performed by [`super::effect::Effect::trigger`].
32    subscribers: RwLock<Vec<u32>>,
33}
34
35/// A reactive cell whose changes notify subscribers. Cheap to clone (Arc).
36pub struct Signal<T> {
37    inner: Arc<SignalInner<T>>,
38}
39
40impl<T> Clone for Signal<T> {
41    fn clone(&self) -> Self {
42        Self {
43            inner: self.inner.clone(),
44        }
45    }
46}
47
48impl<T> Signal<T>
49where
50    T: Clone + Serialize + 'static,
51{
52    /// Create a new signal. Allocates a fresh `SignalId` from the active
53    /// `RenderContext` (or a fallback global counter when called outside of
54    /// SSR — useful in unit tests).
55    pub fn new(initial: T) -> Self {
56        let id = current_context()
57            .map(|ctx| ctx.next_signal_id())
58            .unwrap_or_else(fallback_id);
59
60        let signal = Self {
61            inner: Arc::new(SignalInner {
62                id,
63                value: RwLock::new(initial),
64                subscribers: RwLock::new(Vec::new()),
65            }),
66        };
67
68        if let Some(ctx) = current_context() {
69            ctx.register_signal(id, signal.serialize_value());
70        }
71
72        signal
73    }
74
75    pub fn id(&self) -> SignalId {
76        self.inner.id
77    }
78
79    /// Read the current value (without dependency tracking).
80    pub fn peek(&self) -> T {
81        self.inner.value.read().clone()
82    }
83
84    /// Read the current value and register the active effect (if any) as a
85    /// dependency.
86    pub fn get(&self) -> T {
87        self.track();
88        self.peek()
89    }
90
91    /// Replace the current value and notify subscribers.
92    pub fn set(&self, value: T) {
93        *self.inner.value.write() = value;
94        self.notify();
95    }
96
97    /// Functional update — read, modify, write — atomically.
98    pub fn update<F>(&self, f: F)
99    where
100        F: FnOnce(&mut T),
101    {
102        let mut guard = self.inner.value.write();
103        f(&mut guard);
104        drop(guard);
105        self.notify();
106    }
107
108    fn track(&self) {
109        if let Some(ctx) = current_context() {
110            if let Some(eid) = ctx.current_effect_id() {
111                ctx.record_effect_dep(eid, self.inner.id);
112                let mut subs = self.inner.subscribers.write();
113                if !subs.contains(&eid) {
114                    subs.push(eid);
115                }
116            }
117        }
118    }
119
120    fn notify(&self) {
121        if let Some(ctx) = current_context() {
122            let subs = self.inner.subscribers.read().clone();
123            for eid in subs {
124                ctx.run_effect(eid);
125            }
126            ctx.update_signal(self.inner.id, self.serialize_value());
127        }
128    }
129
130    fn serialize_value(&self) -> Value {
131        serde_json::to_value(&*self.inner.value.read()).unwrap_or(Value::Null)
132    }
133
134    /// Split into a read-only and a write-only handle.
135    pub fn split(self) -> (ReadSignal<T>, WriteSignal<T>) {
136        (
137            ReadSignal {
138                signal: self.clone(),
139            },
140            WriteSignal { signal: self },
141        )
142    }
143}
144
145/// Read half of a signal returned by [`Signal::split`].
146#[derive(Clone)]
147pub struct ReadSignal<T> {
148    signal: Signal<T>,
149}
150
151impl<T: Clone + Serialize + 'static> ReadSignal<T> {
152    pub fn id(&self) -> SignalId {
153        self.signal.id()
154    }
155    pub fn get(&self) -> T {
156        self.signal.get()
157    }
158    pub fn peek(&self) -> T {
159        self.signal.peek()
160    }
161}
162
163/// Write half of a signal returned by [`Signal::split`].
164#[derive(Clone)]
165pub struct WriteSignal<T> {
166    signal: Signal<T>,
167}
168
169impl<T: Clone + Serialize + 'static> WriteSignal<T> {
170    pub fn id(&self) -> SignalId {
171        self.signal.id()
172    }
173    pub fn set(&self, value: T) {
174        self.signal.set(value)
175    }
176    pub fn update<F>(&self, f: F)
177    where
178        F: FnOnce(&mut T),
179    {
180        self.signal.update(f)
181    }
182}
183
184/// Create a reactive signal.
185///
186/// `signal(0)` is the concise constructor recommended for application code.
187/// `use_signal(0)` remains available as the hook-style alias.
188pub fn signal<T: Clone + Serialize + 'static>(initial: T) -> Signal<T> {
189    Signal::new(initial)
190}
191
192/// Hook-style alias for [`signal`].
193pub fn use_signal<T: Clone + Serialize + 'static>(initial: T) -> Signal<T> {
194    signal(initial)
195}
196
197fn fallback_id() -> SignalId {
198    use std::sync::atomic::{AtomicU32, Ordering};
199    static COUNTER: AtomicU32 = AtomicU32::new(1_000_000);
200    SignalId(COUNTER.fetch_add(1, Ordering::Relaxed))
201}