Skip to main content

velox_core/
lifecycle.rs

1// velox-core/src/lifecycle.rs
2//
3// All registry state here is thread-local `RefCell` behind `Rc`-free plain
4// data, so every hook map is `!Send` by construction — the same
5// single-threaded contract as `signal.rs` (no `Arc`/`Mutex`). Every borrow is
6// therefore fallible (`try_borrow`/`try_borrow_mut`): user hooks run without
7// any registry borrow held, so a hook that registers, runs, or cleans up other
8// hooks re-enters these functions instead of panicking with `BorrowMutError`.
9// A borrow that still fails means genuine contention further down this same
10// stack; run paths skip that pass and registration paths warn and drop the
11// hook rather than bringing down the event loop.
12use std::cell::{Cell, RefCell};
13use std::collections::HashMap;
14use std::rc::Rc;
15
16/// A unique identifier for a component instance
17type ComponentId = usize;
18
19thread_local! {
20    static NEXT_COMPONENT_ID: Cell<usize> = const { Cell::new(1) };
21    #[allow(clippy::type_complexity, clippy::missing_const_for_thread_local)]
22    static MOUNTED_HOOKS: RefCell<HashMap<ComponentId, Vec<Box<dyn FnOnce()>>>> = RefCell::new(HashMap::new());
23    #[allow(clippy::type_complexity, clippy::missing_const_for_thread_local)]
24    static DESTROY_HOOKS: RefCell<HashMap<ComponentId, Vec<Box<dyn FnOnce()>>>> = RefCell::new(HashMap::new());
25    #[allow(clippy::missing_const_for_thread_local)]
26    static CURRENT_COMPONENT: RefCell<Option<ComponentId>> = RefCell::new(None);
27}
28
29/// Generate a new unique component ID
30pub fn generate_component_id() -> ComponentId {
31    NEXT_COMPONENT_ID.with(|id| {
32        let current = id.get();
33        id.set(current.saturating_add(1));
34        current
35    })
36}
37
38/// Set the current component context for registering hooks
39pub fn set_current_component(id: ComponentId) {
40    CURRENT_COMPONENT.with(|c| {
41        if let Ok(mut guard) = c.try_borrow_mut() {
42            *guard = Some(id);
43        }
44    });
45}
46
47/// Clear the current component context
48pub fn clear_current_component() {
49    CURRENT_COMPONENT.with(|c| {
50        if let Ok(mut guard) = c.try_borrow_mut() {
51            *guard = None;
52        }
53    });
54}
55
56/// Get the current component ID if any
57///
58/// Returns `None` when no context is set *or* the context cell is borrowed
59/// further down the stack; hook registration treats both as "no context".
60pub fn current_component_id() -> Option<ComponentId> {
61    CURRENT_COMPONENT.with(|c| c.try_borrow().ok().and_then(|guard| *guard))
62}
63
64/// Register a hook to run when the current component is mounted
65pub fn on_mounted(f: impl FnOnce() + 'static) {
66    if let Some(id) = current_component_id() {
67        MOUNTED_HOOKS.with(|h| {
68            if let Ok(mut map) = h.try_borrow_mut() {
69                map.entry(id).or_default().push(Box::new(f));
70            } else {
71                log::warn!(
72                    "on_mounted: hook registry is borrowed; the hook was dropped \
73                     instead of panicking. This indicates a hook running while \
74                     the registry is held, which run paths never do."
75                );
76            }
77        });
78    } else {
79        log::warn!(
80            "on_mounted called without a current component context. \
81             Did you forget to call set_current_component()? The hook will not be registered."
82        );
83    }
84}
85
86/// Internal: run all mounted hooks for a specific component and clear them
87///
88/// The hook vec is removed while borrowed and run after the borrow is
89/// released, so a hook may register or run other hooks without panicking. A
90/// contended registry skips this pass instead of panicking.
91pub fn run_mounted_hooks(id: ComponentId) {
92    let hooks = MOUNTED_HOOKS.with(|h| h.try_borrow_mut().ok().and_then(|mut map| map.remove(&id)));
93    if let Some(hooks) = hooks {
94        for hook in hooks {
95            hook();
96        }
97    }
98}
99
100/// Register a hook to run before a component is destroyed
101pub fn before_destroy(f: impl FnOnce() + 'static) {
102    if let Some(id) = current_component_id() {
103        DESTROY_HOOKS.with(|h| {
104            if let Ok(mut map) = h.try_borrow_mut() {
105                map.entry(id).or_default().push(Box::new(f));
106            } else {
107                log::warn!(
108                    "before_destroy: hook registry is borrowed; the hook was \
109                     dropped instead of panicking."
110                );
111            }
112        });
113    } else {
114        log::warn!(
115            "before_destroy called without a current component context. \
116             Did you forget to call set_current_component()? The hook will not be registered."
117        );
118    }
119}
120
121/// Internal: run all destroy hooks for a specific component and clear them
122///
123/// Same remove-then-run discipline as `run_mounted_hooks`: no registry borrow
124/// is held across user code, and contention skips the pass.
125pub fn run_destroy_hooks(id: ComponentId) {
126    let hooks = DESTROY_HOOKS.with(|h| h.try_borrow_mut().ok().and_then(|mut map| map.remove(&id)));
127    if let Some(hooks) = hooks {
128        for hook in hooks {
129            hook();
130        }
131    }
132}
133
134/// Register a hook to run when the current component is unmounted.
135///
136/// This is an alias for [`before_destroy`] so the lifecycle API reads uniformly
137/// (`on_mounted` → `on_updated` → `on_unmounted`). Registered hooks run once,
138/// right before the component is destroyed.
139pub fn on_unmounted(f: impl FnOnce() + 'static) {
140    before_destroy(f);
141}
142
143/// Internal: run all unmounted hooks for a specific component and clear them.
144/// Alias for [`run_destroy_hooks`].
145pub fn run_unmounted_hooks(id: ComponentId) {
146    run_destroy_hooks(id);
147}
148
149// ===== on_updated =====
150//
151// Unlike `on_mounted`/`on_unmounted` (which fire once and are then removed),
152// an `on_updated` hook must stay registered so it runs again on every update
153// (re-render). It therefore lives in its own storage and is re-runnable (`FnMut`).
154
155type UpdatedHook = dyn FnMut();
156
157thread_local! {
158    #[allow(clippy::type_complexity, clippy::missing_const_for_thread_local)]
159    static UPDATED_HOOKS: RefCell<HashMap<ComponentId, Vec<Box<UpdatedHook>>>> = RefCell::new(HashMap::new());
160}
161
162/// Register a hook to run whenever the current component is updated (re-rendered).
163///
164/// The hook is re-runnable and stays registered until [`cleanup_component`] is
165/// called (e.g. when the component is unmounted), so it fires on every update.
166pub fn on_updated(f: impl FnMut() + 'static) {
167    if let Some(id) = current_component_id() {
168        UPDATED_HOOKS.with(|h| {
169            if let Ok(mut map) = h.try_borrow_mut() {
170                map.entry(id).or_default().push(Box::new(f));
171            } else {
172                log::warn!(
173                    "on_updated: hook registry is borrowed; the hook was \
174                     dropped instead of panicking."
175                );
176            }
177        });
178    } else {
179        log::warn!(
180            "on_updated called without a current component context. \
181             Did you forget to call set_current_component()? The hook will not be registered."
182        );
183    }
184}
185
186/// Internal: run all updated hooks for a specific component.
187/// Unlike mounted/destroy hooks, updated hooks are NOT removed — they fire on
188/// every update.
189///
190/// The vec is taken out of the registry before any hook runs, so a hook that
191/// registers another `on_updated` (or triggers a nested update dispatch) does
192/// not borrow the map while it is borrowed — the old code held `borrow_mut`
193/// across every `hook()` call and panicked on exactly that. After the run the
194/// hooks are merged back in front of any newly registered ones, preserving
195/// registration order; a hook registered mid-run therefore fires from the
196/// next dispatch, matching the old length-snapshot semantics. A contended
197/// registry skips this pass instead of panicking.
198pub fn run_updated_hooks(id: ComponentId) {
199    let Some(mut running) =
200        UPDATED_HOOKS.with(|h| h.try_borrow_mut().ok().and_then(|mut map| map.remove(&id)))
201    else {
202        return;
203    };
204    let len = running.len();
205    for i in 0..len {
206        if let Some(hook) = running.get_mut(i) {
207            hook();
208        }
209    }
210    UPDATED_HOOKS.with(|h| {
211        if let Ok(mut map) = h.try_borrow_mut() {
212            match map.get_mut(&id) {
213                Some(fresh) => {
214                    // Hooks that ran go back in front, in order; anything
215                    // registered mid-run stays queued behind them.
216                    let mut done = std::mem::take(&mut running);
217                    done.append(fresh);
218                    *fresh = done;
219                }
220                None => {
221                    map.insert(id, running);
222                }
223            }
224        } else {
225            log::warn!(
226                "run_updated_hooks: registry is borrowed while merging hooks \
227                 back; the component's updated hooks were dropped instead of \
228                 panicking."
229            );
230        }
231    });
232}
233
234/// Internal: clear all updated hooks for a component.
235pub fn clear_updated_hooks(id: ComponentId) {
236    UPDATED_HOOKS.with(|h| {
237        if let Ok(mut map) = h.try_borrow_mut() {
238            map.remove(&id);
239        }
240    });
241}
242
243/// Check whether a component has any registered `on_updated` hooks.
244///
245/// A contended registry reports `false` rather than panicking.
246pub fn has_updated_hooks(id: ComponentId) -> bool {
247    UPDATED_HOOKS.with(|h| match h.try_borrow() {
248        Ok(map) => map.get(&id).is_some_and(|hooks| !hooks.is_empty()),
249        Err(_) => false,
250    })
251}
252
253// ===== on_resize =====
254//
255// Resize hooks are re-runnable, like updated hooks, and remain registered until
256// the owning component is cleaned up. Each hook is kept behind an `Rc<RefCell<_>>`
257// so the registry map can be released before user code runs; this permits a
258// hook to register another hook or touch component state without a registry
259// borrow panic.
260
261type ResizeHook = dyn FnMut(u32, u32);
262type ResizeHookRef = Rc<RefCell<ResizeHook>>;
263
264thread_local! {
265    #[allow(clippy::type_complexity, clippy::missing_const_for_thread_local)]
266    static RESIZE_HOOKS: RefCell<HashMap<ComponentId, Vec<ResizeHookRef>>> = RefCell::new(HashMap::new());
267}
268
269/// Register a hook to run when the current component's viewport changes.
270///
271/// `width` and `height` are logical/CSS pixels, not physical framebuffer
272/// pixels. The hook is re-runnable and is removed by [`cleanup_component`].
273pub fn on_resize(f: impl FnMut(u32, u32) + 'static) {
274    if let Some(id) = current_component_id() {
275        RESIZE_HOOKS.with(|h| {
276            if let Ok(mut map) = h.try_borrow_mut() {
277                map.entry(id).or_default().push(Rc::new(RefCell::new(f)));
278            } else {
279                log::warn!(
280                    "on_resize: hook registry is borrowed; the hook was \
281                     dropped instead of panicking."
282                );
283            }
284        });
285    } else {
286        log::warn!(
287            "on_resize called without a current component context. \
288             Did you forget to call set_current_component()? The hook will not be registered."
289        );
290    }
291}
292
293/// Run all registered resize hooks for a committed logical viewport size.
294///
295/// This public entry point dispatches each call. The renderer owns the
296/// change-only baseline per window and calls it only after a committed logical
297/// size change, so direct core callers must provide their own dispatch scope.
298pub fn run_resize_hooks(width: u32, height: u32) {
299    // Clone handles while the registry is borrowed, then drop that borrow
300    // before invoking any user code. A newly registered hook is intentionally
301    // deferred until the next dispatch. A contended registry dispatches to
302    // nothing instead of panicking.
303    let hooks: Vec<ResizeHookRef> = RESIZE_HOOKS.with(|h| {
304        h.try_borrow()
305            .ok()
306            .map(|map| {
307                map.values()
308                    .flat_map(|component_hooks| component_hooks.iter().cloned())
309                    .collect()
310            })
311            .unwrap_or_default()
312    });
313
314    for hook in hooks {
315        // `try_borrow_mut` keeps a nested dispatch from panicking if a hook
316        // re-enters this dispatcher while it is already running. The outer
317        // invocation still completes normally, and no registry map guard is
318        // held across either call.
319        if let Ok(mut callback) = hook.try_borrow_mut() {
320            callback(width, height);
321        }
322    }
323}
324
325/// Compatibility name matching the renderer's other global lifecycle helpers.
326pub fn run_all_resize_hooks(width: u32, height: u32) {
327    run_resize_hooks(width, height);
328}
329
330/// Clear all resize hooks for a component (call when component is destroyed).
331pub fn clear_resize_hooks(id: ComponentId) {
332    RESIZE_HOOKS.with(|h| {
333        if let Ok(mut map) = h.try_borrow_mut() {
334            map.remove(&id);
335        }
336    });
337}
338
339/// Check whether a component has any registered resize hooks.
340///
341/// A contended registry reports `false` rather than panicking.
342pub fn has_resize_hooks(id: ComponentId) -> bool {
343    RESIZE_HOOKS.with(|h| match h.try_borrow() {
344        Ok(map) => map.get(&id).is_some_and(|hooks| !hooks.is_empty()),
345        Err(_) => false,
346    })
347}
348
349/// Run *all* queued `on_mounted` hooks that have not yet fired.
350///
351/// Mounted hooks are removed after they run, so this call is idempotent — the
352/// renderer may safely call it at the top of every `RedrawRequested` frame.
353/// A contended registry runs nothing instead of panicking.
354pub fn run_all_mounted_hooks() {
355    let ids: Vec<ComponentId> = MOUNTED_HOOKS.with(|h| {
356        h.try_borrow()
357            .ok()
358            .map(|map| map.keys().copied().collect())
359            .unwrap_or_default()
360    });
361    for id in ids {
362        run_mounted_hooks(id);
363    }
364}
365
366/// Run all `on_updated` hooks for every mounted component.
367pub fn run_all_updated_hooks() {
368    let ids: Vec<ComponentId> = UPDATED_HOOKS.with(|h| {
369        h.try_borrow()
370            .ok()
371            .map(|map| map.keys().copied().collect())
372            .unwrap_or_default()
373    });
374    for id in ids {
375        run_updated_hooks(id);
376    }
377}
378
379/// Run (and remove) all `before_destroy` / `on_unmounted` hooks for every
380/// component and clear their `on_updated` and resize hooks. The renderer calls
381/// this from `CloseRequested` (window close) and from the drop guard around the
382/// event loop.
383pub fn run_all_destroy_hooks() {
384    let ids: Vec<ComponentId> = DESTROY_HOOKS.with(|h| {
385        h.try_borrow()
386            .ok()
387            .map(|map| map.keys().copied().collect())
388            .unwrap_or_default()
389    });
390    for id in ids {
391        run_destroy_hooks(id);
392    }
393    UPDATED_HOOKS.with(|h| {
394        if let Ok(mut map) = h.try_borrow_mut() {
395            map.clear();
396        }
397    });
398    RESIZE_HOOKS.with(|h| {
399        if let Ok(mut map) = h.try_borrow_mut() {
400            map.clear();
401        }
402    });
403    MOUNTED_HOOKS.with(|h| {
404        if let Ok(mut map) = h.try_borrow_mut() {
405            map.clear();
406        }
407    });
408    // `DESTROY_HOOKS` entries were already removed by `run_destroy_hooks`; the
409    // clear above covers cases where cleanup_component was not yet called.
410}
411
412/// Clean up all hooks for a component (call when component is fully destroyed)
413pub fn cleanup_component(id: ComponentId) {
414    MOUNTED_HOOKS.with(|h| {
415        if let Ok(mut map) = h.try_borrow_mut() {
416            map.remove(&id);
417        }
418    });
419    DESTROY_HOOKS.with(|h| {
420        if let Ok(mut map) = h.try_borrow_mut() {
421            map.remove(&id);
422        }
423    });
424    UPDATED_HOOKS.with(|h| {
425        if let Ok(mut map) = h.try_borrow_mut() {
426            map.remove(&id);
427        }
428    });
429    clear_resize_hooks(id);
430}
431
432/// RAII handle that owns a component id and runs destroy+cleanup on drop.
433///
434/// The renderer creates one per mounted app and keeps it alive for the
435/// duration of the event loop so `on_unmounted`/`before_destroy` fire even when
436/// the loop exits via `Drop` rather than an explicit `CloseRequested`.
437pub struct LifecycleHandle {
438    id: ComponentId,
439    mounted: Cell<bool>,
440}
441
442impl LifecycleHandle {
443    /// Allocate a fresh component id and enter its registration context.
444    #[must_use]
445    pub fn new() -> Self {
446        let id = generate_component_id();
447        set_current_component(id);
448        Self {
449            id,
450            mounted: Cell::new(false),
451        }
452    }
453
454    /// Wrap an existing `id` (the id is *not* re-entered into the current
455    /// context; call `set_current_component` if hook registration is needed).
456    #[must_use]
457    pub fn with_id(id: ComponentId) -> Self {
458        Self {
459            id,
460            mounted: Cell::new(false),
461        }
462    }
463
464    pub fn id(&self) -> ComponentId {
465        self.id
466    }
467
468    /// Run mounted hooks once. Safe to call redundantly.
469    pub fn mount(&self) {
470        if !self.mounted.get() {
471            self.mounted.set(true);
472            run_mounted_hooks(self.id);
473        }
474    }
475
476    /// True once [`mount`](Self::mount) has been called.
477    pub fn is_mounted(&self) -> bool {
478        self.mounted.get()
479    }
480}
481
482impl Default for LifecycleHandle {
483    fn default() -> Self {
484        Self::new()
485    }
486}
487
488impl Drop for LifecycleHandle {
489    fn drop(&mut self) {
490        run_destroy_hooks(self.id);
491        cleanup_component(self.id);
492    }
493}
494
495// ===== Lifecycle macros =====
496//
497// These are `#[macro_export]`, so they live at the crate root and are callable
498// from anywhere with a path, e.g. `velox_core::on_mounted!{ ... }` — exactly how
499// an SFC `<script setup>` block (which is embedded verbatim into a generated
500// module) would call them. Importing `use velox_core::on_mounted;` also works.
501//
502// Each macro accepts either a single expression (a closure) or a block body;
503// a block is wrapped in a `move ||` closure for you.
504
505/// Register a hook to run when the current component is mounted.
506///
507/// Pass a block body or a closure:
508/// ```rust,ignore
509/// velox_core::lifecycle::set_current_component(velox_core::lifecycle::generate_component_id());
510/// velox_core::on_mounted! { { println!("mounted"); } }
511/// velox_core::on_mounted!(|| println!("also mounted"));
512/// ```
513#[macro_export]
514macro_rules! on_mounted {
515    ({ $($body:tt)* }) => {
516        $crate::lifecycle::on_mounted(move || { $($body)* })
517    };
518    ($f:expr) => {
519        $crate::lifecycle::on_mounted($f)
520    };
521}
522
523/// Register a hook to run whenever the current component is updated (re-rendered).
524///
525/// Register a hook to run whenever the current component is updated (re-rendered).
526///
527/// ```rust,ignore
528/// velox_core::lifecycle::set_current_component(velox_core::lifecycle::generate_component_id());
529/// velox_core::on_updated! { { println!("updated"); } }
530/// ```
531#[macro_export]
532macro_rules! on_updated {
533    ({ $($body:tt)* }) => {
534        $crate::lifecycle::on_updated(move || { $($body)* })
535    };
536    ($f:expr) => {
537        $crate::lifecycle::on_updated($f)
538    };
539}
540
541/// Register a hook to run when the viewport is resized.
542///
543/// Pass a closure with two logical-pixel arguments:
544/// ```rust,ignore
545/// velox_core::lifecycle::set_current_component(velox_core::lifecycle::generate_component_id());
546/// velox_core::on_resize!(|width, height| println!("resized: {width}x{height}"));
547/// ```
548#[macro_export]
549macro_rules! on_resize {
550    ({ $($body:tt)* }) => {
551        $crate::lifecycle::on_resize(move |width, height| { $($body)* })
552    };
553    ($f:expr) => {
554        $crate::lifecycle::on_resize($f)
555    };
556}
557
558/// Register a hook to run when the current component is unmounted.
559///
560/// Register a hook to run when the current component is unmounted.
561///
562/// ```rust,ignore
563/// velox_core::lifecycle::set_current_component(velox_core::lifecycle::generate_component_id());
564/// velox_core::on_unmounted! { { println!("unmounted"); } }
565/// ```
566#[macro_export]
567macro_rules! on_unmounted {
568    ({ $($body:tt)* }) => {
569        $crate::lifecycle::on_unmounted(move || { $($body)* })
570    };
571    ($f:expr) => {
572        $crate::lifecycle::on_unmounted($f)
573    };
574}