Skip to main content

xengui/
hooks.rs

1// SPDX-License-Identifier: Apache-2.0
2use smol_str::SmolStr;
3use std::any::Any;
4use std::cell::{ Cell, RefCell };
5use std::collections::{ HashMap, HashSet };
6use std::marker::PhantomData;
7use std::rc::Rc;
8use crate::RedrawRequester;
9
10#[derive(Clone, Debug, PartialEq, Eq, Hash)]
11pub struct ComponentId(SmolStr);
12
13impl ComponentId {
14    pub fn root() -> Self {
15        Self(SmolStr::new("root"))
16    }
17
18    pub fn as_str(&self) -> &str {
19        self.0.as_str()
20    }
21}
22
23#[derive(Clone, Debug)]
24pub struct ComponentKey(SmolStr);
25
26impl From<&str> for ComponentKey {
27    fn from(v: &str) -> Self {
28        Self(SmolStr::new(v))
29    }
30}
31
32impl From<String> for ComponentKey {
33    fn from(v: String) -> Self {
34        Self(SmolStr::new(v))
35    }
36}
37
38impl From<SmolStr> for ComponentKey {
39    fn from(v: SmolStr) -> Self {
40        Self(v)
41    }
42}
43
44macro_rules! impl_component_key_from_int {
45    ($($t:ty),*) => {
46        $(
47            impl From<$t> for ComponentKey {
48                fn from(v: $t) -> Self {
49                    Self(SmolStr::new(v.to_string()))
50                }
51            }
52        )*
53    };
54}
55impl_component_key_from_int!(u8, u16, u32, u64, usize, i8, i16, i32, i64, isize);
56
57struct ComponentState {
58    slots: Vec<Rc<RefCell<Box<dyn Any>>>>,
59    cursor: usize,
60}
61
62impl ComponentState {
63    fn new() -> Self {
64        Self { slots: Vec::new(), cursor: 0 }
65    }
66}
67
68thread_local! {
69    static HOOK_STORE: RefCell<HashMap<ComponentId, ComponentState>> = RefCell::new(HashMap::new());
70
71    static COMPONENT_STACK: RefCell<Vec<ComponentId>> = const { RefCell::new(Vec::new()) };
72
73    static LIVE_COMPONENTS: RefCell<HashSet<ComponentId>> = RefCell::new(HashSet::new());
74
75    static DIRTY: Cell<bool> = const { Cell::new(false) };
76
77    static REDRAW_HANDLE: RefCell<Option<Rc<dyn RedrawRequester>>> = const { RefCell::new(None) };
78
79    static RENDER_GENERATION: Cell<u64> = const { Cell::new(0) };
80
81    static PENDING_EFFECTS: RefCell<Vec<PendingEffect>> = const { RefCell::new(Vec::new()) };
82}
83
84pub fn begin_render() {
85    // Lets a render whose reconciliation gets superseded before finishing
86    // be identified later, so its queued effects are never executed.
87    RENDER_GENERATION.with(|g| g.set(g.get() + 1));
88    LIVE_COMPONENTS.with(|s| s.borrow_mut().clear());
89    COMPONENT_STACK.with(|s| {
90        let mut s = s.borrow_mut();
91        debug_assert!(
92            s.is_empty(),
93            "xengui hooks: component stack is not empty - begin_render/end_render may have been called unevenly"
94        );
95        s.clear();
96    });
97}
98
99pub fn end_render() {
100    LIVE_COMPONENTS.with(|live| {
101        let live = live.borrow();
102        HOOK_STORE.with(|store| {
103            store.borrow_mut().retain(|id, state| {
104                let keep = live.contains(id);
105                if !keep {
106                    run_unmount_cleanups(state);
107                }
108                keep
109            });
110        });
111    });
112}
113
114// Runs (and clears) every effect cleanup left behind by a component that
115// didn't appear in this render pass, since it will never build again.
116fn run_unmount_cleanups(state: &ComponentState) {
117    for slot in &state.slots {
118        let cleanup = slot
119            .borrow_mut()
120            .downcast_mut::<EffectRecord>()
121            .and_then(|record| record.cleanup.take());
122
123        if let Some(cleanup) = cleanup {
124            cleanup();
125        }
126    }
127}
128
129pub fn take_dirty() -> bool {
130    DIRTY.with(|d| d.replace(false))
131}
132
133pub fn set_redraw_handle(handle: Rc<dyn RedrawRequester>) {
134    REDRAW_HANDLE.with(|h| {
135        *h.borrow_mut() = Some(handle);
136    });
137}
138
139fn request_redraw() {
140    REDRAW_HANDLE.with(|h| {
141        if let Some(handle) = h.borrow().as_ref() {
142            handle.request_redraw();
143        }
144    });
145}
146
147fn current_component_id() -> ComponentId {
148    COMPONENT_STACK.with(|s| {
149        s.borrow()
150            .last()
151            .cloned()
152            .unwrap_or_else(|| {
153                panic!(
154                    "use_state: called outside a component() scope. \
155                 use_state can only be used within App::render's root function or \
156                 inside a component(key, ...) scope."
157                )
158            })
159    })
160}
161
162fn push_component(key: ComponentKey) -> ComponentId {
163    let id = COMPONENT_STACK.with(|s| {
164        match s.borrow().last() {
165            Some(parent) =>
166                ComponentId(SmolStr::new(format!("{}\u{1f}{}", parent.as_str(), key.0))),
167            None => ComponentId(key.0),
168        }
169    });
170
171    HOOK_STORE.with(|store| {
172        let mut store = store.borrow_mut();
173        let state = store.entry(id.clone()).or_insert_with(ComponentState::new);
174        state.cursor = 0;
175    });
176
177    let first_time_this_frame = LIVE_COMPONENTS.with(|s| s.borrow_mut().insert(id.clone()));
178    if !first_time_this_frame {
179        log::warn!(
180            "xengui: duplicate component key '{}' - used twice in the same frame. \
181             In dynamic lists, give each item a unique key (like React's 'key' prop).",
182            id.as_str()
183        );
184    }
185
186    COMPONENT_STACK.with(|s| s.borrow_mut().push(id.clone()));
187    id
188}
189
190fn pop_component() {
191    COMPONENT_STACK.with(|s| {
192        s.borrow_mut().pop();
193    });
194}
195
196/// component(key, render).
197///
198/// ```ignore
199/// let mut list_view = View::new().flex_direction(FlexDirection::Column);
200/// for item in &items {
201///     list_view = list_view.child(component(item.id, || {
202///         let (checked, set_checked) = use_state(false);
203///         Button::new()
204///             .label(if checked { "✓" } else { "" })
205///             .on_click(move |_ctx| set_checked.set(!checked))
206///     }));
207/// }
208/// ```
209pub fn component<R>(key: impl Into<ComponentKey>, render: impl FnOnce() -> R) -> R {
210    push_component(key.into());
211    let result = render();
212    pop_component();
213    result
214}
215
216/// Creates a state value that persists across component rebuilds.
217///
218/// `use_state` returns the current state value together with a setter that can
219/// be used to update it. Calling [`SetState::set`] schedules the owning
220/// component to be rebuilt with the new value.
221///
222/// State is preserved as long as the component's identity remains stable. When
223/// rendering dynamic lists, assign a stable key to each component to ensure
224/// that state is associated with the correct item.
225///
226/// ## Panics
227///
228/// Panics if the order of hook invocations changes between rebuilds. Hooks must
229/// always be called unconditionally and in the same order on every rebuild.
230///
231/// ## Example
232///
233/// ```ignore
234/// let (count, set_count) = use_state(0i32);
235///
236/// View::new().child(
237///     Button::new()
238///         .label(format!("Count: {count}"))
239///         .on_click(move |_ctx| set_count.set(count + 1))
240/// );
241/// ```
242pub fn use_state<T: Clone + 'static>(initial: T) -> (T, SetState<T>) {
243    let id = current_component_id();
244
245    let (slot, idx) = HOOK_STORE.with(|store| {
246        let mut store = store.borrow_mut();
247        let state = store
248            .get_mut(&id)
249            .expect("use_state: internal error - provided binding used without begin/push");
250
251        let idx = state.cursor;
252        state.cursor += 1;
253
254        if idx == state.slots.len() {
255            state.slots.push(Rc::new(RefCell::new(Box::new(initial) as Box<dyn Any>)));
256        }
257
258        (state.slots[idx].clone(), idx)
259    });
260
261    let value = {
262        let borrowed = slot.borrow();
263        borrowed
264            .downcast_ref::<T>()
265            .unwrap_or_else(|| {
266                panic!(
267                    "use_state: hook order broken in component '{}' (slot #{idx}) - do not call use_state conditionally (inside an if/loop). In dynamic lists, wrap each item in a component (e.g., component(key, ...)) to give it its own isolated hook order.",
268                    id.as_str()
269                )
270            })
271            .clone()
272    };
273
274    (
275        value,
276        SetState {
277            slot,
278            _marker: PhantomData,
279        },
280    )
281}
282
283pub struct SetState<T> {
284    slot: Rc<RefCell<Box<dyn Any>>>,
285    _marker: PhantomData<T>,
286}
287
288impl<T> Clone for SetState<T> {
289    fn clone(&self) -> Self {
290        Self {
291            slot: self.slot.clone(),
292            _marker: PhantomData,
293        }
294    }
295}
296
297impl<T: 'static> SetState<T> {
298    pub fn set(&self, value: T) {
299        *self.slot.borrow_mut() = Box::new(value);
300        DIRTY.with(|d| d.set(true));
301        request_redraw();
302    }
303
304    pub fn update(&self, f: impl FnOnce(&mut T)) {
305        {
306            let mut borrowed = self.slot.borrow_mut();
307            let current = borrowed
308                .downcast_mut::<T>()
309                .expect("use_state: SetState<T> used with the wrong type");
310
311            f(current);
312        }
313        DIRTY.with(|d| d.set(true));
314        request_redraw();
315    }
316}
317
318pub fn mark_dirty_and_redraw() {
319    DIRTY.with(|d| d.set(true));
320    request_redraw();
321}
322
323fn current_generation() -> u64 {
324    RENDER_GENERATION.with(Cell::get)
325}
326
327/// Object-safe equality for an effect's whole dependency list, so
328/// `use_effect` can accept `()`, arrays, or slices without needing one
329/// concrete type shared across every call site.
330trait StoredDeps: Any {
331    fn eq_dyn(&self, other: &dyn StoredDeps) -> bool;
332    fn as_any(&self) -> &dyn Any;
333}
334
335impl<T: PartialEq + 'static> StoredDeps for T {
336    fn eq_dyn(&self, other: &dyn StoredDeps) -> bool {
337        other
338            .as_any()
339            .downcast_ref::<T>()
340            .is_some_and(|o| self == o)
341    }
342
343    fn as_any(&self) -> &dyn Any {
344        self
345    }
346}
347
348/// Opaque, erased snapshot of a `use_effect` dependency list, stored
349/// between renders to decide whether the effect needs to run again.
350pub struct DepsSnapshot(Box<dyn StoredDeps>);
351
352fn deps_changed(old: &DepsSnapshot, new: &DepsSnapshot) -> bool {
353    !old.0.eq_dyn(new.0.as_ref())
354}
355
356/// Converts a dependency list passed to [`use_effect`] into a comparable,
357/// erased snapshot. Implemented for `()` (no dependencies - runs once),
358/// owned arrays, and array/slice references.
359pub trait EffectDeps {
360    fn snapshot(self) -> DepsSnapshot;
361}
362
363impl EffectDeps for () {
364    fn snapshot(self) -> DepsSnapshot {
365        DepsSnapshot(Box::new(()))
366    }
367}
368
369impl<T: PartialEq + 'static, const N: usize> EffectDeps for [T; N] {
370    fn snapshot(self) -> DepsSnapshot {
371        DepsSnapshot(Box::new(self))
372    }
373}
374
375impl<T: PartialEq + Clone + 'static, const N: usize> EffectDeps for &[T; N] {
376    fn snapshot(self) -> DepsSnapshot {
377        DepsSnapshot(Box::new(self.clone()))
378    }
379}
380
381impl<T: PartialEq + Clone + 'static> EffectDeps for &[T] {
382    fn snapshot(self) -> DepsSnapshot {
383        DepsSnapshot(Box::new(self.to_vec()))
384    }
385}
386
387/// Normalizes a `use_effect` closure's return value: `()` means no
388/// cleanup, anything callable once becomes the cleanup that runs before
389/// the next execution (or on unmount).
390pub trait EffectCleanup {
391    fn into_cleanup(self) -> Option<Box<dyn FnOnce()>>;
392}
393
394impl EffectCleanup for () {
395    fn into_cleanup(self) -> Option<Box<dyn FnOnce()>> {
396        None
397    }
398}
399
400impl<F: FnOnce() + 'static> EffectCleanup for F {
401    fn into_cleanup(self) -> Option<Box<dyn FnOnce()>> {
402        Some(Box::new(self))
403    }
404}
405
406struct EffectRecord {
407    deps: Option<DepsSnapshot>,
408    cleanup: Option<Box<dyn FnOnce()>>,
409    mounted: bool,
410    pending: bool,
411}
412
413type BoxedEffectFn = Box<dyn FnOnce() -> Option<Box<dyn FnOnce()>>>;
414
415struct PendingEffect {
416    slot: Rc<RefCell<Box<dyn Any>>>,
417    new_deps: DepsSnapshot,
418    run: BoxedEffectFn,
419    generation: u64,
420}
421
422/// Runs a side effect after this component's tree has actually been
423/// committed, similar to React's `useEffect`.
424///
425/// `effect` runs once after the first successful render, and again
426/// whenever `deps` changes value (compared by equality, not identity).
427/// Returning a closure from `effect` registers it as cleanup, run right
428/// before the next execution and when the component is unmounted.
429///
430/// ## Panics
431///
432/// Panics if called outside a `component()` scope, or if the order of
433/// hook invocations changes between rebuilds (see [`use_state`]).
434pub fn use_effect<F, R, D>(effect: F, deps: D)
435    where F: FnOnce() -> R + 'static, R: EffectCleanup + 'static, D: EffectDeps
436{
437    let id = current_component_id();
438    let new_deps = deps.snapshot();
439
440    let (slot, idx) = HOOK_STORE.with(|store| {
441        let mut store = store.borrow_mut();
442        let state = store
443            .get_mut(&id)
444            .expect("use_effect: internal error - provided binding used without begin/push");
445
446        let idx = state.cursor;
447        state.cursor += 1;
448
449        if idx == state.slots.len() {
450            state.slots.push(
451                Rc::new(
452                    RefCell::new(
453                        Box::new(EffectRecord {
454                            deps: None,
455                            cleanup: None,
456                            mounted: false,
457                            pending: false,
458                        }) as Box<dyn Any>
459                    )
460                )
461            );
462        }
463
464        (state.slots[idx].clone(), idx)
465    });
466
467    let should_run = {
468        let borrowed = slot.borrow();
469        let record = borrowed
470            .downcast_ref::<EffectRecord>()
471            .unwrap_or_else(|| {
472                panic!(
473                    "use_effect: hook order broken in component '{}' (slot #{idx}) - do not call use_effect conditionally.",
474                    id.as_str()
475                )
476            });
477
478        match &record.deps {
479            None => true,
480            Some(old_deps) => deps_changed(old_deps, &new_deps),
481        }
482    };
483
484    if !should_run {
485        return;
486    }
487
488    slot
489        .borrow_mut()
490        .downcast_mut::<EffectRecord>()
491        .expect("use_effect: internal error").pending = true;
492
493    let run: BoxedEffectFn = Box::new(move || effect().into_cleanup());
494
495    PENDING_EFFECTS.with(|q| {
496        q.borrow_mut().push(PendingEffect {
497            slot,
498            new_deps,
499            run,
500            generation: current_generation(),
501        });
502    });
503}
504
505/// Runs every effect queued by the render that was just committed to the
506/// tree. Must be called after reconciliation completes, never during
507/// widget building - the reconciler's own commit point is the intended
508/// caller.
509pub fn run_pending_effects() {
510    let generation = current_generation();
511    let pending = PENDING_EFFECTS.with(|q| std::mem::take(&mut *q.borrow_mut()));
512
513    for entry in pending {
514        // An effect queued by a render that got superseded before its
515        // reconciliation finished was never actually committed, so it
516        // must not run.
517        if entry.generation != generation {
518            continue;
519        }
520
521        let old_cleanup = {
522            let mut boxed = entry.slot.borrow_mut();
523            let record = boxed.downcast_mut::<EffectRecord>().expect("use_effect: internal error");
524            record.pending = false;
525            record.cleanup.take()
526        };
527
528        if let Some(cleanup) = old_cleanup {
529            cleanup();
530        }
531
532        let new_cleanup = (entry.run)();
533
534        let mut boxed = entry.slot.borrow_mut();
535        let record = boxed.downcast_mut::<EffectRecord>().expect("use_effect: internal error");
536        record.deps = Some(entry.new_deps);
537        record.cleanup = new_cleanup;
538        record.mounted = true;
539    }
540}
541
542#[cfg(test)]
543mod effect_tests {
544    use super::*;
545
546    #[test]
547    fn runs_once_on_mount_and_skips_unchanged_deps() {
548        let log = Rc::new(RefCell::new(Vec::<String>::new()));
549
550        let build = || {
551            component("effect_mount_root", || {
552                let log = log.clone();
553                use_effect(move || {
554                    log.borrow_mut().push("mount".to_string());
555                }, ());
556            });
557        };
558
559        begin_render();
560        build();
561        end_render();
562        run_pending_effects();
563
564        begin_render();
565        build();
566        end_render();
567        run_pending_effects();
568
569        assert_eq!(*log.borrow(), vec!["mount".to_string()]);
570    }
571
572    #[test]
573    fn reruns_when_deps_change() {
574        let log = Rc::new(RefCell::new(Vec::<String>::new()));
575
576        let build = |value: i32| {
577            component("effect_deps_root", || {
578                let log = log.clone();
579                use_effect(
580                    move || {
581                        log.borrow_mut().push(format!("run:{value}"));
582                    },
583                    [value]
584                );
585            });
586        };
587
588        begin_render();
589        build(1);
590        end_render();
591        run_pending_effects();
592
593        begin_render();
594        build(1);
595        end_render();
596        run_pending_effects();
597
598        begin_render();
599        build(2);
600        end_render();
601        run_pending_effects();
602
603        assert_eq!(*log.borrow(), vec!["run:1".to_string(), "run:2".to_string()]);
604    }
605
606    #[test]
607    fn cleanup_runs_before_rerun_and_on_unmount() {
608        let log = Rc::new(RefCell::new(Vec::<String>::new()));
609
610        let build_child = |value: i32| {
611            component("effect_cleanup_child", || {
612                let log = log.clone();
613                use_effect(
614                    move || {
615                        log.borrow_mut().push(format!("run:{value}"));
616                        move || {
617                            log.borrow_mut().push(format!("cleanup:{value}"));
618                        }
619                    },
620                    [value]
621                );
622            });
623        };
624
625        begin_render();
626        component("effect_cleanup_root", || build_child(1));
627        end_render();
628        run_pending_effects();
629
630        begin_render();
631        component("effect_cleanup_root", || build_child(2));
632        end_render();
633        run_pending_effects();
634
635        assert_eq!(
636            *log.borrow(),
637            vec!["run:1".to_string(), "cleanup:1".to_string(), "run:2".to_string()]
638        );
639
640        // Third render omits the child entirely, so its cleanup must fire
641        // once during end_render's unmount pass.
642        begin_render();
643        component("effect_cleanup_root", || {});
644        end_render();
645        run_pending_effects();
646
647        assert_eq!(
648            *log.borrow(),
649            vec![
650                "run:1".to_string(),
651                "cleanup:1".to_string(),
652                "run:2".to_string(),
653                "cleanup:2".to_string()
654            ]
655        );
656    }
657}