reinhardt_pages/reactive/resource.rs
1//! Resource - Async data fetching primitive
2//!
3//! This module provides `Resource<T>`, a reactive primitive for handling async operations.
4//! It manages Loading/Success/Error states and integrates with the Signal-based
5//! reactivity system.
6
7use super::{Effect, Signal};
8use serde::{Deserialize, Serialize};
9use std::cell::RefCell;
10use std::fmt;
11use std::rc::Rc;
12
13use crate::platform::{defer_yield, spawn_task};
14use reinhardt_core::reactive::deps::IntoDeps;
15
16/// Type alias for the refetch callback function
17///
18/// This reduces type complexity for the `Resource` struct's `refetch_fn` field.
19type RefetchCallback = Rc<RefCell<Option<Box<dyn Fn()>>>>;
20
21/// State of a Resource
22///
23/// A Resource can be in one of three states:
24/// - `Loading`: Initial state or during refetch
25/// - `Success(T)`: Successfully fetched data
26/// - `Error(E)`: Failed to fetch with error
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28pub enum ResourceState<T, E> {
29 /// Resource is currently loading
30 Loading,
31 /// Resource loaded successfully with data
32 Success(T),
33 /// Resource failed to load with error
34 Error(E),
35}
36
37impl<T, E> ResourceState<T, E> {
38 /// Returns `true` if the state is `Loading`
39 pub fn is_loading(&self) -> bool {
40 matches!(self, ResourceState::Loading)
41 }
42
43 /// Returns `true` if the state is `Success`
44 pub fn is_success(&self) -> bool {
45 matches!(self, ResourceState::Success(_))
46 }
47
48 /// Returns `true` if the state is `Error`
49 pub fn is_error(&self) -> bool {
50 matches!(self, ResourceState::Error(_))
51 }
52
53 /// Returns the success value if available
54 pub fn as_ref(&self) -> Option<&T> {
55 match self {
56 ResourceState::Success(data) => Some(data),
57 _ => None,
58 }
59 }
60
61 /// Returns the error value if available
62 pub fn error(&self) -> Option<&E> {
63 match self {
64 ResourceState::Error(err) => Some(err),
65 _ => None,
66 }
67 }
68}
69
70impl<T: fmt::Display, E: fmt::Display> fmt::Display for ResourceState<T, E> {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self {
73 ResourceState::Loading => write!(f, "Loading..."),
74 ResourceState::Success(data) => write!(f, "{}", data),
75 ResourceState::Error(err) => write!(f, "Error: {}", err),
76 }
77 }
78}
79
80/// Resource - Reactive async data container
81///
82/// A Resource manages async data fetching with automatic state management.
83/// It tracks Loading/Success/Error states and integrates with the Signal system
84/// for fine-grained reactivity.
85///
86/// # Example
87///
88/// ```ignore
89/// use reinhardt_pages::reactive::{Resource, use_resource};
90///
91/// async fn fetch_user(id: u32) -> Result<User, String> {
92/// // Fetch from API...
93/// }
94///
95/// let resource = use_resource(|| fetch_user(42), ());
96///
97/// match resource.get() {
98/// ResourceState::Loading => println!("Loading..."),
99/// ResourceState::Success(user) => println!("User: {:?}", user),
100/// ResourceState::Error(err) => println!("Error: {}", err),
101/// }
102/// ```
103pub struct Resource<T: Clone + 'static, E: Clone + 'static = String> {
104 state: Signal<ResourceState<T, E>>,
105 refetch_fn: RefetchCallback,
106 /// RAII anchor that keeps the dependency-tracking `Effect` alive for the
107 /// lifetime of the `Resource`. An `Effect` disposes itself on drop (removing
108 /// its node from the runtime graph), so without holding the handle here the
109 /// effect would be torn down immediately after creation and dependency-change
110 /// refetch would never fire.
111 effect_guard: Rc<Effect>,
112}
113
114impl<T: Clone + 'static, E: Clone + 'static> Clone for Resource<T, E> {
115 fn clone(&self) -> Self {
116 Resource {
117 state: self.state.clone(),
118 refetch_fn: Rc::clone(&self.refetch_fn),
119 effect_guard: Rc::clone(&self.effect_guard),
120 }
121 }
122}
123
124impl<T: Clone + 'static, E: Clone + 'static> Resource<T, E> {
125 /// Get the current state of the resource
126 ///
127 /// This method tracks the access for reactivity - any Effect or Memo
128 /// that calls this will automatically re-run when the state changes.
129 pub fn get(&self) -> ResourceState<T, E> {
130 self.state.get()
131 }
132
133 /// Update the resource state
134 ///
135 /// This is typically used internally by the fetcher function.
136 pub fn set(&self, new_state: ResourceState<T, E>) {
137 self.state.set(new_state);
138 }
139
140 /// Trigger a refetch of the resource
141 ///
142 /// This sets the state to Loading and re-executes the fetcher function.
143 pub fn refetch(&self) {
144 if let Some(ref refetch) = *self.refetch_fn.borrow() {
145 refetch();
146 }
147 }
148
149 /// Returns `true` if the resource is currently loading
150 pub fn is_loading(&self) -> bool {
151 self.state.with_untracked(|s| s.is_loading())
152 }
153
154 /// Returns `true` if the resource has successfully loaded
155 pub fn is_success(&self) -> bool {
156 self.state.with_untracked(|s| s.is_success())
157 }
158
159 /// Returns `true` if the resource failed to load
160 pub fn is_error(&self) -> bool {
161 self.state.with_untracked(|s| s.is_error())
162 }
163}
164
165impl<T: Clone + 'static, E: Clone + 'static> reinhardt_core::reactive::deps::Trackable
166 for Resource<T, E>
167{
168 /// Returns the underlying state `Signal`'s `NodeId`, allowing this
169 /// `Resource` to participate in hook deps tuples alongside `Signal`
170 /// and `Memo` (Refs #4195).
171 fn node_id(&self) -> reinhardt_core::reactive::runtime::NodeId {
172 self.state.id()
173 }
174}
175
176/// Reactive async data hook — the resource counterpart of `use_effect`.
177///
178/// `use_resource(fetcher, deps)` runs `fetcher` and tracks its result as a
179/// [`Resource`] (`Loading → Success/Error`). The `deps` argument follows the
180/// same [`IntoDeps`] convention as [`use_effect`](super::hooks::use_effect):
181///
182/// - `()` → fetch once on mount (never automatically refetches).
183/// - `(signal,)` / `(a, b, ..)` → refetch whenever any listed dependency
184/// changes. Dependencies are the explicitly listed [`Trackable`]s
185/// (`Signal`/`Memo`/`Resource`); signals merely *read* inside the async
186/// `fetcher` do not subscribe (they cross an `await` boundary), so list
187/// everything that should drive a refetch — the same stale-deps rule as
188/// `use_effect`.
189///
190/// The initial fetch and every dependency-driven refetch are deferred one
191/// microtask (`defer_yield`) so they cannot hang when created during WASM
192/// initialization before the event loop is running (#3316).
193///
194/// [`Trackable`]: reinhardt_core::reactive::deps::Trackable
195/// [`IntoDeps`]: reinhardt_core::reactive::deps::IntoDeps
196///
197/// # Dual-target behavior
198///
199/// Like [`use_action`](super::hooks::use_action), this hook is available on all
200/// targets:
201///
202/// - **WASM**: the fetcher runs via `spawn_task` on the browser event loop.
203/// - **Non-WASM (SSR)**: `spawn_task` drops the future, so the fetcher never
204/// runs and the `Resource` stays `Loading`. The server renders the loading
205/// state and the client performs the real fetch after hydration.
206///
207/// # Example
208///
209/// ```ignore
210/// use reinhardt_pages::reactive::{Signal, use_resource};
211///
212/// // Fetch once on mount:
213/// let user = use_resource(|| async { fetch_user_from_api(42).await }, ());
214///
215/// // Refetch whenever `user_id` changes:
216/// let user_id = Signal::new(42u32);
217/// let user = use_resource(
218/// {
219/// let user_id = user_id.clone();
220/// move || {
221/// let id = user_id.get();
222/// async move { fetch_user_from_api(id).await }
223/// }
224/// },
225/// (user_id.clone(),),
226/// );
227/// user_id.set(100); // triggers a refetch
228/// ```
229pub fn use_resource<T, E, F, Fut, D>(fetcher: F, deps: D) -> Resource<T, E>
230where
231 T: Clone + 'static,
232 E: Clone + 'static,
233 F: Fn() -> Fut + 'static,
234 Fut: std::future::Future<Output = Result<T, E>> + 'static,
235 D: IntoDeps,
236{
237 let state = Signal::new(ResourceState::Loading);
238 let fetcher = Rc::new(fetcher);
239
240 // Single fetch routine shared by the dependency-driven Effect and manual
241 // refetch. `defer_yield` runs on every path (initial, dependency change, and
242 // manual refetch) so the fetch cannot hang when spawned during WASM
243 // initialization before the event loop ticks (#3316).
244 let run: Rc<dyn Fn()> = {
245 let state = state.clone();
246 let fetcher = Rc::clone(&fetcher);
247 Rc::new(move || {
248 state.set(ResourceState::Loading);
249 let state = state.clone();
250 let fetcher = Rc::clone(&fetcher);
251 spawn_task(async move {
252 defer_yield().await;
253 match fetcher().await {
254 Ok(data) => state.set(ResourceState::Success(data)),
255 Err(err) => state.set(ResourceState::Error(err)),
256 }
257 });
258 })
259 };
260
261 // Drive the initial fetch and dependency-change refetches. `new_with_deps`
262 // (not `new`) means only the explicitly listed `deps` trigger re-runs; the
263 // Effect is stored in the returned `Resource` (see `effect_guard`) so it
264 // stays alive for the Resource's lifetime instead of being disposed on drop.
265 let effect = {
266 let run = Rc::clone(&run);
267 Effect::new_with_deps(
268 move || {
269 run();
270 None::<fn()>
271 },
272 deps.into_deps(),
273 )
274 };
275
276 let refetch_fn: RefetchCallback = Rc::new(RefCell::new(Some(Box::new({
277 let run = Rc::clone(&run);
278 move || run()
279 }))));
280
281 Resource {
282 state,
283 refetch_fn,
284 effect_guard: Rc::new(effect),
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn test_resource_state_constructors() {
294 let loading: ResourceState<String, String> = ResourceState::Loading;
295 assert!(loading.is_loading());
296 assert!(!loading.is_success());
297 assert!(!loading.is_error());
298
299 let success: ResourceState<String, String> = ResourceState::Success("data".to_string());
300 assert!(!success.is_loading());
301 assert!(success.is_success());
302 assert!(!success.is_error());
303 assert_eq!(success.as_ref(), Some(&"data".to_string()));
304
305 let error: ResourceState<String, String> = ResourceState::Error("failed".to_string());
306 assert!(!error.is_loading());
307 assert!(!error.is_success());
308 assert!(error.is_error());
309 assert_eq!(error.error(), Some(&"failed".to_string()));
310 }
311
312 #[test]
313 fn test_resource_state_display() {
314 let loading: ResourceState<String, String> = ResourceState::Loading;
315 assert_eq!(format!("{}", loading), "Loading...");
316
317 let success: ResourceState<String, String> = ResourceState::Success("Hello".to_string());
318 assert_eq!(format!("{}", success), "Hello");
319
320 let error: ResourceState<String, String> =
321 ResourceState::Error("Connection failed".to_string());
322 assert_eq!(format!("{}", error), "Error: Connection failed");
323 }
324}