Skip to main content

tpt_appfront_core/
store.rs

1//! State management at scale — a Zustand/Redux-like [`Store`] built directly on
2//! the reactive [`Signal`] core, plus optional devtools time-travel and signal
3//! persistence (`localStorage` / `IndexedDB`).
4//!
5//! A [`Store`] owns a single `Signal<S>` of your app state. Components read it
6//! through [`Store::state`] (a `Signal<S>`) and update it through [`Store::set`]
7//! / [`Store::update`]. Because the store is just a `Signal`, any effect that
8//! reads it re-runs on change — the same reactivity model as `Signal`/`memo`,
9//! but with a single named, subscribable source of truth.
10//!
11//! Three scaling features layer on top of that core:
12//!
13//! * **Subscriptions** — [`Store::subscribe`] gives a plain callback API for
14//!   non-reactive consumers (logging, analytics, IPC to a webview).
15//! * **Time-travel** — [`Store::with_time_travel`] records every committed
16//!   state into a bounded ring buffer; [`Store::undo`]/[`Store::redo`] (and the
17//!   [`crate::devtools`] integration) rewind/replay it. This is the devtools
18//!   "time-travel" debugging story.
19//! * **Persistence** — [`Store::with_persistence`] binds the store to a
20//!   [`Persistence`] backend (e.g. `localStorage` on wasm). State is hydrated on
21//!   creation and written (synchronously, per commit) on every change;
22//!   [`Store::persist_now`] flushes explicitly.
23//!
24//! The store is backend-agnostic: `Persistence` is a trait, and the only
25//! provided impl (`WebStorage`) is gated behind `target_arch = "wasm32"`, so
26//! `appfront-core` keeps building natively.
27
28use crate::signal::{create_effect, EffectHandle, Signal};
29use std::rc::Rc;
30
31/// Shared, mutable list of plain-callback subscribers. Wrapped in `Rc<RefCell>`
32/// so a dropped [`StoreSubscription`] can remove its own entry by holding a
33/// shared handle to the exact same vec (see [`Store::subscribe`]).
34type SubscriberList<S> = Rc<std::cell::RefCell<Vec<Rc<dyn Fn(&S)>>>>;
35
36/// A store of application state `S`, built on a reactive [`Signal`].
37///
38/// Cheap to clone (`Rc`-backed); every clone shares the same underlying state
39/// and subscription list, so providing the store to a subtree and updating it
40/// from anywhere updates every consumer.
41pub struct Store<S> {
42    state: Signal<S>,
43    inner: Rc<StoreInner<S>>,
44}
45
46struct StoreInner<S> {
47    /// Plain-callback subscribers, notified (synchronously) on every commit.
48    /// Wrapped in `Rc` so a dropped [`StoreSubscription`] can remove its own
49    /// entry by holding a shared handle to the exact same vec.
50    subscribers: SubscriberList<S>,
51    /// Time-travel ring buffer, when enabled.
52    history: std::cell::RefCell<Option<HistoryBuf<S>>>,
53    /// Optional persistence binding, when enabled.
54    persistence: std::cell::RefCell<Option<Box<dyn Persistence<S>>>>,
55}
56
57struct HistoryBuf<S> {
58    past: Vec<S>,
59    /// Index into `past` of the currently-active state (so undo/redo move the
60    /// cursor without dropping the redone entries until a new commit happens).
61    cursor: usize,
62    limit: usize,
63}
64
65impl<S: Clone + 'static> Store<S> {
66    /// Creates a store from an initial state.
67    pub fn new(initial: S) -> Self {
68        Store {
69            state: Signal::new(initial),
70            inner: Rc::new(StoreInner {
71                subscribers: SubscriberList::default(),
72                history: std::cell::RefCell::new(None),
73                persistence: std::cell::RefCell::new(None),
74            }),
75        }
76    }
77
78    /// Enables devtools time-travel with a bounded history of `limit` commits.
79    pub fn with_time_travel(self, limit: usize) -> Self {
80        // Seed the history with the current state so `undo` can always return
81        // to it; `past[cursor]` is always the *current* state.
82        let initial = self.state.get();
83        *self.inner.history.borrow_mut() = Some(HistoryBuf {
84            past: vec![initial],
85            cursor: 0,
86            limit: limit.max(1),
87        });
88        self
89    }
90
91    /// Binds the store to a persistence backend (hydrating from it first).
92    pub fn with_persistence(self, backend: Box<dyn Persistence<S>>) -> Self {
93        if let Some(restored) = backend.load() {
94            self.state.set(restored);
95        }
96        *self.inner.persistence.borrow_mut() = Some(backend);
97        self
98    }
99
100    /// The underlying state signal — read it inside effects/views to subscribe.
101    pub fn signal(&self) -> Signal<S> {
102        self.state.clone()
103    }
104
105    /// Reads the current state (clones it).
106    pub fn get(&self) -> S {
107        self.state.get()
108    }
109
110    /// Replaces the state with `next`, notifying subscribers and recording the
111    /// previous state in the time-travel history (if enabled).
112    pub fn set(&self, next: S) {
113        self.commit(next);
114    }
115
116    /// Applies `f` to the current state and commits the result.
117    pub fn update(&self, f: impl FnOnce(&S) -> S) {
118        let next = f(&self.state.get());
119        self.commit(next);
120    }
121
122    fn commit(&self, next: S) {
123        // Record the *new* state into history so `past[cursor]` is always the
124        // current state; `undo` moves the cursor back, `redo` moves it forward.
125        if let Some(hist) = self.inner.history.borrow_mut().as_mut() {
126            hist.past.truncate(hist.cursor + 1);
127            hist.past.push(next.clone());
128            if hist.past.len() > hist.limit {
129                hist.past.remove(0);
130            }
131            hist.cursor = hist.past.len() - 1;
132        }
133        self.state.set(next);
134        self.notify();
135    }
136
137    fn notify(&self) {
138        let subs = self.inner.subscribers.borrow();
139        let s = self.state.get();
140        for sub in subs.iter() {
141            sub(&s);
142        }
143        if let Some(p) = self.inner.persistence.borrow().as_ref() {
144            p.save(&s);
145        }
146    }
147
148    /// Subscribes `cb` to every committed state change. Returns a handle whose
149    /// drop removes the subscription.
150    pub fn subscribe(&self, cb: impl Fn(&S) + 'static) -> StoreSubscription<S> {
151        let cb = Rc::new(cb) as Rc<dyn Fn(&S)>;
152        self.inner.subscribers.borrow_mut().push(Rc::clone(&cb));
153        StoreSubscription {
154            subscribers: std::rc::Rc::clone(&self.inner.subscribers),
155            cb,
156        }
157    }
158
159    /// Whether an `undo` would return to a previous committed state.
160    pub fn can_undo(&self) -> bool {
161        self.inner
162            .history
163            .borrow()
164            .as_ref()
165            .map(|h| h.cursor > 0)
166            .unwrap_or(false)
167    }
168
169    /// Whether a `redo` would replay a previously undone state.
170    pub fn can_redo(&self) -> bool {
171        self.inner
172            .history
173            .borrow()
174            .as_ref()
175            .map(|h| h.cursor + 1 < h.past.len())
176            .unwrap_or(false)
177    }
178
179    /// Rewinds to the previous committed state (time-travel). No-op when there
180    /// is nothing to undo.
181    pub fn undo(&self) {
182        let mut hist = self.inner.history.borrow_mut();
183        if let Some(h) = hist.as_mut() {
184            if h.cursor > 0 {
185                h.cursor -= 1;
186                let prev = h.past[h.cursor].clone();
187                drop(hist);
188                self.state.set(prev);
189                self.notify_without_history();
190            }
191        }
192    }
193
194    /// Replays the next (previously undone) state. No-op when nothing to redo.
195    pub fn redo(&self) {
196        let mut hist = self.inner.history.borrow_mut();
197        if let Some(h) = hist.as_mut() {
198            if h.cursor + 1 < h.past.len() {
199                h.cursor += 1;
200                let next = h.past[h.cursor].clone();
201                drop(hist);
202                self.state.set(next);
203                self.notify_without_history();
204            }
205        }
206    }
207
208    /// Notifies subscribers/persistence without recording into history (used by
209    /// undo/redo, which *move the cursor* rather than commit a new state).
210    fn notify_without_history(&self) {
211        let subs = self.inner.subscribers.borrow();
212        let s = self.state.get();
213        for sub in subs.iter() {
214            sub(&s);
215        }
216        if let Some(p) = self.inner.persistence.borrow().as_ref() {
217            p.save(&s);
218        }
219    }
220
221    /// Flushes the current state to the persistence backend immediately (the
222    /// normal path also writes on every commit, but this is the explicit hook
223    /// for "save now", e.g. on window `CloseRequested`).
224    pub fn persist_now(&self) {
225        if let Some(p) = self.inner.persistence.borrow().as_ref() {
226            p.save(&self.state.get());
227        }
228    }
229}
230
231/// Handle returned by [`Store::subscribe`]; dropping it unsubscribes.
232pub struct StoreSubscription<S> {
233    subscribers: SubscriberList<S>,
234    cb: Rc<dyn Fn(&S)>,
235}
236
237impl<S> Drop for StoreSubscription<S> {
238    fn drop(&mut self) {
239        let mut subs = self.subscribers.borrow_mut();
240        subs.retain(|other| !Rc::ptr_eq(other, &self.cb));
241    }
242}
243
244/// A persistence backend for a store. Implemented by `WebStorage` (wasm) and by
245/// tests with an in-memory backend (see `MemoryStorage` in the test module).
246pub trait Persistence<S>: 'static {
247    /// Loads the persisted state, or `None` if nothing is stored / unparseable.
248    fn load(&self) -> Option<S>;
249    /// Writes the current state.
250    fn save(&self, state: &S);
251}
252
253#[cfg(target_arch = "wasm32")]
254/// A [`Persistence`] backend backed by the browser `localStorage` (wasm only).
255/// State is serialised as JSON via `serde`, so `S` must be `Serialize +
256/// DeserializeOwned`. `IndexedDB` is the recommended choice for large state;
257/// this `localStorage` binding is the simple synchronous option.
258pub struct WebStorage {
259    key: String,
260}
261
262#[cfg(target_arch = "wasm32")]
263impl WebStorage {
264    /// Creates a `localStorage` binding under `key`.
265    pub fn new(key: &str) -> Self {
266        WebStorage {
267            key: key.to_string(),
268        }
269    }
270}
271
272#[cfg(target_arch = "wasm32")]
273impl<S: serde::Serialize + serde::de::DeserializeOwned + 'static> Persistence<S> for WebStorage {
274    fn load(&self) -> Option<S> {
275        let window = web_sys::window()?;
276        let storage = window.local_storage().ok().flatten()?;
277        let raw = storage.get_item(&self.key).ok().flatten()?;
278        serde_json::from_str(&raw).ok()
279    }
280
281    fn save(&self, state: &S) {
282        if let Some(window) = web_sys::window() {
283            if let Some(storage) = window.local_storage().ok().flatten() {
284                if let Ok(json) = serde_json::to_string(state) {
285                    let _ = storage.set_item(&self.key, &json);
286                }
287            }
288        }
289    }
290}
291
292/// Wires a store into the reactive devtools time-travel report: every commit is
293/// also observable through [`crate::signal::signal_activity`] when the store's
294/// state signal is [`crate::signal::Signal::labeled`]. This helper returns an
295/// effect handle that keeps the bridge alive; forget/drop it to detach.
296///
297/// `label` names the backing signal so the [`crate::devtools`] inspector shows
298/// its write count under that name.
299pub fn instrument_store<S: Clone + 'static>(store: &Store<S>, label: &str) -> EffectHandle {
300    let sig = store.signal().labeled(label);
301    create_effect(move || {
302        let _ = sig.get();
303    })
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
311    struct AppState {
312        count: i32,
313        name: String,
314    }
315
316    #[test]
317    fn set_and_get_round_trip() {
318        let store = Store::new(AppState {
319            count: 0,
320            name: "x".into(),
321        });
322        store.set(AppState {
323            count: 1,
324            name: "y".into(),
325        });
326        assert_eq!(store.get().count, 1);
327    }
328
329    #[test]
330    fn update_applies_transformation() {
331        let store = Store::new(AppState {
332            count: 0,
333            name: "x".into(),
334        });
335        store.update(|s| AppState {
336            count: s.count + 5,
337            name: s.name.clone(),
338        });
339        assert_eq!(store.get().count, 5);
340    }
341
342    #[test]
343    fn subscribers_fire_on_commit() {
344        let store = Store::new(AppState {
345            count: 0,
346            name: "x".into(),
347        });
348        let seen = Rc::new(std::cell::RefCell::new(Vec::new()));
349        let seen2 = seen.clone();
350        let _sub = store.subscribe(move |s: &AppState| {
351            seen2.borrow_mut().push(s.count);
352        });
353        store.set(AppState {
354            count: 1,
355            name: "a".into(),
356        });
357        store.update(|s| AppState {
358            count: s.count + 1,
359            name: "b".into(),
360        });
361        assert_eq!(*seen.borrow(), vec![1, 2]);
362    }
363
364    #[test]
365    fn dropping_subscription_stops_callbacks() {
366        let store = Store::new(AppState {
367            count: 0,
368            name: "x".into(),
369        });
370        let seen = Rc::new(std::cell::RefCell::new(0usize));
371        let seen2 = seen.clone();
372        let sub = store.subscribe(move |_: &AppState| {
373            *seen2.borrow_mut() += 1;
374        });
375        store.set(AppState {
376            count: 1,
377            name: "a".into(),
378        });
379        drop(sub);
380        store.set(AppState {
381            count: 2,
382            name: "b".into(),
383        });
384        assert_eq!(*seen.borrow(), 1, "no callback after drop");
385    }
386
387    #[test]
388    fn time_travel_undo_redo() {
389        let store = Store::new(AppState {
390            count: 0,
391            name: "x".into(),
392        })
393        .with_time_travel(10);
394
395        store.set(AppState {
396            count: 1,
397            name: "a".into(),
398        });
399        store.set(AppState {
400            count: 2,
401            name: "b".into(),
402        });
403        assert!(store.can_undo());
404        assert!(!store.can_redo());
405
406        store.undo();
407        assert_eq!(store.get().count, 1);
408        assert!(store.can_redo());
409
410        store.undo();
411        assert_eq!(store.get().count, 0);
412
413        store.redo();
414        assert_eq!(store.get().count, 1);
415        assert_eq!(store.get().name, "a");
416    }
417
418    #[test]
419    fn new_commit_after_undo_truncates_redo_branch() {
420        let store = Store::new(AppState {
421            count: 0,
422            name: "x".into(),
423        })
424        .with_time_travel(10);
425        store.set(AppState {
426            count: 1,
427            name: "a".into(),
428        });
429        store.set(AppState {
430            count: 2,
431            name: "b".into(),
432        });
433        store.undo(); // back to count=1
434        store.set(AppState {
435            count: 9,
436            name: "c".into(),
437        });
438        assert!(!store.can_redo(), "redo branch truncated by new commit");
439        assert_eq!(store.get().count, 9);
440    }
441
442    /// In-memory persistence backend used to validate the binding without a
443    /// browser. Mirrors the `WebStorage` contract.
444    struct MemoryStorage {
445        cell: std::rc::Rc<std::cell::RefCell<Option<String>>>,
446    }
447
448    impl Persistence<AppState> for MemoryStorage {
449        fn load(&self) -> Option<AppState> {
450            self.cell
451                .borrow()
452                .as_ref()
453                .and_then(|s| serde_json::from_str(s).ok())
454        }
455        fn save(&self, state: &AppState) {
456            *self.cell.borrow_mut() = Some(serde_json::to_string(state).unwrap());
457        }
458    }
459
460    #[test]
461    fn persistence_hydrates_initial_and_writes_on_commit() {
462        let cell = std::rc::Rc::new(std::cell::RefCell::new(None));
463        *cell.borrow_mut() = Some(
464            serde_json::to_string(&AppState {
465                count: 42,
466                name: "seed".into(),
467            })
468            .unwrap(),
469        );
470
471        let store = Store::new(AppState {
472            count: 0,
473            name: "default".into(),
474        })
475        .with_persistence(Box::new(MemoryStorage { cell: cell.clone() }));
476
477        assert_eq!(store.get().count, 42);
478        assert_eq!(store.get().name, "seed");
479
480        store.set(AppState {
481            count: 7,
482            name: "updated".into(),
483        });
484        store.persist_now();
485        let raw = cell.borrow();
486        let restored: AppState = serde_json::from_str(raw.as_ref().unwrap()).unwrap();
487        assert_eq!(restored.count, 7);
488        assert_eq!(restored.name, "updated");
489    }
490
491    #[test]
492    fn instrument_store_records_writes_in_devtools() {
493        use crate::signal::{reset_signal_activity, signal_activity};
494        reset_signal_activity();
495        let store = Store::new(AppState {
496            count: 0,
497            name: "x".into(),
498        });
499        let _handle = instrument_store(&store, "counter_store");
500        store.set(AppState {
501            count: 1,
502            name: "a".into(),
503        });
504        store.set(AppState {
505            count: 2,
506            name: "b".into(),
507        });
508        assert_eq!(
509            signal_activity().get("counter_store"),
510            Some(&2),
511            "devtools observes two store writes"
512        );
513    }
514}