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}