Skip to content

Commit 65e9f79

Browse files
committed
feat(cache): Added Effect::new_with_deps method, allowing users to pass in additional dependency initializers to avoid dependency exceptions caused by branch statements in Effect.
1 parent 181a624 commit 65e9f79

File tree

9 files changed

+227
-112
lines changed

9 files changed

+227
-112
lines changed

cache/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ once_cell = "1.21.3"
1414

1515
reactive-macros = { version = "0.2.0", path = "../macros", optional = true }
1616

17+
[dev-dependencies]
18+
reactive-macros = { path = "../macros" }
19+
1720
[features]
1821
default = []
1922

cache/src/cache.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ where
2020
// When the Effect performs dependency calculations for the first time,
2121
// it must ignore the relevant cache,
2222
// otherwise the underlying Signal will not remember the Effect.
23-
if crate::creating_effect_peak().is_some() {
23+
if unsafe { crate::call_stack::CREATING_EFFECT } {
2424
remove_from_cache(key);
2525
return None;
2626
}

cache/src/call_stack.rs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use crate::{IEffect, Observable};
88

99
static mut CALL_STACK: Lazy<Vec<&'static dyn Observable>> = Lazy::new(Vec::new);
1010

11-
static mut CREATING_EFFECT: Option<Weak<dyn IEffect>> = None;
11+
pub(crate) static mut CREATING_EFFECT: bool = false;
12+
static mut CURRENT_EFFECT: Option<Weak<dyn IEffect>> = None;
1213

1314
pub(crate) fn push(op: &'static dyn Observable) {
1415
unsafe { CALL_STACK.push(op) }
@@ -22,16 +23,16 @@ pub(crate) fn pop() -> Option<&'static dyn Observable> {
2223
unsafe { CALL_STACK.pop() }
2324
}
2425

25-
pub(crate) fn creating_effect_push(effect: Weak<dyn IEffect>) {
26-
assert!(unsafe { CREATING_EFFECT.is_none() });
27-
unsafe { CREATING_EFFECT = Some(effect) }
26+
pub(crate) fn current_effect_push(effect: Weak<dyn IEffect>) {
27+
assert!(unsafe { CURRENT_EFFECT.is_none() });
28+
unsafe { CURRENT_EFFECT = Some(effect) }
2829
}
2930

30-
pub(crate) fn creating_effect_peak() -> Option<Weak<dyn IEffect>> {
31-
unsafe { CREATING_EFFECT.clone() }
31+
pub(crate) fn current_effect_peak() -> Option<Weak<dyn IEffect>> {
32+
unsafe { CURRENT_EFFECT.clone() }
3233
}
3334

34-
pub(crate) fn creating_effect_pop() -> Weak<dyn IEffect> {
35-
assert!(unsafe { CREATING_EFFECT.is_some() });
36-
unsafe { CREATING_EFFECT.take().unwrap() }
35+
pub(crate) fn current_effect_pop() -> Weak<dyn IEffect> {
36+
assert!(unsafe { CURRENT_EFFECT.is_some() });
37+
unsafe { CURRENT_EFFECT.take().unwrap() }
3738
}

cache/src/effect.rs

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@ impl<F> Effect<F>
2929
where
3030
F: Fn(),
3131
{
32+
fn new_inner<D>(f: F, deps: Option<D>) -> Rc<dyn IEffect>
33+
where
34+
F: 'static,
35+
D: Fn() + 'static,
36+
{
37+
let e: Rc<dyn IEffect> = Rc::new(Effect { f });
38+
39+
unsafe { crate::call_stack::CREATING_EFFECT = true };
40+
crate::current_effect_push(Rc::downgrade(&e));
41+
42+
if let Some(deps) = deps {
43+
deps();
44+
}
45+
e.run();
46+
47+
crate::current_effect_pop();
48+
unsafe { crate::call_stack::CREATING_EFFECT = false };
49+
50+
e
51+
}
52+
3253
/// Creates a new `Effect`, wrapping the provided closure
3354
/// and running it immediately for dependency tracking.
3455
///
@@ -56,13 +77,68 @@ where
5677
where
5778
F: 'static,
5879
{
59-
let e: Rc<dyn IEffect> = Rc::new(Effect { f });
60-
61-
crate::creating_effect_push(Rc::downgrade(&e));
62-
e.run();
63-
crate::creating_effect_pop();
80+
Self::new_inner::<fn()>(f, None)
81+
}
6482

65-
e
83+
/// Creates a new `Effect` with an additional dependency initializer.
84+
///
85+
/// This works like [`Effect::new`], but also runs the provided `deps` closure
86+
/// during the initial dependency collection phase.
87+
///
88+
/// This is useful when your effect closure contains conditional logic
89+
/// (e.g. `if`/`match`), and you want to ensure that *all possible branches*
90+
/// have their dependencies tracked on the first run.
91+
///
92+
/// Returns an `Rc<dyn IEffect>` so the effect can be stored and shared
93+
/// as a non-generic trait object.
94+
///
95+
/// # Examples
96+
///
97+
/// ```
98+
/// use std::{cell::Cell, rc::Rc};
99+
/// use reactive_cache::Effect;
100+
/// use reactive_macros::signal;
101+
///
102+
/// signal!(static mut FLAG: bool = true;);
103+
/// signal!(static mut COUNTER: i32 = 10;);
104+
///
105+
/// let result = Rc::new(Cell::new(0));
106+
/// let r_clone = result.clone();
107+
///
108+
/// // Effect closure has a conditional branch
109+
/// let effect = Effect::new_with_deps(
110+
/// move || {
111+
/// match *FLAG_get() {
112+
/// true => {}
113+
/// false => {
114+
/// r_clone.set(*COUNTER_get());
115+
/// }
116+
/// }
117+
/// },
118+
/// // Explicitly declare both `FLAG` and `COUNTER` as dependencies
119+
/// move || {
120+
/// FLAG();
121+
/// COUNTER();
122+
/// },
123+
/// );
124+
///
125+
/// assert_eq!(result.get(), 0); // runs with FLAG = true
126+
///
127+
/// // Changing `FLAG` to false will trigger the effect
128+
/// FLAG_set(false);
129+
/// assert_eq!(result.get(), 10);
130+
///
131+
/// // Changing `COUNTER` still triggers the effect, even though
132+
/// // `FLAG` was true on the first run.
133+
/// COUNTER_set(20);
134+
/// assert_eq!(result.get(), 20);
135+
/// ```
136+
pub fn new_with_deps<D>(f: F, deps: D) -> Rc<dyn IEffect>
137+
where
138+
F: 'static,
139+
D: Fn() + 'static,
140+
{
141+
Self::new_inner(f, Some(deps))
66142
}
67143
}
68144

cache/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ mod cache;
22
mod call_stack;
33

44
pub mod effect;
5+
pub mod macros;
56
pub mod memo;
67
pub mod signal;
78

89
pub(crate) use cache::{remove_from_cache, store_in_cache, touch};
9-
pub(crate) use call_stack::{creating_effect_peak, creating_effect_pop, creating_effect_push};
10+
pub(crate) use call_stack::{current_effect_pop, current_effect_push};
1011
pub use effect::{Effect, IEffect};
1112
pub use memo::Memo;
1213
pub use signal::Signal;

cache/src/macros.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/// Creates a reactive effect from a closure (and optionally a dependency collector).
2+
///
3+
/// The `effect!` macro is a convenient wrapper around
4+
/// [`reactive_cache::Effect::new`] and [`reactive_cache::Effect::new_with_deps`].
5+
/// It allows you to quickly register a reactive effect that automatically tracks
6+
/// dependencies and re-runs when they change.
7+
///
8+
/// # Forms
9+
///
10+
/// - `effect!(f)`
11+
/// Equivalent to calling [`Effect::new(f)`].
12+
///
13+
/// - `effect!(f, deps)`
14+
/// Equivalent to calling [`Effect::new_with_deps(f, deps)`].
15+
///
16+
/// # Requirements
17+
///
18+
/// - `f` must be a closure or function pointer that takes no arguments and returns `()`.
19+
/// - `deps` (if provided) must also be a closure or function pointer taking no arguments and returning `()`.
20+
///
21+
/// # Examples
22+
///
23+
/// ```rust
24+
/// use std::{cell::Cell, rc::Rc};
25+
/// use reactive_cache::effect;
26+
/// use reactive_macros::signal;
27+
///
28+
/// signal!(static mut A: i32 = 1;);
29+
///
30+
/// // Track effect runs
31+
/// let counter = Rc::new(Cell::new(0));
32+
/// let counter_clone = counter.clone();
33+
///
34+
/// // `effect!(f)` form
35+
/// let e = effect!(move || {
36+
/// let _ = A(); // reading the signal
37+
/// counter_clone.set(counter_clone.get() + 1); // increment effect counter
38+
/// });
39+
///
40+
/// let ptr = Rc::into_raw(e); // actively leak to avoid implicitly dropping the effect
41+
///
42+
/// // Effect runs immediately upon creation
43+
/// assert_eq!(counter.get(), 1);
44+
///
45+
/// // Changing A triggers the effect again
46+
/// assert!(A_set(10));
47+
/// assert_eq!(counter.get(), 2);
48+
///
49+
/// // Setting the same value does NOT trigger the effect
50+
/// assert!(!A_set(10));
51+
/// assert_eq!(counter.get(), 2);
52+
///
53+
/// // `effect!(f, deps)` form
54+
/// let _ = effect!(
55+
/// || println!("effect body"),
56+
/// || println!("dependency collector")
57+
/// );
58+
/// ```
59+
///
60+
/// # SAFETY
61+
///
62+
/// The macro internally uses [`reactive_cache::Effect`], which relies on
63+
/// `static` tracking and is **not thread-safe**. Only use in single-threaded contexts.
64+
///
65+
/// # Warning
66+
///
67+
/// **Do not set any signal that is part of the same effect chain.**
68+
///
69+
/// Effects automatically run whenever one of their dependent signals changes.
70+
/// If an effect modifies a signal that it (directly or indirectly) observes,
71+
/// it creates a circular dependency. This can lead to:
72+
/// - an infinite loop of updates, or
73+
/// - conflicting updates that the system cannot resolve.
74+
///
75+
/// In the general case, it is impossible to automatically determine whether
76+
/// such an effect will ever terminate—this is essentially a version of the
77+
/// halting problem. Therefore, you must ensure manually that effects do not
78+
/// update signals within their own dependency chain.
79+
#[macro_export]
80+
macro_rules! effect {
81+
($f:expr) => {
82+
reactive_cache::Effect::new($f)
83+
};
84+
($f:expr, $f2:expr) => {
85+
reactive_cache::Effect::new_with_deps($f, $f2)
86+
};
87+
}

cache/src/signal.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ impl<T> Signal<T> {
103103
}
104104

105105
// Track effects in the call stack
106-
if let Some(e) = call_stack::creating_effect_peak()
106+
if let Some(e) = call_stack::current_effect_peak()
107107
&& !self.effects.borrow().iter().any(|w| Weak::ptr_eq(w, &e))
108108
{
109109
self.effects.borrow_mut().push(e);

macros/src/lib.rs

Lines changed: 2 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use proc_macro::TokenStream;
22
use quote::{format_ident, quote};
3-
use syn::{Expr, Ident, ItemFn, ItemStatic, ReturnType, parse_macro_input};
3+
use syn::{Ident, ItemFn, ItemStatic, ReturnType, parse_macro_input};
44

55
/// Wraps a `static mut` variable as a reactive signal (similar to a property)
66
/// with getter and setter functions.
@@ -175,8 +175,7 @@ pub fn memo(_attr: TokenStream, item: TokenStream) -> TokenStream {
175175
}
176176

177177
let ident = format_ident!("{}", ident.to_string().to_uppercase());
178-
let ty =
179-
quote! { reactive_cache::Lazy<reactive_cache::Memo<#output_ty, fn() -> #output_ty>> };
178+
let ty = quote! { reactive_cache::Lazy<reactive_cache::Memo<#output_ty, fn() -> #output_ty>> };
180179
let expr = quote! { reactive_cache::Lazy::new(|| reactive_cache::Memo::new(|| #block)) };
181180

182181
let expanded = quote! {
@@ -192,95 +191,6 @@ pub fn memo(_attr: TokenStream, item: TokenStream) -> TokenStream {
192191
expanded.into()
193192
}
194193

195-
/// Creates a reactive effect from a closure or function pointer.
196-
///
197-
/// The `effect!` procedural macro is a convenient wrapper around `reactive_cache::Effect::new`.
198-
/// It allows you to quickly register a reactive effect that automatically tracks
199-
/// dependencies and re-runs when they change.
200-
///
201-
/// # Requirements
202-
///
203-
/// - The argument must be either:
204-
/// 1. A closure (e.g., `|| { ... }`), or
205-
/// 2. A function pointer / function name with zero arguments.
206-
/// - The closure or function must return `()` (no return value required).
207-
///
208-
/// # Examples
209-
///
210-
/// ```rust
211-
/// use std::{cell::Cell, rc::Rc};
212-
/// use reactive_macros::{effect, signal};
213-
///
214-
/// signal!(static mut A: i32 = 1;);
215-
///
216-
/// // Track effect runs
217-
/// let counter = Rc::new(Cell::new(0));
218-
/// let counter_clone = counter.clone();
219-
///
220-
/// let e = effect!(move || {
221-
/// let _ = A(); // reading the signal
222-
/// counter_clone.set(counter_clone.get() + 1); // increment effect counter
223-
/// });
224-
///
225-
/// let ptr = Rc::into_raw(e); // actively leak to avoid implicitly dropping the effect
226-
///
227-
/// // Effect runs immediately upon creation
228-
/// assert_eq!(counter.get(), 1);
229-
///
230-
/// // Changing A triggers the effect again
231-
/// assert!(A_set(10));
232-
/// assert_eq!(counter.get(), 2);
233-
///
234-
/// // Setting the same value does NOT trigger the effect
235-
/// assert!(!A_set(10));
236-
/// assert_eq!(counter.get(), 2);
237-
/// ```
238-
///
239-
/// # SAFETY
240-
///
241-
/// The macro internally uses `reactive_cache::Effect`, which relies on
242-
/// `static` tracking and is **not thread-safe**. Only use in single-threaded contexts.
243-
///
244-
/// # Warning
245-
///
246-
/// **Do not set any signal that is part of the same effect chain.**
247-
///
248-
/// Effects automatically run whenever one of their dependent signals changes.
249-
/// If an effect modifies a signal that it (directly or indirectly) observes,
250-
/// it creates a circular dependency. This can lead to:
251-
/// - an infinite loop of updates, or
252-
/// - conflicting updates that the system cannot resolve.
253-
///
254-
/// In the general case, it is impossible to automatically determine whether
255-
/// such an effect will ever terminate—this is essentially a version of the
256-
/// halting problem. Therefore, you must ensure manually that effects do not
257-
/// update signals within their own dependency chain.
258-
#[proc_macro]
259-
pub fn effect(input: TokenStream) -> TokenStream {
260-
let expr = parse_macro_input!(input as Expr);
261-
262-
let expanded = match expr {
263-
Expr::Path(path) if path.path.get_ident().is_some() => {
264-
let ident = path.path.get_ident().unwrap();
265-
quote! {
266-
reactive_cache::Effect::new(#ident)
267-
}
268-
}
269-
Expr::Closure(closure) => {
270-
quote! {
271-
reactive_cache::Effect::new(#closure)
272-
}
273-
}
274-
_ => {
275-
return syn::Error::new_spanned(&expr, "Expected a variable name or a closure")
276-
.to_compile_error()
277-
.into();
278-
}
279-
};
280-
281-
expanded.into()
282-
}
283-
284194
/// Evaluates a zero-argument function and optionally reports when the value changes.
285195
///
286196
/// The `#[evaluate(print_fn)]` attribute macro transforms a function into a reactive

0 commit comments

Comments
 (0)