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    /// Stack of scope keys currently being composed (set by `scope!`).
8    /// A stack (not a single slot) so nested scopes attribute signal reads
9    /// to every ancestor: otherwise an outer scope stays `clean` while an
10    /// inner scope is dirty, and the outer cache short-circuits the inner
11    /// re-execution, swallowing the update.
12    static CURRENT_SCOPE_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
13    /// Legacy alias kept for the single-key fast path.
14    static CURRENT_SCOPE_KEY: RefCell<Option<String>> =
15        const { RefCell::new(None) };
16
17    /// signal_id -> set of scope keys that read it during composition.
18    /// Cleaned up when a scope re-executes (old deps are replaced) or when
19    /// the app disposes. Set semantics prevent duplicate keys per signal.
20    static SCOPE_SIGNAL_DEPS: RefCell<FxHashMap<usize, FxHashSet<String>>> =
21        RefCell::new(FxHashMap::default());
22
23    /// scope key -> set of signal ids it read. Reverse map so clearing a
24    /// scope's deps is O(deps) instead of a full-map scan.
25    static SCOPE_TO_SIGNALS: RefCell<FxHashMap<String, FxHashSet<usize>>> =
26        RefCell::new(FxHashMap::default());
27}
28
29/// Record that the current composition scope (if any) depends on `signal_id`.
30/// Called from `reactive::register_signal_read`.
31/// Records against every scope on the stack so ancestor scopes are dirtied
32/// when a signal read only inside a nested scope changes.
33pub fn record_scope_signal_dep(signal_id: usize) {
34    let stack: Vec<String> = CURRENT_SCOPE_STACK.with(|s| s.borrow().clone());
35    let stack = if stack.is_empty() {
36        match CURRENT_SCOPE_KEY.with(|k| k.borrow().clone()) {
37            Some(k) => vec![k],
38            None => Vec::new(),
39        }
40    } else {
41        stack
42    };
43    if stack.is_empty() {
44        return;
45    }
46    SCOPE_SIGNAL_DEPS.with(|deps| {
47        let mut deps = deps.borrow_mut();
48        for key in &stack {
49            deps.entry(signal_id).or_default().insert(key.clone());
50        }
51    });
52    SCOPE_TO_SIGNALS.with(|m| {
53        let mut m = m.borrow_mut();
54        for key in &stack {
55            m.entry(key.clone()).or_default().insert(signal_id);
56        }
57    });
58}
59
60/// Mark all scopes that depend on `signal_id` as dirty.
61/// Called from `reactive::signal_changed`.
62pub fn mark_scope_deps_dirty(signal_id: usize) {
63    let keys = SCOPE_SIGNAL_DEPS.with(|deps| deps.borrow().get(&signal_id).cloned());
64    if let Some(keys) = keys {
65        for key in keys {
66            crate::runtime::COMPOSER.with(|c| {
67                let mut c = c.borrow_mut();
68                if let Some(cache) = c.scope_caches.get_mut(&key) {
69                    cache.clean = false;
70                }
71            });
72        }
73    }
74}
75
76/// Run `f` with the given scope key tracking any signal reads inside.
77/// Panic-safe: the scope stack is restored via a Drop guard.
78pub fn with_scope_key<R>(key: &str, f: impl FnOnce() -> R) -> R {
79    struct Guard;
80    impl Drop for Guard {
81        fn drop(&mut self) {
82            if CURRENT_SCOPE_STACK
83                .try_with(|s| {
84                    if let Ok(mut s) = s.try_borrow_mut() {
85                        s.pop();
86                    } else {
87                        log::error!(
88                            "scope_cache: scope stack busy during scope exit; scope entry leaked"
89                        );
90                    }
91                })
92                .is_err()
93            {
94                log::error!(
95                    "scope_cache: scope stack unavailable during scope exit (thread teardown?)"
96                );
97            }
98            let top = CURRENT_SCOPE_STACK
99                .try_with(|s| s.try_borrow().ok().and_then(|s| s.last().cloned()))
100                .ok()
101                .flatten();
102            if CURRENT_SCOPE_KEY
103                .try_with(|k| {
104                    if let Ok(mut k) = k.try_borrow_mut() {
105                        *k = top;
106                    } else {
107                        log::error!(
108                            "scope_cache: current scope key busy during scope exit; stale scope key retained"
109                        );
110                    }
111                })
112                .is_err()
113            {
114                log::error!(
115                    "scope_cache: current scope key unavailable during scope exit (thread teardown?)"
116                );
117            }
118        }
119    }
120    CURRENT_SCOPE_STACK.with(|s| s.borrow_mut().push(key.to_string()));
121    CURRENT_SCOPE_KEY.with(|k| *k.borrow_mut() = Some(key.to_string()));
122    let _guard = Guard;
123    let result = f();
124    drop(_guard);
125    result
126}
127
128/// Clear all signal->scope tracking for the given scope key.
129/// Called after the scope body executes, so old deps from a previous run are
130/// replaced by the new deps registered during the just-completed run.
131pub fn clear_scope_deps(key: &str) {
132    let signals = SCOPE_TO_SIGNALS.with(|m| m.borrow_mut().remove(key));
133    if let Some(signals) = signals {
134        SCOPE_SIGNAL_DEPS.with(|deps| {
135            let mut deps = deps.borrow_mut();
136            for signal_id in signals {
137                if let Some(scopes) = deps.get_mut(&signal_id) {
138                    scopes.remove(key);
139                    if scopes.is_empty() {
140                        deps.remove(&signal_id);
141                    }
142                }
143            }
144        });
145    }
146}
147
148/// Cached state for a single `scope!` invocation.
149pub struct ScopeCache {
150    /// Combined hash of all scope inputs from the last execution.
151    pub input_hash: u64,
152    /// The cached View tree produced by the last execution.
153    pub view: View,
154    /// How many `remember` slots the body consumed.
155    pub slot_delta: usize,
156    /// `true` if cached output is valid (no signal deps invalidated, inputs unchanged).
157    pub clean: bool,
158}
159
160/// Check whether a scope should re-execute.
161pub fn should_run(key: &str, input_hash: u64) -> bool {
162    crate::runtime::COMPOSER.with(|c| {
163        let c = c.borrow();
164        match c.scope_caches.get(key) {
165            Some(cache) => !cache.clean || cache.input_hash != input_hash,
166            None => true,
167        }
168    })
169}
170
171/// Retrieve the cached View for a scope being skipped, advancing the remember-slot
172/// cursor so sibling scopes remain consistent. IDs are self-contained in the cached
173/// View (packed scope-local IDs), so no global ID advance is needed.
174pub fn get_cached(key: &str, _s: &mut crate::runtime::Scheduler) -> View {
175    crate::runtime::COMPOSER.with(|c| {
176        let mut c = c.borrow_mut();
177        let (slot_delta, view) = {
178            let cache = c
179                .scope_caches
180                .get(key)
181                .expect("scope_cache::get_cached called but no cache entry found");
182            (cache.slot_delta, cache.view.clone())
183        };
184
185        c.cursor += slot_delta;
186        view
187    })
188}
189
190/// Store a new or updated cache entry after executing the scope body.
191pub fn set_cache(key: &str, input_hash: u64, view: View, slot_delta: usize) {
192    crate::runtime::COMPOSER.with(|c| {
193        let mut c = c.borrow_mut();
194        c.scope_caches.insert(
195            key.to_string(),
196            ScopeCache {
197                input_hash,
198                view,
199                slot_delta,
200                clean: true,
201            },
202        );
203    });
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::signal::signal;
210
211    fn reset_maps() {
212        SCOPE_SIGNAL_DEPS.with(|d| d.borrow_mut().clear());
213        SCOPE_TO_SIGNALS.with(|d| d.borrow_mut().clear());
214    }
215
216    #[test]
217    fn scope_deps_deduplicate_keys() {
218        reset_maps();
219        let sig = signal(0);
220
221        // Reading the same signal twice inside one scope registers one dep.
222        with_scope_key("dedupe_scope", || {
223            let _ = sig.get();
224            let _ = sig.get();
225        });
226
227        SCOPE_SIGNAL_DEPS.with(|d| {
228            let d = d.borrow();
229            assert_eq!(
230                d.get(&sig.id()).map(|s| s.len()),
231                Some(1),
232                "duplicate signal reads must collapse to a single scope dep"
233            );
234        });
235        SCOPE_TO_SIGNALS.with(|d| {
236            let d = d.borrow();
237            assert_eq!(d.get("dedupe_scope").map(|s| s.len()), Some(1));
238        });
239
240        // Clearing the scope removes both the reverse entry and the forward entry.
241        clear_scope_deps("dedupe_scope");
242        SCOPE_TO_SIGNALS.with(|d| assert!(d.borrow().is_empty()));
243        SCOPE_SIGNAL_DEPS.with(|d| assert!(d.borrow().is_empty()));
244    }
245
246    #[test]
247    fn scope_deps_multiple_scopes_share_signal() {
248        reset_maps();
249        let sig = signal(0);
250
251        with_scope_key("scope_a", || {
252            let _ = sig.get();
253        });
254        with_scope_key("scope_b", || {
255            let _ = sig.get();
256        });
257
258        SCOPE_SIGNAL_DEPS.with(|d| {
259            let d = d.borrow();
260            let scopes = d.get(&sig.id()).unwrap();
261            assert!(scopes.contains("scope_a"));
262            assert!(scopes.contains("scope_b"));
263        });
264
265        // Clearing only scope_a leaves scope_b intact.
266        clear_scope_deps("scope_a");
267        SCOPE_SIGNAL_DEPS.with(|d| {
268            let d = d.borrow();
269            let scopes = d.get(&sig.id()).unwrap();
270            assert!(!scopes.contains("scope_a"));
271            assert!(scopes.contains("scope_b"));
272        });
273        SCOPE_TO_SIGNALS.with(|d| {
274            assert!(d.borrow().get("scope_a").is_none());
275            assert!(d.borrow().get("scope_b").is_some());
276        });
277    }
278}