Skip to main content

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}