Skip to main content

repose_core/
scope_cache.rs

1use rustc_hash::{FxHashMap, FxHashSet};
2use std::cell::RefCell;
3
4use crate::View;
5
6thread_local! {
7    /// The scope key currently being composed (set by `scope!`).
8    static CURRENT_SCOPE_KEY: RefCell<Option<String>> =
9        const { RefCell::new(None) };
10
11    /// signal_id → set of scope keys that read it during composition.
12    /// Cleaned up when a scope re-executes (old deps are replaced) or when
13    /// the app disposes. Set semantics prevent duplicate keys per signal.
14    static SCOPE_SIGNAL_DEPS: RefCell<FxHashMap<usize, FxHashSet<String>>> =
15        RefCell::new(FxHashMap::default());
16
17    /// scope key → set of signal ids it read. Reverse map so clearing a
18    /// scope's deps is O(deps) instead of a full-map scan.
19    static SCOPE_TO_SIGNALS: RefCell<FxHashMap<String, FxHashSet<usize>>> =
20        RefCell::new(FxHashMap::default());
21}
22
23/// Record that the current composition scope (if any) depends on `signal_id`.
24/// Called from `reactive::register_signal_read`.
25pub fn record_scope_signal_dep(signal_id: usize) {
26    let key = CURRENT_SCOPE_KEY.with(|k| k.borrow().clone());
27    if let Some(key) = key {
28        SCOPE_SIGNAL_DEPS.with(|deps| {
29            deps.borrow_mut()
30                .entry(signal_id)
31                .or_default()
32                .insert(key.clone());
33        });
34        SCOPE_TO_SIGNALS.with(|m| {
35            m.borrow_mut().entry(key).or_default().insert(signal_id);
36        });
37    }
38}
39
40/// Mark all scopes that depend on `signal_id` as dirty.
41/// Called from `reactive::signal_changed`.
42pub fn mark_scope_deps_dirty(signal_id: usize) {
43    let keys = SCOPE_SIGNAL_DEPS.with(|deps| deps.borrow().get(&signal_id).cloned());
44    if let Some(keys) = keys {
45        for key in keys {
46            crate::runtime::COMPOSER.with(|c| {
47                let mut c = c.borrow_mut();
48                if let Some(cache) = c.scope_caches.get_mut(&key) {
49                    cache.clean = false;
50                }
51            });
52        }
53    }
54}
55
56/// Run `f` with the given scope key tracking any signal reads inside.
57pub fn with_scope_key<R>(key: &str, f: impl FnOnce() -> R) -> R {
58    CURRENT_SCOPE_KEY.with(|k| {
59        let prev = k.borrow_mut().take();
60        *k.borrow_mut() = Some(key.to_string());
61        let result = f();
62        *k.borrow_mut() = prev;
63        result
64    })
65}
66
67/// Clear all signal→scope tracking for the given scope key.
68/// Called after the scope body executes, so old deps from a previous run are
69/// replaced by the new deps registered during the just-completed run.
70pub fn clear_scope_deps(key: &str) {
71    let signals = SCOPE_TO_SIGNALS.with(|m| m.borrow_mut().remove(key));
72    if let Some(signals) = signals {
73        SCOPE_SIGNAL_DEPS.with(|deps| {
74            let mut deps = deps.borrow_mut();
75            for signal_id in signals {
76                if let Some(scopes) = deps.get_mut(&signal_id) {
77                    scopes.remove(key);
78                    if scopes.is_empty() {
79                        deps.remove(&signal_id);
80                    }
81                }
82            }
83        });
84    }
85}
86
87/// Cached state for a single `scope!` invocation.
88pub struct ScopeCache {
89    /// Combined hash of all scope inputs from the last execution.
90    pub input_hash: u64,
91    /// The cached View tree produced by the last execution.
92    pub view: View,
93    /// How many `remember` slots the body consumed.
94    pub slot_delta: usize,
95    /// `true` if cached output is valid (no signal deps invalidated, inputs unchanged).
96    pub clean: bool,
97}
98
99/// Check whether a scope should re-execute.
100pub fn should_run(key: &str, input_hash: u64) -> bool {
101    crate::runtime::COMPOSER.with(|c| {
102        let c = c.borrow();
103        match c.scope_caches.get(key) {
104            Some(cache) => !cache.clean || cache.input_hash != input_hash,
105            None => true,
106        }
107    })
108}
109
110/// Retrieve the cached View for a scope being skipped, advancing the remember-slot
111/// cursor so sibling scopes remain consistent. IDs are self-contained in the cached
112/// View (packed scope-local IDs), so no global ID advance is needed.
113pub fn get_cached(key: &str, _s: &mut crate::runtime::Scheduler) -> View {
114    crate::runtime::COMPOSER.with(|c| {
115        let mut c = c.borrow_mut();
116        let (slot_delta, view) = {
117            let cache = c
118                .scope_caches
119                .get(key)
120                .expect("scope_cache::get_cached called but no cache entry found");
121            (cache.slot_delta, cache.view.clone())
122        };
123
124        c.cursor += slot_delta;
125        view
126    })
127}
128
129/// Store a new or updated cache entry after executing the scope body.
130pub fn set_cache(key: &str, input_hash: u64, view: View, slot_delta: usize) {
131    crate::runtime::COMPOSER.with(|c| {
132        let mut c = c.borrow_mut();
133        c.scope_caches.insert(
134            key.to_string(),
135            ScopeCache {
136                input_hash,
137                view,
138                slot_delta,
139                clean: true,
140            },
141        );
142    });
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::signal::signal;
149
150    fn reset_maps() {
151        SCOPE_SIGNAL_DEPS.with(|d| d.borrow_mut().clear());
152        SCOPE_TO_SIGNALS.with(|d| d.borrow_mut().clear());
153    }
154
155    #[test]
156    fn scope_deps_deduplicate_keys() {
157        reset_maps();
158        let sig = signal(0);
159
160        // Reading the same signal twice inside one scope registers one dep.
161        with_scope_key("dedupe_scope", || {
162            let _ = sig.get();
163            let _ = sig.get();
164        });
165
166        SCOPE_SIGNAL_DEPS.with(|d| {
167            let d = d.borrow();
168            assert_eq!(
169                d.get(&sig.id()).map(|s| s.len()),
170                Some(1),
171                "duplicate signal reads must collapse to a single scope dep"
172            );
173        });
174        SCOPE_TO_SIGNALS.with(|d| {
175            let d = d.borrow();
176            assert_eq!(d.get("dedupe_scope").map(|s| s.len()), Some(1));
177        });
178
179        // Clearing the scope removes both the reverse entry and the forward entry.
180        clear_scope_deps("dedupe_scope");
181        SCOPE_TO_SIGNALS.with(|d| assert!(d.borrow().is_empty()));
182        SCOPE_SIGNAL_DEPS.with(|d| assert!(d.borrow().is_empty()));
183    }
184
185    #[test]
186    fn scope_deps_multiple_scopes_share_signal() {
187        reset_maps();
188        let sig = signal(0);
189
190        with_scope_key("scope_a", || {
191            let _ = sig.get();
192        });
193        with_scope_key("scope_b", || {
194            let _ = sig.get();
195        });
196
197        SCOPE_SIGNAL_DEPS.with(|d| {
198            let d = d.borrow();
199            let scopes = d.get(&sig.id()).unwrap();
200            assert!(scopes.contains("scope_a"));
201            assert!(scopes.contains("scope_b"));
202        });
203
204        // Clearing only scope_a leaves scope_b intact.
205        clear_scope_deps("scope_a");
206        SCOPE_SIGNAL_DEPS.with(|d| {
207            let d = d.borrow();
208            let scopes = d.get(&sig.id()).unwrap();
209            assert!(!scopes.contains("scope_a"));
210            assert!(scopes.contains("scope_b"));
211        });
212        SCOPE_TO_SIGNALS.with(|d| {
213            assert!(d.borrow().get("scope_a").is_none());
214            assert!(d.borrow().get("scope_b").is_some());
215        });
216    }
217}