rustolio_web/hooks/signal/mod.rs
1//
2// SPDX-License-Identifier: MPL-2.0
3//
4// Copyright (c) 2026 Tobias Binnewies. All rights reserved.
5//
6// This Source Code Form is subject to the terms of the Mozilla Public
7// License, v. 2.0. If a copy of the MPL was not distributed with this
8// file, You can obtain one at http://mozilla.org/MPL/2.0/.
9//
10
11mod store;
12
13use super::{Dependency, Scope};
14
15pub(super) use store::SignalStore;
16
17pub trait SignalBase<T> {
18 /// Returns the underlying signal of the concrete implementation.
19 ///
20 /// This is used the implement all other common signal methods, but can be useful to "flatten" other Hooks so they can be used as a "standard" signal
21 fn base(&self) -> Signal<T>;
22
23 /// Should only called using the [`global!`] macro.
24 ///
25 /// # Unsafe
26 /// Breaks if this is not called directly after signal creation.
27 ///
28 /// [`global!`]: crate::prelude::global!
29 #[doc(hidden)]
30 unsafe fn globalize(&self) {
31 unsafe {
32 // SAFETY: Caller is responsible for safe call.
33 Scope::deregister_signal(self.base().slot)
34 };
35 }
36}
37
38pub trait SignalGetter<T>: SignalBase<T>
39where
40 T: Clone + 'static,
41{
42 // /// Calls all dependencies of the signal without changing its value.
43 // fn refresh(&self) {
44 // let s = self.signal();
45 // SignalStore::refresh(s.slot);
46 // }
47
48 /// Returns the current value of the signal without tracking updates.
49 ///
50 /// If updates should be tracked, use [`Self::value()`].
51 #[track_caller]
52 fn peek(&self) -> T {
53 let s = self.base();
54 SignalStore::read::<T>(s.slot)
55 }
56
57 /// Return the value of the signal and appends it to the current scope's dependencies.
58 /// If the values changes, the scope gets reexecuted to update to the new value.
59 ///
60 /// If update should not be tracked, use [`Self::peek()`].
61 #[track_caller]
62 fn value(&self) -> T {
63 let s = self.base();
64
65 #[cfg(debug_assertions)]
66 if s.is_immutable() {
67 panic!(
68 r#"
69A signal value was accessed within the same scope in which it was created, so the signal itself will be recreated with every update. This makes the signal effectively immutable.
70
71Wrap the signal access in a dynamic element (e.g., `move || my_signal.value()`) to fix this.
72 "#
73 );
74 }
75
76 SignalStore::update_read(s.slot)
77 }
78}
79
80pub trait SignalSetter<T>: SignalBase<T>
81where
82 T: PartialEq + 'static,
83{
84 /// Sets the value of the signal and notifies all dependencies.
85 ///
86 /// If the value is the same as the current value, nothing happens.
87 ///
88 /// Returns the old value of the signal.
89 #[track_caller]
90 fn set(&self, value: impl Into<T>) -> T {
91 let s = self.base();
92 SignalStore::set(s.slot, value.into())
93 }
94}
95
96pub trait SignalUpdater<T>: SignalBase<T>
97where
98 T: 'static,
99{
100 /// Updates the signal value without cloning it using the provided updater function and notifies all dependencies.
101 /// This will happen, no matter if the value acually changed or not.
102 ///
103 /// Returns whatever the updater function returns.
104 ///
105 /// # Panic
106 /// This will panic if other signals are read or created in the updater closure.
107 /// Do this beforehand if its needed.
108 #[track_caller]
109 fn update<U>(&self, updater: impl FnOnce(&mut T) -> U) -> U {
110 let s = self.base();
111 SignalStore::update(s.slot, updater)
112 }
113
114 /// Sets the value of the signal and notifies all its dependencies.
115 ///
116 /// This does not check if the value is the same as the current value.
117 #[track_caller]
118 fn set_unchecked(&self, value: impl Into<T>) {
119 let s = self.base();
120 SignalStore::set_unchecked(s.slot, value.into());
121 }
122}
123
124pub trait SignalToggle: SignalUpdater<bool> {
125 #[track_caller]
126 fn toggle(&self) -> bool {
127 self.update(|s| {
128 let old = *s;
129 *s = !old;
130 old
131 })
132 }
133}
134impl<S> SignalToggle for S where S: SignalUpdater<bool> {}
135
136#[derive(Debug, PartialEq, Eq)]
137pub struct Signal<T> {
138 pub(super) slot: usize,
139 _value: std::marker::PhantomData<T>,
140
141 #[cfg(debug_assertions)]
142 always_mutable: bool,
143}
144
145impl<T> SignalBase<T> for Signal<T> {
146 fn base(&self) -> Signal<T> {
147 *self
148 }
149}
150impl<T> SignalGetter<T> for Signal<T> where T: Clone + 'static {}
151impl<T> SignalSetter<T> for Signal<T> where T: PartialEq + 'static {}
152impl<T> SignalUpdater<T> for Signal<T> where T: 'static {}
153
154// Impl `Clone` and `Copy` manually to avoid requiring `T: Clone + Copy`
155impl<T> Clone for Signal<T> {
156 fn clone(&self) -> Self {
157 *self
158 }
159}
160impl<T> Copy for Signal<T> {}
161
162impl<T> Default for Signal<T>
163where
164 T: Default + 'static,
165{
166 fn default() -> Self {
167 Self::new(T::default())
168 }
169}
170
171impl<T> Signal<T>
172where
173 T: 'static,
174{
175 /// Creates a new signal with the given initial value.
176 ///
177 /// A signal is a reactive value that can be read and updated. When a signal is updated, all function components that read the signal will be re-run to reflect the new value.
178 ///
179 /// The signal is removed when the component which created it goes out of scope.
180 ///
181 /// # Example
182 /// ```
183 /// use rustolio_web::prelude::*;
184 ///
185 /// let s = Signal::new("Hello World".to_string());
186 /// div! {
187 /// class: "container",
188 /// move || s.value(),
189 /// };
190 /// ```
191 ///
192 /// # Caveats
193 /// All caveats will throw an runtime error in debug mode.
194 ///
195 /// ## Immutable Signals
196 /// When creating a signal, it is important to ensure that the signal is read in a function component (e.g., `move || s.value()`). If this is not done, the signal itself will be recreated on every update, making it effectively immutable.
197 ///
198 /// Dont do this:
199 /// ``` ignore
200 /// use rustolio_web::prelude::*;
201 ///
202 /// let s = Signal::new("Hello World".to_string());
203 /// div! {
204 /// s.value()
205 /// };
206 /// ```
207 /// Do this instead:
208 /// ```
209 /// use rustolio_web::prelude::*;
210 ///
211 /// let s = Signal::new("Hello World".to_string());
212 /// div! {
213 /// move || s.value()
214 /// };
215 /// // OR
216 /// move || div! {
217 /// s.value()
218 /// };
219 /// ```
220 ///
221 /// ## Breaking Signals
222 /// When reading a signal value, it is important to ensure that the next component is not a function component. This would break the signal's updater, as it would replace the component without notifying the signal's updater.
223 ///
224 /// Dont do this:
225 /// ``` ignore
226 /// use rustolio_web::prelude::*;
227 ///
228 /// let s = Signal::new("Hello World".to_string());
229 /// let s1 = Signal::new("Hello World 2".to_string());
230 /// div! {
231 /// move || {
232 /// let value = s.value();
233 /// move || div! {
234 /// format!("S: {} - S1: {}", value, s1.value())
235 /// }
236 /// }
237 /// };
238 /// ```
239 /// Do this instead:
240 /// ```
241 /// use rustolio_web::prelude::*;
242 ///
243 /// let s = Signal::new("Hello World".to_string());
244 /// let s1 = Signal::new("Hello World 2".to_string());
245 /// div! {
246 /// move || {
247 /// let value = s.value();
248 /// div! {
249 /// move || div! {
250 /// format!("S: {} - S1: {}", value, s1.value())
251 /// }
252 /// }
253 /// }
254 /// };
255 /// ```
256 ///
257 pub fn new(initial: T) -> Self {
258 let slot = SignalStore::insert(initial);
259 Scope::register_signal(slot);
260 Signal {
261 slot,
262 _value: std::marker::PhantomData,
263 #[cfg(debug_assertions)]
264 always_mutable: false,
265 }
266 }
267
268 /// Creates the Signal (same as [`Self::new`]) but the check for immutablility will alway succeed.
269 ///
270 /// This can be useful for Signal-Wrapper which are allowed the be read in the same scope.
271 pub fn always_mutable(initial: T) -> Self {
272 let slot = SignalStore::insert(initial);
273 Scope::register_signal(slot);
274 Signal {
275 slot,
276 _value: std::marker::PhantomData,
277 #[cfg(debug_assertions)]
278 always_mutable: true,
279 }
280 }
281
282 /// Creates a new signal without setting an initial value.
283 ///
284 /// This is unsafe because reading, updating or setting the signal before it has been initialized will cause a panic.
285 ///
286 /// The signal must be set by using the [`Self::set_unchecked`] method before using any other method.
287 pub(super) unsafe fn empty_always_mutable(scoped: bool) -> Self {
288 let slot = SignalStore::insert(());
289 if scoped {
290 Scope::register_signal(slot);
291 }
292 Signal {
293 slot,
294 _value: std::marker::PhantomData,
295 #[cfg(debug_assertions)]
296 always_mutable: true,
297 }
298 }
299
300 pub(super) fn from_slot(slot: usize) -> Self {
301 Signal {
302 slot,
303 _value: std::marker::PhantomData,
304 #[cfg(debug_assertions)]
305 always_mutable: false,
306 }
307 }
308
309 #[cfg(debug_assertions)]
310 fn is_immutable(&self) -> bool {
311 !self.always_mutable && Scope::signal_created_in_current_scope(*self)
312 }
313
314 // #[cfg(debug_assertions)]
315 // #[track_caller]
316 // fn check_breaking_read(&self) -> Option<HashSet<SignalReadInfo>> {
317 // BreakingSignalChecker::signal_read(self.slot)
318 // }
319}
320
321#[derive(Debug, PartialEq, Eq)]
322pub struct GlobalSignal<T>(Signal<T>);
323
324impl<T> GlobalSignal<T>
325where
326 T: 'static,
327{
328 /// Will not be removed when the component which created it goes out of scope.
329 ///
330 /// This is useful for signals that should be globally accessible.
331 pub fn new(initial: T) -> Self {
332 let slot = SignalStore::insert(initial);
333 Self(Signal {
334 slot,
335 _value: std::marker::PhantomData,
336 #[cfg(debug_assertions)]
337 always_mutable: true,
338 })
339 }
340}
341
342impl<T> Default for GlobalSignal<T>
343where
344 T: Default + 'static,
345{
346 fn default() -> Self {
347 Self::new(T::default())
348 }
349}
350
351impl<T> SignalBase<T> for GlobalSignal<T> {
352 fn base(&self) -> Signal<T> {
353 self.0
354 }
355 unsafe fn globalize(&self) {
356 // Nothing to do - the underlying signal was never registered
357 }
358}
359impl<T> SignalGetter<T> for GlobalSignal<T> where T: Clone + 'static {}
360impl<T> SignalSetter<T> for GlobalSignal<T> where T: PartialEq + 'static {}
361impl<T> SignalUpdater<T> for GlobalSignal<T> where T: 'static {}
362
363impl<T> From<GlobalSignal<T>> for Signal<T>
364where
365 T: Clone + 'static,
366{
367 fn from(val: GlobalSignal<T>) -> Self {
368 val.base()
369 }
370}
371
372// Impl `Clone` and `Copy` manually to avoid requiring `T: Clone + Copy`
373impl<T> Clone for GlobalSignal<T> {
374 fn clone(&self) -> Self {
375 *self
376 }
377}
378impl<T> Copy for GlobalSignal<T> {}
379
380#[cfg(test)]
381mod tests {
382 use crate::prelude::*;
383
384 #[test]
385 fn test_signal() {
386 let s = Signal::new(0);
387 assert_eq!(s.value(), 0);
388 assert_eq!(s.peek(), 0);
389 let old = s.set(42);
390 assert_eq!(old, 0);
391 assert_eq!(s.value(), 42);
392 assert_eq!(s.peek(), 42);
393 let res = s.update(|v| {
394 *v += 1;
395 "Some random return value"
396 });
397 assert_eq!(res, "Some random return value");
398 assert_eq!(s.value(), 43);
399 assert_eq!(s.peek(), 43);
400 s.set_unchecked(100);
401 assert_eq!(s.value(), 100);
402 assert_eq!(s.peek(), 100);
403
404 // Test that signal impls `Copy` with `T: !Copy`
405 let s = Signal::new("Hello".to_string());
406 let _s1 = s;
407 let _s2 = s;
408
409 // Test that signal impls `Copy` with `T: !Copy`
410 let s = GlobalSignal::new("Hello".to_string());
411 let _s1 = s;
412 let _s2 = s;
413 }
414}