Skip to main content

cranpose_core/
concurrency.rs

1//! Composition-scoped structured concurrency.
2//!
3//! `LaunchedEffect` covers work that starts because a key changed. This covers
4//! the rest: work started from an event handler, timed work, work that feeds a
5//! piece of state, and blocking work that must not run on the UI thread.
6//! Everything here is owned by the composition — a scope cancels its tasks when
7//! it leaves, and a timer stops when nothing is waiting on it — so an
8//! application never keeps its own task list or its own "is this still alive"
9//! flag.
10
11#[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
12use std::sync::{Condvar, Mutex, PoisonError};
13use std::{
14    any::Any,
15    cell::{Cell, RefCell},
16    future::Future,
17    pin::Pin,
18    rc::Rc,
19    sync::{
20        Arc, OnceLock,
21        atomic::{AtomicBool, Ordering},
22    },
23    task::{Context, Poll, Waker},
24    time::Duration,
25};
26
27#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
28use wasm_bindgen::JsCast;
29use web_time::Instant;
30
31use crate::{
32    hooks::{mutableStateOfNeverEqual, remember},
33    runtime::{RuntimeHandle, TaskHandle, current_runtime_handle},
34    state::{MutableState, State},
35    task_site::TaskLabel,
36};
37
38/// Spawns `future` on the current runtime's UI task queue.
39///
40/// Framework-internal: application code launches through a
41/// [`CoroutineScope`] so the work is cancelled with its
42/// composition. Returns `None` when there is no runtime on this thread.
43pub fn spawn_ui_task(future: impl Future<Output = ()> + 'static) -> Option<TaskHandle> {
44    current_runtime_handle().and_then(|runtime| runtime.spawn_ui(future))
45}
46
47/// A cancellation scope for work launched outside the composition pass.
48///
49/// Tasks launched through a scope are cancelled when the scope leaves the
50/// composition, so a click handler can start an asynchronous job without the
51/// job outliving the screen that started it.
52#[derive(Clone)]
53pub struct CoroutineScope {
54    inner: Rc<ScopeInner>,
55}
56
57struct ScopeInner {
58    runtime: Option<RuntimeHandle>,
59    tasks: RefCell<Vec<TaskHandle>>,
60    closed: Cell<bool>,
61}
62
63impl Drop for ScopeInner {
64    fn drop(&mut self) {
65        for task in self.tasks.get_mut().drain(..) {
66            task.cancel();
67        }
68    }
69}
70
71struct CompositionScopeOwner(CoroutineScope);
72
73impl Drop for CompositionScopeOwner {
74    fn drop(&mut self) {
75        self.0.close();
76    }
77}
78
79impl CoroutineScope {
80    pub(crate) fn new(runtime: Option<RuntimeHandle>) -> Self {
81        Self {
82            inner: Rc::new(ScopeInner {
83                runtime,
84                tasks: RefCell::new(Vec::new()),
85                closed: Cell::new(false),
86            }),
87        }
88    }
89
90    /// Launches `future`, keeping it alive until it finishes or the scope is
91    /// cancelled.
92    pub fn launch(&self, future: impl Future<Output = ()> + 'static) {
93        self.launch_labelled(Box::pin(future), TaskLabel::Unnamed);
94    }
95
96    pub(crate) fn launch_labelled(
97        &self,
98        future: Pin<Box<dyn Future<Output = ()>>>,
99        label: TaskLabel,
100    ) {
101        if self.inner.closed.get() {
102            return;
103        }
104        let Some(runtime) = self.inner.runtime.as_ref() else {
105            log::warn!("cranpose: a coroutine scope with no runtime dropped its work");
106            return;
107        };
108        self.inner
109            .tasks
110            .borrow_mut()
111            .retain(|task| !task.is_finished());
112        if let Some(handle) = runtime.spawn_labelled(future, label) {
113            self.inner.tasks.borrow_mut().push(handle);
114        }
115    }
116
117    // Cancels every task and refuses new ones.
118    pub(crate) fn close(&self) {
119        self.inner.closed.set(true);
120        self.cancel();
121    }
122
123    /// Cancels every task this scope launched.
124    pub fn cancel(&self) {
125        let tasks = std::mem::take(&mut *self.inner.tasks.borrow_mut());
126        for task in tasks {
127            task.cancel();
128        }
129    }
130
131    #[cfg(test)]
132    pub(crate) fn probe_identity(&self) -> usize {
133        Rc::as_ptr(&self.inner) as *const () as usize
134    }
135}
136
137/// Remembers a [`CoroutineScope`] bound to this position in the composition.
138#[expect(non_snake_case, reason = "names follow Compose's casing")]
139#[track_caller]
140pub fn rememberCoroutineScope() -> CoroutineScope {
141    remember(|| CompositionScopeOwner(CoroutineScope::new(current_runtime_handle())))
142        .with(|owner| owner.0.clone())
143}
144
145/// Resolves after `duration` has elapsed.
146///
147/// The wait is served by the framework's timer, which posts the wake-up onto
148/// the runtime's UI queue. Nothing spins and no frames are requested while a
149/// delay is pending, so a one-minute timer costs nothing for a minute.
150pub fn delay(duration: Duration) -> Delay {
151    Delay {
152        deadline: Instant::now() + duration,
153        armed: false,
154        fired: Arc::new(AtomicBool::new(false)),
155    }
156}
157
158/// The future returned by [`delay`].
159pub struct Delay {
160    deadline: Instant,
161    armed: bool,
162    fired: Arc<AtomicBool>,
163}
164
165impl Future for Delay {
166    type Output = ();
167
168    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> {
169        if self.fired.load(Ordering::Acquire) || Instant::now() >= self.deadline {
170            return Poll::Ready(());
171        }
172        let this = self.get_mut();
173        if !this.armed {
174            this.armed = true;
175            arm(
176                this.deadline,
177                context.waker().clone(),
178                Arc::clone(&this.fired),
179            );
180        }
181        Poll::Pending
182    }
183}
184
185/// Runs `tick` every `period` until the returned future is dropped.
186///
187/// The first tick happens after one full period, matching a repeating timer
188/// rather than a leading-edge one.
189pub async fn interval(period: Duration, mut tick: impl FnMut()) {
190    loop {
191        delay(period).await;
192        tick();
193    }
194}
195
196#[cfg(not(any(
197    all(target_arch = "wasm32", target_os = "unknown"),
198    target_vendor = "apple"
199)))]
200pub(crate) struct Alarm {
201    deadline: Instant,
202    waker: Waker,
203    fired: Arc<AtomicBool>,
204}
205
206/// Arms the alarm of a [`Delay`]. On ESP-IDF, a delay on a thread that runs
207/// a composition waits in that thread's runtime, as under Emscripten: its
208/// host fires it as it drains the UI work and wakes for it through
209/// [`next_alarm_deadline`], so no timer thread takes the 9 KB of RAM its
210/// stack and task need. A delay on any other thread waits on the timer
211/// thread.
212fn arm(deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
213    #[cfg(target_os = "espidf")]
214    if current_runtime_handle().is_some() {
215        arm_host_alarm(Alarm {
216            deadline,
217            waker,
218            fired,
219        });
220        return;
221    }
222    timer().arm(deadline, waker, fired);
223}
224
225struct Timer {
226    #[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
227    alarms: Mutex<Vec<Alarm>>,
228    #[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
229    wake: Condvar,
230}
231
232fn timer() -> &'static Timer {
233    static TIMER: OnceLock<&'static Timer> = OnceLock::new();
234    TIMER.get_or_init(|| {
235        let timer: &'static Timer = Box::leak(Box::new(Timer::new()));
236        timer.start();
237        timer
238    })
239}
240
241#[cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))]
242impl Timer {
243    fn new() -> Self {
244        Self {
245            alarms: Mutex::new(Vec::new()),
246            wake: Condvar::new(),
247        }
248    }
249
250    fn start(&'static self) {
251        std::thread::Builder::new()
252            .name("cranpose-timer".to_string())
253            .spawn(move || self.run())
254            .expect("the timer thread starts");
255    }
256
257    fn run(&self) {
258        let mut alarms = self.alarms.lock().unwrap_or_else(PoisonError::into_inner);
259        loop {
260            let now = Instant::now();
261            let mut due = Vec::new();
262            let mut next: Option<Duration> = None;
263            alarms.retain(|alarm| {
264                if alarm.deadline <= now {
265                    due.push((alarm.waker.clone(), Arc::clone(&alarm.fired)));
266                    false
267                } else {
268                    let remaining = alarm.deadline - now;
269                    next = Some(next.map_or(remaining, |current| current.min(remaining)));
270                    true
271                }
272            });
273
274            if !due.is_empty() {
275                drop(alarms);
276                for (waker, fired) in due {
277                    fired.store(true, Ordering::Release);
278                    waker.wake();
279                }
280                alarms = self.alarms.lock().unwrap_or_else(PoisonError::into_inner);
281                continue;
282            }
283
284            alarms = match next {
285                Some(timeout) => {
286                    self.wake
287                        .wait_timeout(alarms, timeout)
288                        .unwrap_or_else(PoisonError::into_inner)
289                        .0
290                }
291                None => self
292                    .wake
293                    .wait(alarms)
294                    .unwrap_or_else(PoisonError::into_inner),
295            };
296        }
297    }
298
299    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
300        let mut alarms = self.alarms.lock().unwrap_or_else(PoisonError::into_inner);
301        alarms.push(Alarm {
302            deadline,
303            waker,
304            fired,
305        });
306        self.wake.notify_one();
307    }
308}
309
310/// Apple platforms hand each deadline to Grand Central Dispatch on the
311/// user-interactive queue. A thread sleeping on a condition variable at the
312/// default quality of service wakes about 8 ms late there, as macOS coalesces
313/// its timers, so a one-frame `delay` often missed its frame; GCD at
314/// user-interactive quality of service wakes within a millisecond.
315#[cfg(target_vendor = "apple")]
316impl Timer {
317    fn new() -> Self {
318        Self {}
319    }
320
321    fn start(&'static self) {}
322
323    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
324        use dispatch2::{DispatchQoS, DispatchQueue, DispatchTime, GlobalQueueIdentifier};
325
326        let nanos = deadline
327            .saturating_duration_since(Instant::now())
328            .as_nanos()
329            .min(i64::MAX as u128) as i64;
330        let queue = DispatchQueue::global_queue(GlobalQueueIdentifier::QualityOfService(
331            DispatchQoS::UserInteractive,
332        ));
333        let fire = move || {
334            fired.store(true, Ordering::Release);
335            waker.wake();
336        };
337        if queue.after(DispatchTime::NOW.time(nanos), fire).is_err() {
338            log::error!("cranpose: GCD refused a timer; the delay never resolves");
339        }
340    }
341}
342
343#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
344impl Timer {
345    fn new() -> Self {
346        Self {}
347    }
348
349    fn start(&'static self) {}
350
351    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
352        let millis = deadline
353            .saturating_duration_since(Instant::now())
354            .as_millis()
355            .min(i32::MAX as u128) as i32;
356        let callback = wasm_bindgen::closure::Closure::once_into_js(move || {
357            fired.store(true, Ordering::Release);
358            waker.wake();
359        });
360        let scheduled = web_sys::window().and_then(|window| {
361            window
362                .set_timeout_with_callback_and_timeout_and_arguments_0(
363                    callback.unchecked_ref(),
364                    millis,
365                )
366                .ok()
367        });
368        if scheduled.is_none() {
369            log::warn!("cranpose: no window timer is available; the delay resolves immediately");
370        }
371    }
372}
373
374// Under Emscripten there is no browser timer to call back and no thread to
375// wait, so alarms wait in the thread's runtime state, which the runtime
376// checks each time it drains its UI work. The host that embeds Cranpose,
377// such as a game engine, drives those drains with its frames.
378#[cfg(target_os = "emscripten")]
379impl Timer {
380    fn new() -> Self {
381        Self {}
382    }
383
384    fn start(&'static self) {}
385
386    fn arm(&self, deadline: Instant, waker: Waker, fired: Arc<AtomicBool>) {
387        arm_host_alarm(Alarm {
388            deadline,
389            waker,
390            fired,
391        });
392    }
393}
394
395/// Puts `alarm` in the thread's runtime state, for the runtime to fire as it
396/// drains its UI work.
397#[cfg(any(target_os = "emscripten", target_os = "espidf"))]
398fn arm_host_alarm(alarm: Alarm) {
399    crate::thread_state::runtimes(|state| state.alarms.borrow_mut().push(alarm));
400}
401
402/// When the earliest delay waiting in this thread's runtime is due, for a
403/// host that runs its own loop to wake then and drain the UI work. `None`
404/// when none waits, and on platforms whose delays wait on a timer of their
405/// own.
406pub fn next_alarm_deadline() -> Option<Instant> {
407    #[cfg(any(target_os = "emscripten", target_os = "espidf"))]
408    return crate::thread_state::try_runtimes(|state| {
409        state
410            .alarms
411            .borrow()
412            .iter()
413            .map(|alarm| alarm.deadline)
414            .min()
415    })
416    .flatten();
417    #[cfg(not(any(target_os = "emscripten", target_os = "espidf")))]
418    None
419}
420
421/// Whether a delay waiting in this thread's runtime is due, which makes UI
422/// work pending.
423#[cfg(any(target_os = "emscripten", target_os = "espidf"))]
424pub(crate) fn has_due_alarm() -> bool {
425    next_alarm_deadline().is_some_and(|deadline| deadline <= Instant::now())
426}
427
428/// Wakes the delays whose deadline passed.
429#[cfg(any(target_os = "emscripten", target_os = "espidf"))]
430pub(crate) fn fire_due_alarms() {
431    let now = Instant::now();
432    let due: Vec<Alarm> = crate::thread_state::runtimes(|state| {
433        let mut alarms = state.alarms.borrow_mut();
434        if alarms.iter().all(|alarm| alarm.deadline > now) {
435            return Vec::new();
436        }
437        let (due, waiting) = std::mem::take(&mut *alarms)
438            .into_iter()
439            .partition(|alarm| alarm.deadline <= now);
440        *alarms = waiting;
441        due
442    });
443    for alarm in due {
444        alarm.fired.store(true, Ordering::Release);
445        alarm.waker.wake();
446    }
447}
448
449/// The producing half of an [`EventStream`].
450///
451/// A service that receives events from outside the composition — a platform
452/// callback, a worker thread, a socket — publishes through a channel, and every
453/// pending collector is woken. This is the shape that replaces
454/// "register an observer, then drain a queue" everywhere in the framework.
455pub struct EventChannel<T: 'static> {
456    shared: Rc<ChannelShared<T>>,
457}
458
459struct ChannelShared<T: 'static> {
460    ready: RefCell<std::collections::VecDeque<T>>,
461    closed: Cell<bool>,
462    delivered: Cell<usize>,
463    wakers: RefCell<Vec<Waker>>,
464}
465
466impl<T: 'static> ChannelShared<T> {
467    fn wake_all(&self) {
468        for waker in self.wakers.borrow_mut().drain(..) {
469            waker.wake();
470        }
471    }
472}
473
474impl<T: 'static> Default for EventChannel<T> {
475    fn default() -> Self {
476        Self::new()
477    }
478}
479
480impl<T: 'static> EventChannel<T> {
481    /// Creates an open channel.
482    pub fn new() -> Self {
483        Self {
484            shared: Rc::new(ChannelShared {
485                ready: RefCell::new(std::collections::VecDeque::new()),
486                closed: Cell::new(false),
487                delivered: Cell::new(0),
488                wakers: RefCell::new(Vec::new()),
489            }),
490        }
491    }
492
493    /// The consuming half, handed to collectors.
494    pub fn stream(&self) -> EventStream<T> {
495        EventStream {
496            shared: Rc::clone(&self.shared),
497        }
498    }
499
500    /// Publishes one event and wakes every pending collector.
501    pub fn send(&self, event: T) {
502        if self.shared.closed.get() {
503            return;
504        }
505        self.shared.ready.borrow_mut().push_back(event);
506        self.shared.wake_all();
507    }
508
509    /// Ends the stream. Collectors drain what is queued and then finish.
510    pub fn close(&self) {
511        if self.shared.closed.get() {
512            return;
513        }
514        self.shared.closed.set(true);
515        self.shared.wake_all();
516    }
517
518    /// Whether the channel has been closed.
519    pub fn is_closed(&self) -> bool {
520        self.shared.closed.get()
521    }
522
523    /// How many events are queued but not yet taken.
524    pub fn pending(&self) -> usize {
525        self.shared.ready.borrow().len()
526    }
527}
528
529/// The consuming half of an [`EventChannel`].
530///
531/// Collectors take events one at a time; an event goes to exactly one
532/// collector, so two collectors share the stream rather than each seeing every
533/// event.
534pub struct EventStream<T: 'static> {
535    shared: Rc<ChannelShared<T>>,
536}
537
538impl<T: 'static> Clone for EventStream<T> {
539    fn clone(&self) -> Self {
540        Self {
541            shared: Rc::clone(&self.shared),
542        }
543    }
544}
545
546impl<T: 'static> EventStream<T> {
547    /// Resolves with the next event, or `None` once the stream is closed and
548    /// drained.
549    pub fn next(&self) -> EventStreamNext<T> {
550        EventStreamNext {
551            shared: Rc::clone(&self.shared),
552        }
553    }
554
555    /// How many events this stream has handed out.
556    pub fn delivered(&self) -> usize {
557        self.shared.delivered.get()
558    }
559}
560
561/// The future returned by [`EventStream::next`].
562pub struct EventStreamNext<T: 'static> {
563    shared: Rc<ChannelShared<T>>,
564}
565
566impl<T: 'static> Future for EventStreamNext<T> {
567    type Output = Option<T>;
568
569    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<T>> {
570        if let Some(event) = self.shared.ready.borrow_mut().pop_front() {
571            self.shared.delivered.set(self.shared.delivered.get() + 1);
572            return Poll::Ready(Some(event));
573        }
574        if self.shared.closed.get() {
575            return Poll::Ready(None);
576        }
577        self.shared
578            .wakers
579            .borrow_mut()
580            .push(context.waker().clone());
581        Poll::Pending
582    }
583}
584
585/// Collects `stream` for as long as this call stays in the composition,
586/// handing each event to `on_event`.
587///
588/// `key` re-starts the collection when it changes, exactly like
589/// `LaunchedEffect`.
590#[expect(non_snake_case, reason = "names follow Compose's casing")]
591#[track_caller]
592pub fn CollectEvents<T, K>(stream: EventStream<T>, key: K, on_event: impl FnMut(T) + 'static)
593where
594    T: 'static,
595    K: PartialEq + 'static,
596{
597    crate::__launched_effect_impl(
598        crate::caller_location_key(),
599        std::panic::Location::caller().into(),
600        key,
601        async move |_| {
602            let mut on_event = on_event;
603            while let Some(event) = stream.next().await {
604                on_event(event);
605            }
606        },
607    );
608}
609
610/// Collects `stream` into state, starting at `initial`.
611///
612/// The composition reads the latest value the stream produced, and recomposes
613/// when a new one arrives.
614#[expect(non_snake_case, reason = "names follow Compose's casing")]
615#[track_caller]
616pub fn collectAsState<T, K>(stream: EventStream<T>, key: K, initial: T) -> State<T>
617where
618    T: Clone + 'static,
619    K: PartialEq + 'static,
620{
621    let state = remember(|| mutableStateOfNeverEqual(initial)).with(|state| *state);
622    let sink = state;
623    CollectEvents(stream, key, move |event| sink.set(event));
624    state.as_state()
625}
626
627/// A `Send` publishing handle for a composition-scoped [`EventStream`].
628///
629/// Platform services publish events from whatever thread they run on — a JNI
630/// callback, a worker, a socket reader. The sender hops each event onto the UI
631/// thread through the runtime's dispatcher and pushes it into the stream the
632/// composition is collecting, so no service and no application ever writes that
633/// hop again.
634pub struct EventSender<T: Send + 'static> {
635    #[cfg(not(target_arch = "wasm32"))]
636    dispatcher: crate::runtime::UiDispatcher,
637    bridge: u64,
638    _events: std::marker::PhantomData<fn(T)>,
639}
640
641impl<T: Send + 'static> Clone for EventSender<T> {
642    fn clone(&self) -> Self {
643        Self {
644            #[cfg(not(target_arch = "wasm32"))]
645            dispatcher: self.dispatcher.clone(),
646            bridge: self.bridge,
647            _events: std::marker::PhantomData,
648        }
649    }
650}
651
652impl<T: Send + 'static> EventSender<T> {
653    /// Publishes `event` to the composition that owns this bridge.
654    pub fn send(&self, event: T) {
655        let bridge = self.bridge;
656        #[cfg(not(target_arch = "wasm32"))]
657        self.dispatcher
658            .post(move || deliver_bridged::<T>(bridge, event));
659        #[cfg(target_arch = "wasm32")]
660        deliver_bridged::<T>(bridge, event);
661    }
662}
663
664fn bridge_channel(bridge: u64) -> Option<Rc<dyn Any>> {
665    crate::thread_state::runtimes(|state| state.bridges.borrow().get(&bridge).cloned())
666}
667
668static NEXT_BRIDGE: crate::ProcessIds = crate::ProcessIds::starting_at(1);
669
670fn deliver_bridged<T: Send + 'static>(bridge: u64, event: T) {
671    let Some(channel) = bridge_channel(bridge) else {
672        log::debug!("event bridge {bridge} is gone, one event dropped");
673        return;
674    };
675    if let Ok(channel) = channel.downcast::<EventChannel<T>>() {
676        channel.send(event);
677    }
678}
679
680struct Bridge<T: Send + 'static> {
681    id: u64,
682    channel: Rc<EventChannel<T>>,
683}
684
685impl<T: Send + 'static> Bridge<T> {
686    fn new() -> Self {
687        let id = NEXT_BRIDGE.next();
688        let channel = Rc::new(EventChannel::<T>::new());
689        crate::thread_state::runtimes(|state| {
690            state
691                .bridges
692                .borrow_mut()
693                .insert(id, Rc::clone(&channel) as Rc<dyn Any>)
694        });
695        Self { id, channel }
696    }
697}
698
699impl<T: Send + 'static> Drop for Bridge<T> {
700    fn drop(&mut self) {
701        crate::thread_state::try_runtimes(|state| state.bridges.borrow_mut().remove(&self.id));
702        self.channel.close();
703    }
704}
705
706/// Turns a platform subscription into a composition-scoped [`EventStream`].
707///
708/// `subscribe` receives a `Send` [`EventSender`] and returns whatever
709/// registration handle the service uses; that handle is dropped — unsubscribing
710/// the service — when `key` changes or the composition leaves. This is the one
711/// place the framework bridges "a service publishes from another thread" to
712/// "a composition collects".
713#[expect(non_snake_case, reason = "names follow Compose's casing")]
714#[track_caller]
715pub fn rememberEventStream<T, K, R, S>(key: K, subscribe: S) -> EventStream<T>
716where
717    T: Send + 'static,
718    K: PartialEq + 'static,
719    R: 'static,
720    S: FnOnce(EventSender<T>) -> R + 'static,
721{
722    let bridge = remember(Bridge::<T>::new);
723    let (id, stream) = bridge.with(|bridge| (bridge.id, bridge.channel.stream()));
724    #[cfg(not(target_arch = "wasm32"))]
725    let dispatcher = current_runtime_handle().map(|runtime| runtime.dispatcher());
726
727    crate::__disposable_effect_impl(crate::caller_location_key(), key, move |scope| {
728        #[cfg(not(target_arch = "wasm32"))]
729        let Some(dispatcher) = dispatcher else {
730            log::warn!("cranpose: an event stream was remembered without a runtime");
731            return scope.on_dispose(|| {});
732        };
733        let registration = subscribe(EventSender {
734            #[cfg(not(target_arch = "wasm32"))]
735            dispatcher,
736            bridge: id,
737            _events: std::marker::PhantomData,
738        });
739        scope.on_dispose(move || drop(registration))
740    });
741
742    stream
743}
744
745/// Runs `producer` when `key` changes and exposes what it publishes as state.
746///
747/// The Compose `produceState` contract: the producer receives a handle it uses
748/// to publish values, and is cancelled when the key changes or the composition
749/// leaves.
750#[expect(non_snake_case, reason = "names follow Compose's casing")]
751#[track_caller]
752pub fn produceState<T, K, F, Fut>(initial: T, key: K, producer: F) -> State<T>
753where
754    T: Clone + 'static,
755    K: PartialEq + 'static,
756    F: FnOnce(ProduceScope<T>) -> Fut + 'static,
757    Fut: Future<Output = ()> + 'static,
758{
759    let state = remember(|| mutableStateOfNeverEqual(initial)).with(|state| *state);
760    let handle = ProduceScope { state };
761    crate::__launched_effect_impl(
762        crate::caller_location_key(),
763        std::panic::Location::caller().into(),
764        key,
765        move |_scope| producer(handle),
766    );
767    state.as_state()
768}
769
770/// The publishing half handed to a [`produceState`] producer.
771pub struct ProduceScope<T: Clone + 'static> {
772    state: MutableState<T>,
773}
774
775impl<T: Clone + 'static> ProduceScope<T> {
776    /// Publishes `value` to the produced state.
777    pub fn set(&self, value: T) {
778        self.state.set(value);
779    }
780}
781
782#[cfg(test)]
783#[path = "tests/concurrency_tests.rs"]
784mod tests;
785
786#[cfg(test)]
787#[path = "tests/concurrency_stream_tests.rs"]
788mod stream_tests;
789
790#[cfg(test)]
791#[path = "tests/concurrency_timer_race_tests.rs"]
792mod timer_race_tests;