whisker_runtime/reactive/resource.rs
1//! Async-data primitive — runs an `async` fetcher on Whisker's
2//! single-threaded task pool ([`crate::tasks`]) and exposes the
3//! loading / ready / error state through a [`ReadSignal`]-shaped
4//! handle.
5//!
6//! The fetcher runs on the TASM thread under
7//! [`futures_executor::LocalPool`]. For blocking sync IO (`ureq`,
8//! `std::fs`, …) inside the fetcher, wrap the call in
9//! [`crate::tasks::run_blocking`] which offloads to a fresh worker
10//! thread and marshals the result back via [`run_on_main_thread`]:
11//!
12//! ```ignore
13//! use whisker::runtime::tasks::run_blocking;
14//!
15//! let stories = resource(|| async {
16//! run_blocking(|| {
17//! ureq::get("https://hn.algolia.com/...")
18//! .call()
19//! .map_err(|e| e.to_string())?
20//! .into_string()
21//! .map_err(|e| e.to_string())
22//! })
23//! .await
24//! .and_then(|body| parse(&body))
25//! });
26//! ```
27//!
28//! For purely-async fetchers (a non-blocking HTTP client, a
29//! pre-computed value, etc.) you can just write `async move { ... }`
30//! and skip the `run_blocking` step.
31
32use std::future::Future;
33
34use crate::tasks::spawn_local;
35
36use super::signal::RwSignal;
37
38/// Three-state machine the [`Resource`] cycles through. `Clone` so
39/// reads inside effects can take owned copies without borrowing the
40/// underlying signal slot.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum ResourceState<T> {
43 /// Worker hasn't returned yet — neither value nor error available.
44 Loading,
45 /// Worker returned `Ok(v)` — `v` is the fetched value.
46 Ready(T),
47 /// Worker returned `Err(msg)`. The string is the user-readable
48 /// reason. (Plain `String` rather than a generic `E` keeps the
49 /// type parameter count low and matches the common pattern of
50 /// stringifying upstream errors with `.map_err(|e| e.to_string())`.)
51 Error(String),
52}
53
54impl<T> ResourceState<T> {
55 pub fn is_loading(&self) -> bool {
56 matches!(self, ResourceState::Loading)
57 }
58 pub fn is_ready(&self) -> bool {
59 matches!(self, ResourceState::Ready(_))
60 }
61 pub fn is_error(&self) -> bool {
62 matches!(self, ResourceState::Error(_))
63 }
64}
65
66/// Copy handle to a deferred value. Wraps an [`RwSignal`] whose slot
67/// the worker thread writes into once the fetch completes; consumer
68/// code reads through the accessors below.
69pub struct Resource<T: Clone + 'static> {
70 state: RwSignal<ResourceState<T>>,
71}
72
73// Hand-written Copy/Clone — `derive(Copy)` would require `T: Copy`
74// which is unnecessarily strict (the resource only holds a u32-ish
75// signal handle, not the T itself).
76impl<T: Clone + 'static> Copy for Resource<T> {}
77impl<T: Clone + 'static> Clone for Resource<T> {
78 fn clone(&self) -> Self {
79 *self
80 }
81}
82
83impl<T: Clone + 'static> Resource<T> {
84 /// Construct a `Resource<T>` backed by an externally-owned
85 /// [`RwSignal`]. The signal becomes the resource's source of
86 /// truth — writes to it surface as state transitions through
87 /// the resource's accessors.
88 ///
89 /// Hidden from rustdoc: regular users go through [`resource`] or
90 /// [`resource_sync`]. This is here so tests + non-standard
91 /// "synthetic resource" cases (e.g. a value derived from a
92 /// context signal, exposed as a Resource) can build one without
93 /// re-spawning a fetcher.
94 #[doc(hidden)]
95 pub fn from_state(state: RwSignal<ResourceState<T>>) -> Self {
96 Self { state }
97 }
98
99 /// Read the current state (reactive — registers a dependency on
100 /// the underlying signal).
101 pub fn state(&self) -> ResourceState<T> {
102 self.state.get()
103 }
104
105 /// Convenience: return `Some(value)` when ready, `None` otherwise.
106 pub fn get(&self) -> Option<T> {
107 match self.state.get() {
108 ResourceState::Ready(v) => Some(v),
109 _ => None,
110 }
111 }
112
113 /// Convenience: `true` while the worker is still running.
114 pub fn loading(&self) -> bool {
115 matches!(self.state.get(), ResourceState::Loading)
116 }
117
118 /// Convenience: return `Some(message)` if the fetch ended in error.
119 pub fn error(&self) -> Option<String> {
120 match self.state.get() {
121 ResourceState::Error(e) => Some(e),
122 _ => None,
123 }
124 }
125}
126
127/// Fire-and-forget async fetch. Drives `fetcher` (an `async fn` or
128/// `async move {…}` block) on Whisker's task pool and writes the
129/// resolved [`Result`] into the returned [`Resource`]'s signal.
130///
131/// `fetcher` is called once on the TASM thread to obtain the
132/// `Future`, which is then spawned onto [`crate::tasks::spawn_local`]
133/// and polled by every tick. The future runs cooperatively — `await`
134/// points yield back to the runtime so the UI stays responsive.
135///
136/// For blocking sync work inside the fetcher (e.g. `ureq::get(...)`,
137/// `std::fs::read(...)`), wrap the call in
138/// [`crate::tasks::run_blocking`] which moves it to a worker thread
139/// and resumes the awaiting task on the main thread once the result
140/// is back.
141///
142/// Returns immediately with a `Resource<T>` in
143/// [`ResourceState::Loading`].
144///
145/// Owner discipline: the underlying [`RwSignal`] is registered with
146/// whatever owner is current at call time. If that owner is disposed
147/// before the future completes, the eventual write is a no-op (the
148/// signal node is gone), so no stale write hits a re-mounted owner.
149///
150/// For tests, prefer [`resource_sync`] — it runs the fetcher inline
151/// and doesn't depend on the executor having been ticked.
152pub fn resource<T, F, Fut>(fetcher: F) -> Resource<T>
153where
154 T: Clone + 'static,
155 F: FnOnce() -> Fut + 'static,
156 Fut: Future<Output = Result<T, String>> + 'static,
157{
158 let state = RwSignal::new(ResourceState::Loading);
159 spawn_local(async move {
160 let result = fetcher().await;
161 state.set(match result {
162 Ok(v) => ResourceState::Ready(v),
163 Err(e) => ResourceState::Error(e),
164 });
165 });
166 Resource { state }
167}
168
169/// Synchronous-fetch variant. Runs `fetcher` inline on the calling
170/// thread and writes the result directly into the resource's signal.
171/// No worker thread, no main-thread dispatcher needed — useful for
172/// tests, for cases where the value is already in memory, and for
173/// computed pseudo-resources (e.g. derive from a context value).
174///
175/// The returned `Resource` is in [`ResourceState::Ready`] or
176/// [`ResourceState::Error`] *immediately* — never `Loading`.
177pub fn resource_sync<T, F>(fetcher: F) -> Resource<T>
178where
179 T: Clone + 'static,
180 F: FnOnce() -> Result<T, String>,
181{
182 // `fetcher` is a one-shot seed for the resource's RwSignal —
183 // its signal reads are meant to compute an initial value, not to
184 // re-fire the resource when those signals change. Run it under
185 // `untrack` so the reads don't leak into whatever outer effect
186 // / computed / component body happens to be calling
187 // `resource_sync`. Same principle as the computed seed guard.
188 let state = RwSignal::new(match super::untrack(fetcher) {
189 Ok(v) => ResourceState::Ready(v),
190 Err(e) => ResourceState::Error(e),
191 });
192 Resource { state }
193}