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}