Skip to main content

tpt_appfront_core/
resource.rs

1//! Async data primitive (`Resource<T>`) — a SolidJS/Svelte-store-style wrapper
2//! for async fetches that exposes `Loading` / `Ready` / `Error` states through
3//! the reactive [`Signal`] core, so UI code doesn't hand-roll ad-hoc loading
4//! flags.
5//!
6//! The loader is synchronous-from-the-core's perspective: callers feed either a
7//! blocking loader (`Resource::new`) or an already-resolved `Result`
8//! (`Resource::ready` / `Resource::error`). Frontends that drive a real async
9//! runtime (the DOM/canvas backends, or an app's own executor) call
10//! [`Resource::load_blocking`] inside their async task and then [`Resource::set_result`]
11//! once the future resolves — the resource's signal updates and any subscribed
12//! view re-renders. Keeping the core runtime-free is intentional: the same
13//! `Resource` works identically on every backend.
14//!
15//! The [`spawn_resource`] bridge and the [`crate::suspense`] boundary build on
16//! this: they turn an `async fn` into a `Resource` (driving the future via a
17//! caller-supplied executor) and render a fallback while the resource is
18//! `Loading`, swapping to the real subtree once it `Ready`s — with automatic
19//! cancellation on reload/unmount (see [`crate::suspense::Suspense`]).
20
21use crate::signal::Signal;
22use std::fmt::Debug;
23
24/// The lifecycle state of a [`Resource`].
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ResourceState<T> {
27    /// Loader has not completed yet.
28    Loading,
29    /// Loader succeeded with a value.
30    Ready(T),
31    /// Loader failed with a message.
32    Error(String),
33}
34
35/// A reactive handle to an async-loaded value.
36///
37/// Clone is cheap (internally `Rc`-backed via [`Signal`]); all clones share the
38/// same underlying state, so providing a `Resource` to a subtree and updating
39/// it from a task updates every consumer.
40pub struct Resource<T> {
41    state: Signal<ResourceState<T>>,
42    /// Monotonic generation counter. Bumped on every (re)load so a late-
43    /// resolving fetch from a *previous* generation can be detected and
44    /// discarded instead of overwriting a newer result. This is the
45    /// cancellation mechanism behind [`Resource::load_async`] and
46    /// [`crate::suspense::Suspense`]: a task holds the generation it started
47    /// with and refuses to commit its result if the resource has since moved on.
48    generation: std::rc::Rc<std::cell::Cell<u64>>,
49}
50
51impl<T> Clone for Resource<T> {
52    fn clone(&self) -> Self {
53        Resource {
54            state: self.state.clone(),
55            generation: self.generation.clone(),
56        }
57    }
58}
59
60impl<T: Clone + 'static> Resource<T> {
61    /// Creates a resource in the `Loading` state, then runs `loader`
62    /// synchronously and stores the result. Useful for "load on creation"
63    /// cases where the loader is already resolved (e.g. a cached value or a
64    /// blocking fetch driven by the caller's executor).
65    pub fn new(loader: impl FnOnce() -> Result<T, String>) -> Self {
66        let res = Resource {
67            state: Signal::new(ResourceState::Loading),
68            generation: std::rc::Rc::new(std::cell::Cell::new(0)),
69        };
70        res.load_blocking(loader);
71        res
72    }
73
74    /// Creates a resource already in the `Ready` state.
75    pub fn ready(value: T) -> Self {
76        Resource {
77            state: Signal::new(ResourceState::Ready(value)),
78            generation: std::rc::Rc::new(std::cell::Cell::new(0)),
79        }
80    }
81
82    /// Creates a resource already in the `Error` state.
83    pub fn error(message: impl Into<String>) -> Self {
84        Resource {
85            state: Signal::new(ResourceState::Error(message.into())),
86            generation: std::rc::Rc::new(std::cell::Cell::new(0)),
87        }
88    }
89
90    /// Runs `loader` and updates the shared state to `Ready`/`Error`. Safe to
91    /// call from any thread/task that can reach this `Resource` (e.g. the
92    /// continuation of an awaited future).
93    pub fn load_blocking(&self, loader: impl FnOnce() -> Result<T, String>) {
94        let next = match loader() {
95            Ok(v) => ResourceState::Ready(v),
96            Err(e) => ResourceState::Error(e),
97        };
98        self.state.set(next);
99    }
100
101    /// Updates the state directly (e.g. from an already-resolved future).
102    ///
103    /// Async callers should prefer [`Resource::load_async`], whose spawn
104    /// closure uses [`Resource::is_current`] to commit a result only if it
105    /// hasn't been superseded by a newer load — that is the cancellation guard.
106    pub fn set_result(&self, result: Result<T, String>) {
107        self.state.set(match result {
108            Ok(v) => ResourceState::Ready(v),
109            Err(e) => ResourceState::Error(e),
110        });
111    }
112
113    /// The current generation counter. Incremented on every reload/async
114    /// (re)load, so async tasks can detect that they've been superseded.
115    pub fn generation(&self) -> u64 {
116        self.generation.get()
117    }
118
119    /// True only if the resource is *still* on generation `gen` — i.e. no newer
120    /// `reload`/`load_async` has superseded the load that started at `gen`. A
121    /// task that captured `gen = resource.generation()` before awaiting its
122    /// future checks `resource.is_current(gen)` after the await: if `false`, a
123    /// newer load has started and the result must be dropped (cancellation).
124    pub fn is_current(&self, gen: u64) -> bool {
125        self.generation.get() == gen
126    }
127
128    /// Spawns an `async` loader and bridges its result back into this resource.
129    ///
130    /// The core is runtime-free, so the caller supplies a `spawn` function that
131    /// drives a `Future<Output = Result<T, String>>` to completion on whatever
132    /// executor the backend provides (the DOM uses `wasm_bindgen_futures`, a
133    /// native app uses `tokio`/`smol`, tests use a oneshot channel). `spawn`
134    /// is given the future plus a clone of this `Resource` and the generation it
135    /// started with; once the future resolves the result is committed *only if*
136    /// the resource is still on that generation (cancellation on reload).
137    ///
138    /// The resource is immediately put into the `Loading` state and its
139    /// generation is bumped so any in-flight load from a previous generation is
140    /// invalidated. Calling `load_async` again on the same resource (or any
141    /// clone) cancels the prior in-flight future.
142    pub fn load_async<F, Fut>(&self, spawn: F, loader: Fut)
143    where
144        F: FnOnce(Resource<T>, u64, Fut),
145        Fut: std::future::Future<Output = Result<T, String>> + 'static,
146    {
147        // Bump generation *before* entering Loading so a concurrent task that
148        // captured the old generation after this call will be invalidated.
149        let gen = self.generation.get() + 1;
150        self.generation.set(gen);
151        self.state.set(ResourceState::Loading);
152        spawn(self.clone(), gen, loader);
153    }
154
155    /// Resets the resource back to `Loading` (e.g. to re-trigger a fetch).
156    ///
157    /// Bumps the generation so any in-flight async load is cancelled (its
158    /// eventual result will be discarded by [`Resource::is_current`]).
159    pub fn reload(&self) {
160        self.generation.set(self.generation.get() + 1);
161        self.state.set(ResourceState::Loading);
162    }
163
164    /// The current state.
165    pub fn state(&self) -> ResourceState<T> {
166        self.state.get()
167    }
168
169    /// Convenience accessor: the ready value, or `None` while loading/errored.
170    pub fn ready_value(&self) -> Option<T> {
171        match self.state.get() {
172            ResourceState::Ready(v) => Some(v),
173            _ => None,
174        }
175    }
176
177    /// `true` while the loader has not completed.
178    pub fn is_loading(&self) -> bool {
179        matches!(self.state.get(), ResourceState::Loading)
180    }
181
182    /// `true` once the loader has succeeded.
183    pub fn is_ready(&self) -> bool {
184        matches!(self.state.get(), ResourceState::Ready(_))
185    }
186
187    /// `true` if the loader failed.
188    pub fn is_error(&self) -> bool {
189        matches!(self.state.get(), ResourceState::Error(_))
190    }
191
192    /// The underlying state signal, so views can subscribe to changes.
193    pub fn signal(&self) -> Signal<ResourceState<T>> {
194        self.state.clone()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn new_runs_loader_and_stores_value() {
204        let r = Resource::new(|| Ok(42));
205        assert!(r.is_ready());
206        assert_eq!(r.ready_value(), Some(42));
207        assert!(!r.is_loading());
208        assert!(!r.is_error());
209    }
210
211    #[test]
212    fn new_captures_loader_error() {
213        let r: Resource<i32> = Resource::new(|| Err("boom".to_string()));
214        assert!(r.is_error());
215        assert_eq!(r.state(), ResourceState::Error("boom".to_string()));
216    }
217
218    #[test]
219    fn ready_and_error_constructors() {
220        assert_eq!(Resource::<i32>::ready(7).ready_value(), Some(7));
221        assert!(Resource::<i32>::error("nope").is_error());
222    }
223
224    #[test]
225    fn load_blocking_updates_state_and_clones_share_it() {
226        let r = Resource::ready(1);
227        let clone = r.clone();
228        r.load_blocking(|| Ok(99));
229        assert_eq!(clone.ready_value(), Some(99));
230    }
231
232    #[test]
233    fn reload_resets_to_loading() {
234        let r = Resource::ready(5);
235        r.reload();
236        assert!(r.is_loading());
237    }
238
239    #[test]
240    fn set_result_stores_error() {
241        let r = Resource::new(|| Ok(1));
242        r.set_result(Err("late failure".to_string()));
243        assert!(r.is_error());
244    }
245
246    /// A trivial synchronous "executor" used by the async tests: it runs the
247    /// future to completion on the spot and — if still current — commits it.
248    #[cfg(test)]
249    fn sync_spawn<T, Fut>(res: Resource<T>, gen: u64, fut: Fut)
250    where
251        T: Clone + 'static,
252        Fut: std::future::Future<Output = Result<T, String>>,
253    {
254        let out = futures_lite_like::block_on(fut);
255        if res.is_current(gen) {
256            res.set_result(out);
257        }
258    }
259
260    #[test]
261    fn load_async_enters_loading_then_resolves_when_current() {
262        let r = Resource::<i32>::ready(0);
263        let before = r.generation();
264        r.load_async(
265            sync_spawn,
266            async { Ok(123) },
267        );
268        // `load_async` immediately bumps the generation and goes Loading; the
269        // synchronous spawn resolves it and commits because the gen is current.
270        assert!(r.generation() > before, "generation bumped");
271        assert!(r.is_ready(), "synchronous spawn committed the result");
272        assert_eq!(r.ready_value(), Some(123));
273    }
274
275    #[test]
276    fn reload_cancels_a_superseded_async_load() {
277        // Use a spawn that *defers* committing so we can interleave a reload.
278        // We capture the gen, then supersede it with `reload`, then attempt to
279        // commit the old result — it must be dropped (cancellation).
280        let r = Resource::<i32>::ready(0);
281
282        // `deferred` records the (res, gen) pair so the test can drive it.
283        let mut pending: Option<(Resource<i32>, u64)> = None;
284        r.load_async(
285            |res, g, _fut| {
286                pending = Some((res, g));
287            },
288            async { Ok(123) },
289        );
290        assert!(r.is_loading());
291        let (res, gen) = pending.take().unwrap();
292
293        // A newer load starts while the old one is still "in flight".
294        res.reload();
295        assert!(!res.is_current(gen), "reload invalidated the old generation");
296
297        // The stale result is dropped because its generation is obsolete.
298        if res.is_current(gen) {
299            res.set_result(Ok(999));
300        }
301        assert!(res.is_loading(), "stale result was cancelled; still Loading");
302        assert_eq!(res.ready_value(), None);
303    }
304
305    #[test]
306    fn clones_share_one_generation_counter() {
307        let r = Resource::<String>::ready("init".to_string());
308        let clone = r.clone();
309        let captured = r.generation();
310        // Reload via the clone; the original observes the same bump.
311        clone.reload();
312        assert!(!r.is_current(captured));
313        assert_eq!(r.generation(), clone.generation());
314    }
315}
316
317/// A tiny `block_on` shim so the async tests don't need a real executor or an
318/// async-std/tokio dependency in `appfront-core`. It polls a future with a
319/// no-op waker until it resolves.
320#[cfg(test)]
321mod futures_lite_like {
322    use std::future::Future;
323    use std::pin::Pin;
324    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
325
326    pub fn block_on<F: Future>(fut: F) -> F::Output {
327        // SAFETY: the no-op waker never touches the data pointer, so the
328        // vtable's clone/wake/drop are all inert.
329        let waker = unsafe { Waker::from_raw(noop_waker()) };
330        let mut cx = Context::from_waker(&waker);
331        let mut fut = fut;
332        let mut fut = unsafe { Pin::new_unchecked(&mut fut) };
333        loop {
334            if let Poll::Ready(val) = fut.as_mut().poll(&mut cx) {
335                return val;
336            }
337        }
338    }
339
340    fn noop_raw_waker() -> *const () {
341        &()
342    }
343
344    unsafe fn clone(_: *const ()) -> RawWaker {
345        noop_waker()
346    }
347    unsafe fn wake(_: *const ()) {}
348    unsafe fn drop(_: *const ()) {}
349
350    fn noop_waker() -> RawWaker {
351        static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake, drop);
352        RawWaker::new(noop_raw_waker(), &VTABLE)
353    }
354}