Skip to main content

repose_core/
timer.rs

1//! Wall-clock and frame-based timers without an async runtime.
2//!
3//! Schedule a callback for an [`Instant`] or a frame count; the platform
4//! wakes the event loop exactly then via `ReposeRuntime::next_wakeup_deadline`
5//! (`ControlFlow::WaitUntil`). No executor, no threads, no per-frame cost
6//! when idle.
7//!
8//! Compose parallels (approximate):
9//!
10//! | Compose                           | Here                              |
11//! |-----------------------------------|-----------------------------------|
12//! | `delay(d)` half of `LaunchedEffect` | [`delay`] (unscoped; hold handle) |
13//! | `LaunchedEffect` without keys     | [`scoped_delay`] (unmount only)   |
14//! | fixed-rate repetition             | [`interval`] (drift-free)         |
15//! | run-after-deadline                | [`timeout`] ([`delay`] alias)      |
16//! | flow `debounce` (trailing edge)   | [`Debouncer`] / `debounced_signal`|
17//! | sequencing over redraws           | [`delay_frames`]                  |
18//!
19//! Caveats vs coroutines: handles and flags suppress only *pending* firings.
20//! There is no counterpart for cancelling *running* work (`LaunchedEffect`
21//! key-change/leave, `withTimeout` aborting in-flight work, `collectLatest`):
22//! a firing callback runs to completion, `timeout` is not `withTimeout`, and
23//! `delay_frames` counts redraw polls (no vsync timestamp, refresh-rate
24//! dependent) rather than subscribing to frames.
25//!
26//! Rules of thumb: wall-clock waiting goes through [`delay`]/[`interval`];
27//! sequencing after animations or redraws through [`delay_frames`]; reactive
28//! signal shaping through `debounced_signal`.
29
30use std::cell::{Cell, RefCell};
31use std::rc::Rc;
32
33use rustc_hash::FxHashMap;
34
35use web_time::{Duration, Instant};
36
37use crate::{request_frame, unique_component_id};
38
39/// Minimum period for repeating timers. Zero would re-fire every poll and
40/// busy-loop the event loop; clamp instead of panicking.
41const MIN_PERIOD: Duration = Duration::from_millis(1);
42
43thread_local! {
44    static REGISTRY: RefCell<FxHashMap<u64, Entry>> = RefCell::new(FxHashMap::default());
45    /// Redraw counter, advanced by [`poll`]. Basis for [`delay_frames`].
46    static FRAME: Cell<u64> = const { Cell::new(0) };
47    /// Reentrancy flag: [`poll`] ignores reentrant calls (e.g. from inside
48    /// a timer callback), where a nested pass would fire due timers twice.
49    static IN_POLL: Cell<bool> = const { Cell::new(false) };
50}
51
52type Callback = Rc<RefCell<Box<dyn FnMut()>>>;
53
54#[derive(Clone, Copy)]
55enum Due {
56    /// Fire when wall-clock reaches this instant.
57    At(Instant),
58    /// Fire once [`FRAME`] reaches this count.
59    Frame(u64),
60}
61
62#[derive(Clone, Copy)]
63enum Repeat {
64    Once,
65    Every { period: Duration },
66    Times { period: Duration, left: u32 },
67}
68
69struct Entry {
70    due: Due,
71    repeat: Repeat,
72    callback: Callback,
73}
74
75/// Owner of a scheduled timer. Dropping cancels it (no-op if already fired).
76#[must_use = "dropping the handle cancels the timer"]
77pub struct TimerHandle {
78    id: Option<u64>,
79}
80
81impl TimerHandle {
82    /// Cancel now instead of at drop. Consuming makes double-cancel impossible.
83    pub fn cancel(mut self) {
84        self.cancel_now();
85    }
86
87    /// Detach without cancelling: the timer fires even though no handle owns
88    /// it anymore. Prefer holding the handle so timers cancel with their
89    /// owner. Never detach a repeating timer unless it is truly global:
90    /// nothing will stop it afterwards.
91    pub fn detach(self) {
92        std::mem::forget(self);
93    }
94
95    fn cancel_now(&mut self) {
96        if let Some(id) = self.id.take() {
97            cancel(id);
98        }
99    }
100}
101
102impl Drop for TimerHandle {
103    fn drop(&mut self) {
104        self.cancel_now();
105    }
106}
107
108fn insert(due: Due, repeat: Repeat, callback: Callback) -> TimerHandle {
109    let id = unique_component_id();
110    let old = REGISTRY.with(|r| {
111        r.borrow_mut().insert(
112            id,
113            Entry {
114                due,
115                repeat,
116                callback,
117            },
118        )
119    });
120    drop(old);
121    request_frame();
122    TimerHandle { id: Some(id) }
123}
124
125fn cancel(id: u64) {
126    let removed = REGISTRY
127        .try_with(|r| {
128            r.try_borrow_mut()
129                .ok()
130                .and_then(|mut registry| registry.remove(&id))
131        })
132        .ok()
133        .flatten();
134    drop(removed);
135}
136
137fn wrap_once(cb: impl FnOnce() + 'static) -> Callback {
138    let mut cb = Some(cb);
139    Rc::new(RefCell::new(Box::new(move || {
140        if let Some(f) = cb.take() {
141            f();
142        }
143    }) as Box<dyn FnMut()>))
144}
145
146/// Run `cb` once after `duration`. Returns a handle; dropping it cancels.
147///
148/// The platform sleeps until the deadline (`ControlFlow::WaitUntil`), so an
149/// idle timer costs nothing per frame.
150pub fn delay(duration: Duration, cb: impl FnOnce() + 'static) -> TimerHandle {
151    insert(
152        Due::At(saturating_add(Instant::now(), duration)),
153        Repeat::Once,
154        wrap_once(cb),
155    )
156}
157
158/// Run `cb` once after `duration`. A [`delay`] alias naming the
159/// run-after-deadline intent. This is not `withTimeout`: it cannot abort
160/// in-flight work, it only starts `cb` once the duration elapses.
161pub fn timeout(duration: Duration, cb: impl FnOnce() + 'static) -> TimerHandle {
162    delay(duration, cb)
163}
164
165/// Run `cb` once after `frames` redraws have been polled. Counts polls, not
166/// vsync frames: no timestamp, refresh-rate dependent. While frame entries
167/// are pending [`poll`] keeps requesting frames. A `0` count fires on the
168/// next poll.
169pub fn delay_frames(frames: u32, cb: impl FnOnce() + 'static) -> TimerHandle {
170    let at = FRAME.with(|f| f.get().wrapping_add(frames as u64));
171    insert(Due::Frame(at), Repeat::Once, wrap_once(cb))
172}
173
174/// Run `cb` every `period`, drift-free (next fire = last scheduled fire +
175/// `period`, so slow frames skip beats instead of bunching). Dropping the
176/// handle stops the timer. Periods below 1ms are clamped to 1ms.
177pub fn interval(period: Duration, cb: impl FnMut() + 'static) -> TimerHandle {
178    let period = period.max(MIN_PERIOD);
179    insert(
180        Due::At(saturating_add(Instant::now(), period)),
181        Repeat::Every { period },
182        Rc::new(RefCell::new(Box::new(cb) as Box<dyn FnMut()>)),
183    )
184}
185
186/// Like [`interval`], but stops on its own after `times` firings. A `0` count
187/// schedules nothing and returns a disarmed handle.
188pub fn interval_n(period: Duration, times: u32, cb: impl FnMut() + 'static) -> TimerHandle {
189    if times == 0 {
190        return TimerHandle { id: None };
191    }
192    let period = period.max(MIN_PERIOD);
193    insert(
194        Due::At(saturating_add(Instant::now(), period)),
195        Repeat::Times {
196            period,
197            left: times,
198        },
199        Rc::new(RefCell::new(Box::new(cb) as Box<dyn FnMut()>)),
200    )
201}
202
203/// Current redraw count (advanced by [`poll`]). Basis for [`delay_frames`].
204pub fn frame_count() -> u64 {
205    FRAME.with(Cell::get)
206}
207
208/// Earliest wall-clock deadline, if any. Fed into
209/// `ReposeRuntime::next_wakeup_deadline` so the platform sleeps until a timer
210/// is due.
211pub fn next_deadline() -> Option<Instant> {
212    REGISTRY.with(|r| {
213        r.borrow()
214            .values()
215            .filter_map(|e| match e.due {
216                Due::At(t) => Some(t),
217                Due::Frame(_) => None,
218            })
219            .min()
220    })
221}
222
223/// Saturating `Instant + Duration`: absurd durations fire immediately
224/// instead of panicking on overflow.
225fn saturating_add(t: Instant, d: Duration) -> Instant {
226    t.checked_add(d).unwrap_or(t)
227}
228
229/// Advance the frame counter and fire due timers. Called once per redraw
230/// (from `ReposeRuntime::tick_overlays`). Reentrant calls, e.g. from inside
231/// a timer callback, are ignored: the outer pass already collected the due
232/// timers, so a nested pass would fire them twice.
233pub fn poll() {
234    if IN_POLL.with(|f| f.replace(true)) {
235        return;
236    }
237    struct Guard;
238    impl Drop for Guard {
239        fn drop(&mut self) {
240            let _ = IN_POLL.try_with(|f| f.set(false));
241        }
242    }
243    let _guard = Guard;
244    let frame = FRAME.with(|f| {
245        let frame = f.get().wrapping_add(1);
246        f.set(frame);
247        frame
248    });
249    let now = Instant::now();
250    // Ids are never reused, so rechecking membership before firing lets a
251    // same-batch cancel actually suppress the timer.
252    let mut due: Vec<(u64, Callback)> = Vec::new();
253    let mut remove: Vec<u64> = Vec::new();
254    let mut need_frames = false;
255    REGISTRY.with(|r| {
256        let mut reg = r.borrow_mut();
257        for (id, entry) in reg.iter_mut() {
258            let is_due = match entry.due {
259                Due::At(t) => t <= now,
260                Due::Frame(f) => {
261                    if frame >= f {
262                        true
263                    } else {
264                        need_frames = true;
265                        false
266                    }
267                }
268            };
269            if !is_due {
270                continue;
271            }
272            due.push((*id, entry.callback.clone()));
273            // Re-base on the old schedule, not now: keeps fixed-rate phase.
274            let base = match entry.due {
275                Due::At(t) => t,
276                Due::Frame(_) => now,
277            };
278            match entry.repeat {
279                Repeat::Once => remove.push(*id),
280                Repeat::Every { period } => {
281                    entry.due = Due::At(skip_ahead(base, period, now));
282                }
283                Repeat::Times { period, left } => {
284                    if left <= 1 {
285                        remove.push(*id);
286                    } else {
287                        entry.repeat = Repeat::Times {
288                            period,
289                            left: left - 1,
290                        };
291                        entry.due = Due::At(skip_ahead(base, period, now));
292                    }
293                }
294            }
295        }
296    });
297    for (id, cb) in due.iter() {
298        let live = REGISTRY.with(|r| r.borrow().contains_key(id));
299        if live {
300            cb.borrow_mut()();
301        }
302    }
303    if !remove.is_empty() {
304        let removed_entries = REGISTRY.with(|r| {
305            let mut reg = r.borrow_mut();
306            let removed = remove
307                .into_iter()
308                .filter_map(|id| reg.remove(&id))
309                .collect::<Vec<_>>();
310            need_frames = need_frames || reg.values().any(|e| matches!(e.due, Due::Frame(_)));
311            removed
312        });
313        drop(removed_entries);
314    }
315    if need_frames {
316        request_frame();
317    }
318}
319
320/// Skip missed beats so sleepers resume without bursting.
321fn skip_ahead(mut next: Instant, period: Duration, now: Instant) -> Instant {
322    let mut guard = 0u32;
323    while next <= now && guard < 1024 {
324        next = saturating_add(next, period);
325        guard += 1;
326    }
327    if next <= now {
328        saturating_add(now, period)
329    } else {
330        next
331    }
332}
333
334fn replace_timer_slot(slot: &Rc<RefCell<Option<TimerHandle>>>, value: Option<TimerHandle>) {
335    let old = slot
336        .try_borrow_mut()
337        .ok()
338        .map(|mut current| std::mem::replace(&mut *current, value));
339    drop(old);
340}
341
342/// Trailing-edge debouncer: each call reschedules the single pending firing.
343/// Cloneable (shared slot); dropping all clones cancels it.
344#[derive(Clone)]
345pub struct Debouncer {
346    delay: Duration,
347    pending: Rc<RefCell<Option<TimerHandle>>>,
348}
349
350impl Default for Debouncer {
351    /// 300 ms trailing debounce, the conventional UI default.
352    fn default() -> Self {
353        Self::new(Duration::from_millis(300))
354    }
355}
356
357impl Debouncer {
358    /// Debounce firings by `delay` of inactivity.
359    pub fn new(delay: Duration) -> Self {
360        Self {
361            delay: delay.max(MIN_PERIOD),
362            pending: Rc::new(RefCell::new(None)),
363        }
364    }
365
366    /// Schedule `cb` after a quiet `delay`, replacing any pending firing.
367    pub fn call(&self, cb: impl FnOnce() + 'static) {
368        replace_timer_slot(&self.pending, Some(delay(self.delay, cb)));
369    }
370
371    /// Drop the pending firing, if any.
372    pub fn cancel_pending(&self) {
373        replace_timer_slot(&self.pending, None);
374    }
375}
376
377/// Leading-edge throttler with one coalesced trailing firing per period.
378#[derive(Clone)]
379pub struct Throttler {
380    period: Duration,
381    last_fire: Rc<RefCell<Option<Instant>>>,
382    pending: Rc<RefCell<Option<TimerHandle>>>,
383}
384
385impl Throttler {
386    /// Throttle firings to at most one leading plus one trailing per `period`.
387    pub fn new(period: Duration) -> Self {
388        Self {
389            period: period.max(MIN_PERIOD),
390            last_fire: Rc::new(RefCell::new(None)),
391            pending: Rc::new(RefCell::new(None)),
392        }
393    }
394
395    /// Run `cb` now if the period elapsed since the last firing, else
396    /// coalesce it into the single trailing firing at the period edge.
397    /// Trailing windows slide from the actual fire time, not the edge.
398    pub fn call(&self, cb: impl FnOnce() + 'static) {
399        let now = Instant::now();
400        let edge = self
401            .last_fire
402            .borrow()
403            .map(|t| saturating_add(t, self.period))
404            .unwrap_or(now);
405        if now >= edge {
406            *self.last_fire.borrow_mut() = Some(now);
407            cb();
408        } else {
409            let last_fire = self.last_fire.clone();
410            replace_timer_slot(
411                &self.pending,
412                Some(delay(edge - now, move || {
413                    *last_fire.borrow_mut() = Some(Instant::now());
414                    cb();
415                })),
416            );
417        }
418    }
419}
420
421/// [`delay`] tied to the current composition scope: if the scope disposes
422/// before the deadline, the callback is suppressed. Schedules once per mount;
423/// use [`scoped_delay_with_key`] to restart on change.
424pub fn scoped_delay(duration: Duration, cb: impl FnOnce() + 'static) {
425    scoped_delay_with_key((), duration, cb);
426}
427
428struct ScopedSlot<K> {
429    key: Option<K>,
430    alive: Rc<RefCell<bool>>,
431    installed: bool,
432}
433
434/// Keyed [`scoped_delay`]: reschedules when `key` changes (cancelling the
435/// previous generation via its flag) and suppresses on unmount. Storage is
436/// callsite-keyed, so branches above the call site cannot shift slots.
437#[track_caller]
438pub fn scoped_delay_with_key<K: PartialEq + Clone + 'static>(
439    key: K,
440    duration: Duration,
441    cb: impl FnOnce() + 'static,
442) {
443    let loc = std::panic::Location::caller();
444    let callsite = format!(
445        "scoped_delay:{}:{}:{}",
446        loc.file(),
447        loc.line(),
448        loc.column()
449    );
450    let cell: Rc<RefCell<ScopedSlot<K>>> = crate::remember_with_key(callsite, || {
451        RefCell::new(ScopedSlot {
452            key: None,
453            alive: Rc::new(RefCell::new(true)),
454            installed: false,
455        })
456    });
457    let install = {
458        let mut slot = cell.borrow_mut();
459        if !slot.installed {
460            slot.installed = true;
461            true
462        } else {
463            false
464        }
465    };
466    if install {
467        let cell_c = cell.clone();
468        crate::scoped_effect(move || {
469            crate::on_unmount(move || {
470                *cell_c.borrow().alive.borrow_mut() = false;
471            })
472        });
473    }
474    let alive = Rc::new(RefCell::new(true));
475    let old_values = {
476        let mut slot = cell.borrow_mut();
477        if slot.key.as_ref() != Some(&key) {
478            *slot.alive.borrow_mut() = false;
479            let old_alive = std::mem::replace(&mut slot.alive, alive.clone());
480            let old_key = slot.key.replace(key.clone());
481            Some((old_alive, old_key))
482        } else {
483            None
484        }
485    };
486    if let Some((old_alive, old_key)) = old_values {
487        drop(old_alive);
488        drop(old_key);
489        delay(duration, move || {
490            if *alive.borrow() {
491                cb();
492            }
493        })
494        .detach();
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use std::time::Duration as StdDuration;
502
503    fn sleep_ms(ms: u64) {
504        std::thread::sleep(StdDuration::from_millis(ms));
505    }
506
507    /// Clear the thread-local registry so reused test threads don't leak state.
508    fn reset() {
509        let entries = REGISTRY.with(|r| std::mem::take(&mut *r.borrow_mut()));
510        drop(entries);
511    }
512
513    #[test]
514    fn delay_fires_after_duration() {
515        reset();
516        let fired = Rc::new(RefCell::new(false));
517        let fired_c = fired.clone();
518        let _h = delay(Duration::from_millis(5), move || {
519            *fired_c.borrow_mut() = true;
520        });
521        poll();
522        assert!(!*fired.borrow(), "must not fire before the deadline");
523        sleep_ms(30);
524        poll();
525        assert!(*fired.borrow(), "must fire once the deadline passes");
526        sleep_ms(30);
527        poll();
528        assert!(next_deadline().is_none(), "one-shot must not reschedule");
529    }
530
531    #[test]
532    fn drop_cancels_delay() {
533        reset();
534        let fired = Rc::new(RefCell::new(false));
535        let fired_c = fired.clone();
536        let h = delay(Duration::from_millis(5), move || {
537            *fired_c.borrow_mut() = true;
538        });
539        drop(h);
540        sleep_ms(30);
541        poll();
542        assert!(!*fired.borrow(), "cancelled timer must not fire");
543    }
544
545    #[test]
546    fn interval_repeats_and_drop_stops() {
547        reset();
548        let count = Rc::new(RefCell::new(0u32));
549        let count_c = count.clone();
550        let h = interval(Duration::from_millis(5), move || {
551            *count_c.borrow_mut() += 1;
552        });
553        sleep_ms(30);
554        poll();
555        assert!(
556            *count.borrow() >= 1,
557            "must fire at least once, got {}",
558            *count.borrow()
559        );
560        let after_first = *count.borrow();
561        drop(h);
562        sleep_ms(30);
563        poll();
564        assert_eq!(*count.borrow(), after_first, "dropped interval must stop");
565    }
566
567    #[test]
568    fn interval_n_fires_exactly_n_times() {
569        reset();
570        let count = Rc::new(RefCell::new(0u32));
571        let count_c = count.clone();
572        let _h = interval_n(Duration::from_millis(5), 3, move || {
573            *count_c.borrow_mut() += 1;
574        });
575        for _ in 0..10 {
576            sleep_ms(15);
577            poll();
578        }
579        assert_eq!(*count.borrow(), 3);
580        assert!(next_deadline().is_none());
581    }
582
583    #[test]
584    fn delay_frames_counts_polls() {
585        reset();
586        let fired = Rc::new(RefCell::new(false));
587        let fired_c = fired.clone();
588        let start = frame_count();
589        let _h = delay_frames(3, move || {
590            *fired_c.borrow_mut() = true;
591        });
592        poll();
593        poll();
594        assert!(!*fired.borrow(), "must not fire before 3 polls");
595        poll();
596        assert!(*fired.borrow(), "must fire on the 3rd poll");
597        assert_eq!(frame_count(), start + 3);
598    }
599
600    #[test]
601    fn debouncer_coalesces_rapid_calls() {
602        reset();
603        let count = Rc::new(RefCell::new(0u32));
604        let deb = Debouncer::new(Duration::from_millis(10));
605        for _ in 0..5 {
606            let count_c = count.clone();
607            deb.call(move || {
608                *count_c.borrow_mut() += 1;
609            });
610        }
611        sleep_ms(40);
612        poll();
613        assert_eq!(
614            *count.borrow(),
615            1,
616            "rapid calls must coalesce into one firing"
617        );
618    }
619
620    #[test]
621    fn throttler_leads_and_trails_once() {
622        reset();
623        let count = Rc::new(RefCell::new(0u32));
624        let thro = Throttler::new(Duration::from_millis(50));
625        for _ in 0..5 {
626            let count_c = count.clone();
627            thro.call(move || {
628                *count_c.borrow_mut() += 1;
629            });
630        }
631        assert_eq!(*count.borrow(), 1, "first call fires immediately");
632        sleep_ms(80);
633        poll();
634        assert_eq!(
635            *count.borrow(),
636            2,
637            "the rest collapse into one trailing firing"
638        );
639    }
640
641    #[test]
642    fn same_batch_cancel_suppresses() {
643        // Due-buffer order follows HashMap iteration (nondeterministic), so
644        // retry until the canceller runs first; only then can suppression be
645        // observed. 32 misses in a row is effectively impossible.
646        for _ in 0..32 {
647            reset();
648            let events: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
649            let slot: Rc<RefCell<Option<TimerHandle>>> = Rc::new(RefCell::new(None));
650            let ev_c = events.clone();
651            let slot_c = slot.clone();
652            let _first = delay(Duration::from_millis(1), move || {
653                ev_c.borrow_mut().push("cancel");
654                *slot_c.borrow_mut() = None;
655            });
656            let ev_c = events.clone();
657            *slot.borrow_mut() = Some(delay(Duration::from_millis(1), move || {
658                ev_c.borrow_mut().push("second");
659            }));
660            sleep_ms(20);
661            poll();
662            let ev = events.borrow();
663            if *ev == ["cancel"] {
664                return;
665            }
666            assert_eq!(
667                *ev,
668                ["second", "cancel"],
669                "unexpected event sequence: {ev:?}"
670            );
671        }
672        panic!("canceller never ran first in 32 trials");
673    }
674
675    #[test]
676    fn reentrant_poll_is_ignored() {
677        reset();
678        let count = Rc::new(RefCell::new(0u32));
679        let count_c = count.clone();
680        let _h = delay(Duration::from_millis(1), move || {
681            *count_c.borrow_mut() += 1;
682            poll();
683        });
684        sleep_ms(20);
685        poll();
686        poll();
687        assert_eq!(*count.borrow(), 1, "nested poll must not refire");
688
689        reset();
690        let ticks = Rc::new(RefCell::new(0u32));
691        let ticks_c = ticks.clone();
692        let _i = interval(Duration::from_millis(5), move || {
693            *ticks_c.borrow_mut() += 1;
694            poll();
695        });
696        sleep_ms(30);
697        poll();
698        assert_eq!(
699            *ticks.borrow(),
700            1,
701            "nested poll must not double-fire intervals"
702        );
703    }
704
705    #[test]
706    fn interval_holds_phase_when_poll_late() {
707        reset();
708        let stamps = Rc::new(RefCell::new(Vec::new()));
709        let stamps_c = stamps.clone();
710        let period = Duration::from_millis(20);
711        let _h = interval(period, move || {
712            stamps_c.borrow_mut().push(Instant::now());
713        });
714        // Sleep through ~3 periods, then poll once: one firing at most, and
715        // the next deadline re-bases on the old schedule instead of drifting.
716        sleep_ms(70);
717        poll();
718        assert_eq!(stamps.borrow().len(), 1, "one poll fires once at most");
719        let first = stamps.borrow()[0];
720        let next = next_deadline().expect("interval must reschedule");
721        let gap = next.saturating_duration_since(first);
722        assert!(
723            gap < period + Duration::from_millis(15),
724            "reschedule must not drift by the full lateness, gap was {gap:?}"
725        );
726    }
727}