Skip to main content

pocopine_core/
model_runtime.rs

1use std::cell::{Cell, RefCell};
2use std::collections::HashMap;
3
4use js_sys::JSON;
5use wasm_bindgen::JsValue;
6use web_sys::{CustomEvent, CustomEventInit, Element};
7
8use crate::reactive::ScopeId;
9use crate::scope::Scope;
10use crate::tick;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum WriteOrigin {
14    ParentModelIn,
15    LocalHandler,
16    SetupSeed,
17    ObserveMirror,
18}
19
20#[derive(Clone)]
21struct PendingModel {
22    wire_name: String,
23    detail: JsValue,
24    origin: WriteOrigin,
25}
26
27#[derive(Default)]
28struct ScopeModelRuntime {
29    emit_el: Option<Element>,
30    pending: HashMap<String, PendingModel>,
31}
32
33thread_local! {
34    static WRITE_ORIGIN: Cell<WriteOrigin> = const { Cell::new(WriteOrigin::LocalHandler) };
35    static MODEL_RUNTIME: RefCell<HashMap<ScopeId, ScopeModelRuntime>> =
36        RefCell::new(HashMap::new());
37    static FLUSH_PENDING: RefCell<Vec<ScopeId>> = const { RefCell::new(Vec::new()) };
38    static FLUSH_SCHEDULED: Cell<bool> = const { Cell::new(false) };
39}
40
41pub fn with_write_origin<R>(origin: WriteOrigin, f: impl FnOnce() -> R) -> R {
42    let prev = WRITE_ORIGIN.with(|c| c.replace(origin));
43    let out = f();
44    WRITE_ORIGIN.with(|c| c.set(prev));
45    out
46}
47
48pub fn current_write_origin() -> WriteOrigin {
49    WRITE_ORIGIN.with(|c| c.get())
50}
51
52pub fn capture_emit_el(scope_id: ScopeId, el: &Element) {
53    MODEL_RUNTIME.with(|m| {
54        m.borrow_mut().entry(scope_id).or_default().emit_el = Some(el.clone());
55    });
56}
57
58pub fn clear_scope(scope_id: ScopeId) {
59    MODEL_RUNTIME.with(|m| {
60        m.borrow_mut().remove(&scope_id);
61    });
62    FLUSH_PENDING.with(|q| {
63        q.borrow_mut().retain(|id| *id != scope_id);
64    });
65}
66
67pub fn emit_target(scope_id: ScopeId) -> Option<Element> {
68    MODEL_RUNTIME.with(|m| m.borrow().get(&scope_id).and_then(|rt| rt.emit_el.clone()))
69}
70
71pub fn resolve_model_key(scope_id: ScopeId, wire_name: &str) -> Option<String> {
72    let scope = Scope::find(scope_id)?;
73    let state = scope.state.borrow();
74    for key in state.keys() {
75        if state.is_model(key) && state.model_name(key) == Some(wire_name) {
76            return Some((*key).to_string());
77        }
78    }
79    if state.keys().contains(&wire_name) {
80        return Some(wire_name.to_string());
81    }
82    None
83}
84
85pub fn with_scope_write<R>(scope_id: ScopeId, origin: WriteOrigin, f: impl FnOnce() -> R) -> R {
86    let before = snapshot_models(scope_id);
87    let out = with_write_origin(origin, f);
88    let after = snapshot_models(scope_id);
89    queue_changed_models(scope_id, origin, before, after);
90    out
91}
92
93fn snapshot_models(scope_id: ScopeId) -> HashMap<String, (String, JsValue, String)> {
94    let Some(scope) = Scope::find(scope_id) else {
95        return HashMap::new();
96    };
97    // Re-entrant `Scope::invoke` (handler → DOM mutation → synchronous
98    // event dispatch → another `Scope::invoke` for the same scope)
99    // arrives here with the outer's `borrow_mut` still held. We
100    // cannot snapshot under a live mutable borrow, but panicking on
101    // the inner invoke is the wrong call — the outer write is still
102    // valid; we just lose the inner write's diff. Skip silently:
103    // model `pp:update:*` events for the inner invoke degrade to
104    // best-effort rather than aborting the whole event chain.
105    let Ok(state) = scope.state.try_borrow() else {
106        return HashMap::new();
107    };
108    let mut out = HashMap::new();
109    for key in state.keys() {
110        if !state.is_model(key) {
111            continue;
112        }
113        let value = state.get_model_value(key);
114        let Some(fingerprint) = fingerprint(&value) else {
115            continue;
116        };
117        let wire_name = state.model_name(key).unwrap_or(key).to_string();
118        out.insert((*key).to_string(), (wire_name, value, fingerprint));
119    }
120    out
121}
122
123fn fingerprint(value: &JsValue) -> Option<String> {
124    if value.is_undefined() {
125        return Some("undefined".into());
126    }
127    JSON::stringify(value).ok()?.as_string()
128}
129
130fn queue_changed_models(
131    scope_id: ScopeId,
132    origin: WriteOrigin,
133    before: HashMap<String, (String, JsValue, String)>,
134    after: HashMap<String, (String, JsValue, String)>,
135) {
136    MODEL_RUNTIME.with(|m| {
137        let mut map = m.borrow_mut();
138        let runtime = map.entry(scope_id).or_default();
139        for (key, (wire_name, detail, after_fp)) in after {
140            let changed = before
141                .get(&key)
142                .map(|(_, _, before_fp)| before_fp != &after_fp)
143                .unwrap_or(true);
144            if !changed {
145                continue;
146            }
147            runtime.pending.insert(
148                key,
149                PendingModel {
150                    wire_name,
151                    detail,
152                    origin,
153                },
154            );
155        }
156    });
157    schedule_flush(scope_id);
158}
159
160fn schedule_flush(scope_id: ScopeId) {
161    FLUSH_PENDING.with(|q| {
162        let mut q = q.borrow_mut();
163        if !q.contains(&scope_id) {
164            q.push(scope_id);
165        }
166    });
167    if FLUSH_SCHEDULED.with(|f| f.replace(true)) {
168        return;
169    }
170    tick::next(|| {
171        FLUSH_SCHEDULED.with(|f| f.set(false));
172        flush_pending();
173    });
174}
175
176fn flush_pending() {
177    let scope_ids = FLUSH_PENDING.with(|q| q.borrow_mut().drain(..).collect::<Vec<_>>());
178    for scope_id in scope_ids {
179        let (emit_el, pending) = MODEL_RUNTIME.with(|m| {
180            let mut map = m.borrow_mut();
181            let Some(runtime) = map.get_mut(&scope_id) else {
182                return (None, Vec::new());
183            };
184            let emit_el = runtime.emit_el.clone();
185            let pending = runtime
186                .pending
187                .drain()
188                .map(|(_, item)| item)
189                .collect::<Vec<_>>();
190            (emit_el, pending)
191        });
192        let Some(el) = emit_el else { continue };
193        for item in pending {
194            // RFC-044 §5.5 origin suppression — none of these should
195            // advance the public two-way contract outward:
196            //
197            //   ParentModelIn — a parent's `pp-model:<field>` write.
198            //     Echoing it back out would immediately rewrite the
199            //     same value on the parent; hard loop.
200            //   SetupSeed — initial hydration. Not a user-visible
201            //     contract advance; parent already drove the value.
202            //   ObserveMirror — a `#[observe(KEY)]` mirror from a
203            //     provided root Handle. Re-publishing on every
204            //     observed change would ping-pong: Root write →
205            //     observe fires → child emit → outer parent's
206            //     pp-model listener → Root write back. Authors who
207            //     want observed changes to re-publish must do so
208            //     from an explicit handler (which runs under
209            //     LocalHandler origin and thus emits).
210            if matches!(
211                item.origin,
212                WriteOrigin::ParentModelIn | WriteOrigin::SetupSeed | WriteOrigin::ObserveMirror
213            ) {
214                #[cfg(any(debug_assertions, feature = "devtools"))]
215                testing::record_suppressed(scope_id, &item);
216                continue;
217            }
218            #[cfg(any(debug_assertions, feature = "devtools"))]
219            testing::record_emitted(scope_id, &item);
220            let init = CustomEventInit::new();
221            init.set_bubbles(true);
222            init.set_detail(&item.detail);
223            let name = format!("pp:update:{}", item.wire_name);
224            if let Ok(ev) = CustomEvent::new_with_event_init_dict(&name, &init) {
225                let _ = el.dispatch_event(&ev);
226            }
227        }
228    }
229}
230
231/// Cfg-gated model-emission log for unit tests.
232///
233/// Mirrors the `stats()` / `listener_count()` pattern used elsewhere
234/// in the crate — compiled under `debug_assertions` and when the
235/// `devtools` feature is on, absent from release binaries built
236/// `--no-default-features --release`.
237///
238/// Usage from an author test:
239///
240/// ```ignore
241/// use pocopine_core::model_runtime::testing::{drain_emitted, reset};
242///
243/// reset();
244/// scope.invoke("select_date", js_sys::Array::of1(&"2024-06-15".into()).as_ref());
245/// pocopine_core::tick::flush_sync();
246/// let events = drain_emitted();
247/// assert_eq!(events.len(), 1);
248/// assert_eq!(events[0].wire_name, "value");
249/// ```
250///
251/// Semantics:
252///   - `drain_emitted` / `drain_suppressed` snapshot + clear the
253///     corresponding log. Subsequent calls return `Vec::new()`
254///     until new events land.
255///   - Entries are recorded at flush time, AFTER origin-based
256///     suppression has run — so `drain_emitted` contains only
257///     writes the runtime chose to publish, and `drain_suppressed`
258///     contains writes it silenced (ParentModelIn / SetupSeed /
259///     ObserveMirror). Inspecting the latter is how a test verifies
260///     "parent mirror-in did NOT echo back out" or "observe-driven
261///     write did NOT re-publish."
262///   - `reset` clears both logs + the pending-emit queue; use
263///     between tests that share a thread-local runtime.
264#[cfg(any(debug_assertions, feature = "devtools"))]
265pub mod testing {
266    use super::{PendingModel, WriteOrigin};
267    use crate::reactive::ScopeId;
268    use std::cell::RefCell;
269    use wasm_bindgen::JsValue;
270
271    /// One observed model emission / suppression, snapshotted at
272    /// flush time.
273    #[derive(Clone, Debug)]
274    pub struct ModelEvent {
275        pub scope_id: ScopeId,
276        pub wire_name: String,
277        pub detail: JsValue,
278        pub origin: WriteOrigin,
279    }
280
281    thread_local! {
282        static EMITTED_LOG: RefCell<Vec<ModelEvent>> = const { RefCell::new(Vec::new()) };
283        static SUPPRESSED_LOG: RefCell<Vec<ModelEvent>> = const { RefCell::new(Vec::new()) };
284    }
285
286    pub(super) fn record_emitted(scope_id: ScopeId, item: &PendingModel) {
287        EMITTED_LOG.with(|l| {
288            l.borrow_mut().push(ModelEvent {
289                scope_id,
290                wire_name: item.wire_name.clone(),
291                detail: item.detail.clone(),
292                origin: item.origin,
293            });
294        });
295    }
296
297    pub(super) fn record_suppressed(scope_id: ScopeId, item: &PendingModel) {
298        SUPPRESSED_LOG.with(|l| {
299            l.borrow_mut().push(ModelEvent {
300                scope_id,
301                wire_name: item.wire_name.clone(),
302                detail: item.detail.clone(),
303                origin: item.origin,
304            });
305        });
306    }
307
308    /// Snapshot + clear every model event emitted to the DOM since
309    /// the last call. Ordered by flush arrival.
310    pub fn drain_emitted() -> Vec<ModelEvent> {
311        EMITTED_LOG.with(|l| std::mem::take(&mut *l.borrow_mut()))
312    }
313
314    /// Snapshot + clear every model write the runtime suppressed
315    /// via origin rules. Use this to assert that e.g. a parent
316    /// `pp-model` write didn't echo back out.
317    pub fn drain_suppressed() -> Vec<ModelEvent> {
318        SUPPRESSED_LOG.with(|l| std::mem::take(&mut *l.borrow_mut()))
319    }
320
321    /// Clear both logs AND the pending-emit queue. Call between
322    /// tests that share the thread-local model runtime.
323    pub fn reset() {
324        EMITTED_LOG.with(|l| l.borrow_mut().clear());
325        SUPPRESSED_LOG.with(|l| l.borrow_mut().clear());
326        super::MODEL_RUNTIME.with(|m| m.borrow_mut().clear());
327        super::FLUSH_PENDING.with(|q| q.borrow_mut().clear());
328        super::FLUSH_SCHEDULED.with(|f| f.set(false));
329    }
330}