Skip to main content

tui_dispatch_core/runtime/
core.rs

1//! Core runtime helpers for tui-dispatch apps.
2//!
3//! These helpers wrap the common event/action/render loop while keeping
4//! the same behavior as the manual wiring shown in the examples.
5
6use std::io;
7use std::marker::PhantomData;
8use std::time::Duration;
9
10use ratatui::backend::Backend;
11use ratatui::layout::Rect;
12use ratatui::{Frame, Terminal};
13use tokio::sync::mpsc;
14use tokio_util::sync::CancellationToken;
15
16use crate::bus::{process_raw_event, spawn_event_poller, EventOutcome, RawEvent};
17use crate::event::EventKind;
18use crate::store::{
19    DispatchError, Middleware, NoEffect, Reducer, ReducerResult, Store, StoreWithMiddleware,
20};
21use crate::Action;
22
23#[cfg(feature = "subscriptions")]
24use crate::subscriptions::Subscriptions;
25#[cfg(feature = "tasks")]
26use crate::tasks::TaskManager;
27
28/// Configuration for the event poller.
29#[derive(Debug, Clone, Copy)]
30pub struct PollerConfig {
31    /// Timeout passed to each `crossterm::event::poll` call.
32    pub poll_timeout: Duration,
33    /// Sleep between poll cycles.
34    pub loop_sleep: Duration,
35}
36
37impl Default for PollerConfig {
38    fn default() -> Self {
39        Self {
40            poll_timeout: Duration::from_millis(10),
41            loop_sleep: Duration::from_millis(16),
42        }
43    }
44}
45
46/// Context passed to render closures.
47#[derive(Debug, Clone, Copy, Default)]
48pub struct RenderContext {
49    /// Whether the debug overlay is currently active.
50    pub debug_enabled: bool,
51}
52
53impl RenderContext {
54    /// Whether the app should treat input focus as active.
55    pub fn is_focused(self) -> bool {
56        !self.debug_enabled
57    }
58}
59
60/// Policy applied by runtimes when `try_dispatch` returns a [`DispatchError`].
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum DispatchErrorPolicy {
63    /// Keep running without forcing a render.
64    Continue,
65    /// Keep running and force a render pass.
66    ///
67    /// The runtime does not persist or log the error automatically;
68    /// capture visibility in your error handler closure if needed.
69    Render,
70    /// Stop the runtime loop gracefully.
71    Stop,
72}
73
74fn apply_dispatch_error_policy(
75    handler: &mut dyn FnMut(&DispatchError) -> DispatchErrorPolicy,
76    error: DispatchError,
77    should_render: &mut bool,
78) -> bool {
79    match handler(&error) {
80        DispatchErrorPolicy::Continue => false,
81        DispatchErrorPolicy::Render => {
82            *should_render = true;
83            false
84        }
85        DispatchErrorPolicy::Stop => true,
86    }
87}
88
89#[cfg(feature = "debug")]
90pub trait DebugAdapter<S, A>: 'static {
91    fn render(
92        &mut self,
93        frame: &mut Frame,
94        state: &S,
95        render_ctx: RenderContext,
96        render_fn: &mut dyn FnMut(&mut Frame, Rect, &S, RenderContext),
97    );
98
99    fn handle_event(
100        &mut self,
101        event: &EventKind,
102        state: &S,
103        action_tx: &mpsc::UnboundedSender<A>,
104    ) -> Option<bool>;
105
106    fn log_action(&mut self, action: &A);
107    fn is_enabled(&self) -> bool;
108
109    #[cfg(feature = "tasks")]
110    fn with_task_manager(self, _tasks: &TaskManager<A>) -> Self
111    where
112        Self: Sized,
113    {
114        self
115    }
116
117    #[cfg(feature = "subscriptions")]
118    fn with_subscriptions(self, _subscriptions: &Subscriptions<A>) -> Self
119    where
120        Self: Sized,
121    {
122        self
123    }
124}
125
126pub(crate) fn draw_frame<S: 'static, A, B, F>(
127    shell: &mut RuntimeShell<S, A>,
128    state: &S,
129    terminal: &mut Terminal<B>,
130    mut render: F,
131) -> io::Result<()>
132where
133    A: Action,
134    B: Backend,
135    F: FnMut(&mut Frame, Rect, &S, RenderContext),
136{
137    let render_ctx = shell.render_ctx();
138    terminal.draw(|frame| {
139        #[cfg(feature = "debug")]
140        if let Some(debug) = shell.debug.as_mut() {
141            let mut rf =
142                |f: &mut Frame, area: Rect, s: &S, ctx: RenderContext| render(f, area, s, ctx);
143            debug.render(frame, state, render_ctx, &mut rf);
144        } else {
145            render(frame, frame.area(), state, render_ctx);
146        }
147        #[cfg(not(feature = "debug"))]
148        {
149            render(frame, frame.area(), state, render_ctx);
150        }
151    })?;
152    shell.should_render = false;
153    Ok(())
154}
155
156pub(crate) struct RuntimeShell<S, A: Action> {
157    pub(crate) action_tx: mpsc::UnboundedSender<A>,
158    pub(crate) action_rx: mpsc::UnboundedReceiver<A>,
159    pub(crate) poller_config: PollerConfig,
160    #[cfg(feature = "debug")]
161    pub(crate) debug: Option<Box<dyn DebugAdapter<S, A>>>,
162    pub(crate) dispatch_error_handler: Box<dyn FnMut(&DispatchError) -> DispatchErrorPolicy>,
163    pub(crate) should_render: bool,
164    _state: PhantomData<S>,
165}
166
167impl<S: 'static, A: Action> RuntimeShell<S, A> {
168    pub(crate) fn new() -> Self {
169        let (action_tx, action_rx) = mpsc::unbounded_channel();
170        Self {
171            action_tx,
172            action_rx,
173            poller_config: PollerConfig::default(),
174            #[cfg(feature = "debug")]
175            debug: None,
176            dispatch_error_handler: Box::new(|_| DispatchErrorPolicy::Stop),
177            should_render: true,
178            _state: PhantomData,
179        }
180    }
181
182    pub(crate) fn enqueue(&self, action: A) {
183        let _ = self.action_tx.send(action);
184    }
185
186    pub(crate) fn action_tx_clone(&self) -> mpsc::UnboundedSender<A> {
187        self.action_tx.clone()
188    }
189
190    pub(crate) fn render_ctx(&self) -> RenderContext {
191        RenderContext {
192            debug_enabled: {
193                #[cfg(feature = "debug")]
194                {
195                    self.debug.as_ref().is_some_and(|d| d.is_enabled())
196                }
197                #[cfg(not(feature = "debug"))]
198                {
199                    false
200                }
201            },
202        }
203    }
204
205    #[allow(unused_variables)]
206    pub(crate) fn debug_intercept_event(&mut self, event: &EventKind, state: &S) -> Option<bool> {
207        #[cfg(feature = "debug")]
208        if let Some(debug) = self.debug.as_mut() {
209            return debug.handle_event(event, state, &self.action_tx);
210        }
211        None
212    }
213
214    #[allow(unused_variables)]
215    pub(crate) fn debug_log_action(&mut self, action: &A) {
216        #[cfg(feature = "debug")]
217        if let Some(debug) = self.debug.as_mut() {
218            debug.log_action(action);
219        }
220    }
221
222    pub(crate) fn enqueue_outcome(&mut self, outcome: EventOutcome<A>) {
223        if outcome.needs_render {
224            self.should_render = true;
225        }
226        for action in outcome.actions {
227            let _ = self.action_tx.send(action);
228        }
229    }
230
231    pub(crate) fn spawn_poller(&self) -> (mpsc::UnboundedReceiver<RawEvent>, CancellationToken) {
232        let (event_tx, event_rx) = mpsc::unbounded_channel::<RawEvent>();
233        let cancel_token = CancellationToken::new();
234        let _handle = spawn_event_poller(
235            event_tx,
236            self.poller_config.poll_timeout,
237            self.poller_config.loop_sleep,
238            cancel_token.clone(),
239        );
240        (event_rx, cancel_token)
241    }
242
243    pub(crate) fn apply_error_policy(&mut self, error: DispatchError) -> bool {
244        apply_dispatch_error_policy(
245            self.dispatch_error_handler.as_mut(),
246            error,
247            &mut self.should_render,
248        )
249    }
250
251    pub(crate) fn process_event<FMap, R>(&mut self, raw_event: RawEvent, state: &S, map: FMap)
252    where
253        FMap: FnOnce(&EventKind, &S) -> R,
254        R: Into<EventOutcome<A>>,
255    {
256        let event = process_raw_event(raw_event);
257        if let Some(needs_render) = self.debug_intercept_event(&event, state) {
258            self.should_render = needs_render;
259            return;
260        }
261        self.enqueue_outcome(map(&event, state).into());
262    }
263}
264
265/// Store interface used by [`Runtime`].
266pub trait RuntimeStore<S, A: Action, E = NoEffect> {
267    /// Dispatch an action and return state changes plus effects.
268    fn dispatch(&mut self, action: A) -> ReducerResult<E>;
269    /// Dispatch an action and return state changes plus effects.
270    ///
271    /// Default behavior wraps [`Self::dispatch`] in `Ok(...)`.
272    fn try_dispatch(&mut self, action: A) -> Result<ReducerResult<E>, DispatchError> {
273        Ok(self.dispatch(action))
274    }
275    /// Get the current state.
276    fn state(&self) -> &S;
277}
278
279impl<S, A: Action, E> RuntimeStore<S, A, E> for Store<S, A, E> {
280    fn dispatch(&mut self, action: A) -> ReducerResult<E> {
281        Store::dispatch(self, action)
282    }
283
284    fn state(&self) -> &S {
285        Store::state(self)
286    }
287}
288
289impl<S, A: Action, E, M: Middleware<S, A>> RuntimeStore<S, A, E>
290    for StoreWithMiddleware<S, A, E, M>
291{
292    fn dispatch(&mut self, action: A) -> ReducerResult<E> {
293        StoreWithMiddleware::dispatch(self, action)
294    }
295
296    fn try_dispatch(&mut self, action: A) -> Result<ReducerResult<E>, DispatchError> {
297        StoreWithMiddleware::try_dispatch(self, action)
298    }
299
300    fn state(&self) -> &S {
301        StoreWithMiddleware::state(self)
302    }
303}
304
305/// Direct event routing mode.
306#[doc(hidden)]
307#[derive(Debug, Clone, Copy, Default)]
308pub struct Direct;
309
310/// Runtime helper for store-driven applications.
311pub struct Runtime<S, A: Action, E = NoEffect, Routing = Direct, St = Store<S, A, E>>
312where
313    St: RuntimeStore<S, A, E>,
314{
315    pub(crate) store: St,
316    pub(crate) shell: RuntimeShell<S, A>,
317    pub(crate) routing: Routing,
318    #[cfg(feature = "tasks")]
319    pub(crate) tasks: TaskManager<A>,
320    #[cfg(feature = "subscriptions")]
321    pub(crate) subscriptions: Subscriptions<A>,
322    pub(crate) action_broadcast: tokio::sync::broadcast::Sender<String>,
323    pub(crate) _effect: PhantomData<E>,
324}
325
326impl<S: 'static, A: Action, E> Runtime<S, A, E, Direct, Store<S, A, E>> {
327    /// Create a runtime from state + reducer.
328    pub fn new(state: S, reducer: Reducer<S, A, E>) -> Self {
329        Self::from_store(Store::new(state, reducer))
330    }
331}
332
333impl<S: 'static, A: Action, E, St: RuntimeStore<S, A, E>> Runtime<S, A, E, Direct, St> {
334    /// Create a runtime from an existing store.
335    pub fn from_store(store: St) -> Self {
336        Self::from_store_with_routing(store, Direct)
337    }
338}
339
340impl<S: 'static, A: Action, E, Routing, St> Runtime<S, A, E, Routing, St>
341where
342    St: RuntimeStore<S, A, E>,
343{
344    pub(crate) fn from_store_with_routing(store: St, routing: Routing) -> Self {
345        let shell = RuntimeShell::new();
346        let (action_broadcast, _) = tokio::sync::broadcast::channel(64);
347
348        #[cfg(feature = "tasks")]
349        let tasks = TaskManager::new(shell.action_tx.clone());
350        #[cfg(feature = "subscriptions")]
351        let subscriptions = Subscriptions::new(shell.action_tx.clone());
352
353        Self {
354            store,
355            shell,
356            routing,
357            #[cfg(feature = "tasks")]
358            tasks,
359            #[cfg(feature = "subscriptions")]
360            subscriptions,
361            action_broadcast,
362            _effect: PhantomData,
363        }
364    }
365
366    /// Attach a debug layer.
367    #[cfg(feature = "debug")]
368    pub fn with_debug<D>(mut self, debug: D) -> Self
369    where
370        D: DebugAdapter<S, A>,
371    {
372        let debug = {
373            let debug = debug;
374            #[cfg(feature = "tasks")]
375            let debug = debug.with_task_manager(&self.tasks);
376            #[cfg(feature = "subscriptions")]
377            let debug = debug.with_subscriptions(&self.subscriptions);
378            debug
379        };
380        self.shell.debug = Some(Box::new(debug));
381        self
382    }
383
384    /// Configure event polling behavior.
385    pub fn with_event_poller(mut self, config: PollerConfig) -> Self {
386        self.shell.poller_config = config;
387        self
388    }
389
390    /// Configure handling for recoverable dispatch errors.
391    ///
392    /// The handler receives each [`DispatchError`] and selects a
393    /// [`DispatchErrorPolicy`]. Runtimes do not log or store errors by default;
394    /// do that inside this closure when needed.
395    pub fn with_dispatch_error_handler<F>(mut self, handler: F) -> Self
396    where
397        F: FnMut(&DispatchError) -> DispatchErrorPolicy + 'static,
398    {
399        self.shell.dispatch_error_handler = Box::new(handler);
400        self
401    }
402
403    /// Subscribe to action name broadcasts.
404    pub fn subscribe_actions(&self) -> tokio::sync::broadcast::Receiver<String> {
405        self.action_broadcast.subscribe()
406    }
407
408    /// Send an action into the runtime queue.
409    pub fn enqueue(&self, action: A) {
410        self.shell.enqueue(action);
411    }
412
413    /// Clone the action sender.
414    pub fn action_tx(&self) -> mpsc::UnboundedSender<A> {
415        self.shell.action_tx_clone()
416    }
417
418    /// Access the current state.
419    pub fn state(&self) -> &S {
420        self.store.state()
421    }
422
423    /// Access the task manager.
424    #[cfg(feature = "tasks")]
425    pub fn tasks(&mut self) -> &mut TaskManager<A> {
426        &mut self.tasks
427    }
428
429    /// Access subscriptions.
430    #[cfg(feature = "subscriptions")]
431    pub fn subscriptions(&mut self) -> &mut Subscriptions<A> {
432        &mut self.subscriptions
433    }
434
435    #[cfg(all(feature = "tasks", feature = "subscriptions"))]
436    pub(crate) fn effect_context(&mut self) -> EffectContext<'_, A> {
437        EffectContext {
438            action_tx: &self.shell.action_tx,
439            tasks: &mut self.tasks,
440            subscriptions: &mut self.subscriptions,
441        }
442    }
443
444    #[cfg(all(feature = "tasks", not(feature = "subscriptions")))]
445    pub(crate) fn effect_context(&mut self) -> EffectContext<'_, A> {
446        EffectContext {
447            action_tx: &self.shell.action_tx,
448            tasks: &mut self.tasks,
449        }
450    }
451
452    #[cfg(all(not(feature = "tasks"), feature = "subscriptions"))]
453    pub(crate) fn effect_context(&mut self) -> EffectContext<'_, A> {
454        EffectContext {
455            action_tx: &self.shell.action_tx,
456            subscriptions: &mut self.subscriptions,
457        }
458    }
459
460    #[cfg(all(not(feature = "tasks"), not(feature = "subscriptions")))]
461    pub(crate) fn effect_context(&mut self) -> EffectContext<'_, A> {
462        EffectContext {
463            action_tx: &self.shell.action_tx,
464        }
465    }
466
467    fn broadcast_action(&self, action: &A) {
468        if self.action_broadcast.receiver_count() > 0 {
469            let _ = self.action_broadcast.send(action.name().to_string());
470        }
471    }
472
473    pub(crate) fn cleanup(&mut self, cancel_token: CancellationToken) {
474        cancel_token.cancel();
475        #[cfg(feature = "subscriptions")]
476        self.subscriptions.cancel_all();
477        #[cfg(feature = "tasks")]
478        self.tasks.cancel_all();
479    }
480
481    pub(crate) fn dispatch_and_handle_effects(
482        &mut self,
483        action: A,
484        handle_effect: &mut impl FnMut(E, &mut EffectContext<A>),
485    ) -> bool {
486        self.broadcast_action(&action);
487        match self.store.try_dispatch(action) {
488            Ok(result) => {
489                if result.has_effects() {
490                    let mut ctx = self.effect_context();
491                    for effect in result.effects {
492                        handle_effect(effect, &mut ctx);
493                    }
494                }
495                self.shell.should_render = result.changed;
496                false
497            }
498            Err(error) => self.shell.apply_error_policy(error),
499        }
500    }
501}
502
503impl<S: 'static, A: Action, Routing, St> Runtime<S, A, NoEffect, Routing, St>
504where
505    St: RuntimeStore<S, A, NoEffect>,
506{
507    pub(crate) fn dispatch_action(&mut self, action: A) -> bool {
508        self.broadcast_action(&action);
509        match self.store.try_dispatch(action) {
510            Ok(result) => {
511                self.shell.should_render = result.changed;
512                false
513            }
514            Err(error) => self.shell.apply_error_policy(error),
515        }
516    }
517}
518
519impl<S: 'static, A: Action, St> Runtime<S, A, NoEffect, Direct, St>
520where
521    St: RuntimeStore<S, A, NoEffect>,
522{
523    /// Run the event/action loop until quit.
524    pub async fn run<B, FRender, FEvent, FQuit, R>(
525        &mut self,
526        terminal: &mut Terminal<B>,
527        mut render: FRender,
528        mut map_event: FEvent,
529        mut should_quit: FQuit,
530    ) -> io::Result<()>
531    where
532        B: Backend,
533        FRender: FnMut(&mut Frame, Rect, &S, RenderContext),
534        FEvent: FnMut(&EventKind, &S) -> R,
535        R: Into<EventOutcome<A>>,
536        FQuit: FnMut(&A) -> bool,
537    {
538        let (mut event_rx, cancel_token) = self.shell.spawn_poller();
539
540        loop {
541            if self.shell.should_render {
542                draw_frame(&mut self.shell, self.store.state(), terminal, &mut render)?;
543            }
544
545            tokio::select! {
546                Some(raw_event) = event_rx.recv() => {
547                    self.shell.process_event(raw_event, self.store.state(), &mut map_event);
548                }
549
550                Some(action) = self.shell.action_rx.recv() => {
551                    if should_quit(&action) {
552                        break;
553                    }
554                    self.shell.debug_log_action(&action);
555                    if self.dispatch_action(action) {
556                        break;
557                    }
558                }
559
560                else => { break; }
561            }
562        }
563
564        self.cleanup(cancel_token);
565        Ok(())
566    }
567}
568
569impl<S: 'static, A: Action, E, St> Runtime<S, A, E, Direct, St>
570where
571    St: RuntimeStore<S, A, E>,
572{
573    /// Run the event/action loop until quit, handling emitted effects at the
574    /// run boundary.
575    pub async fn run_with_effects<B, FRender, FEvent, FQuit, FEffect, R>(
576        &mut self,
577        terminal: &mut Terminal<B>,
578        mut render: FRender,
579        mut map_event: FEvent,
580        mut should_quit: FQuit,
581        mut handle_effect: FEffect,
582    ) -> io::Result<()>
583    where
584        B: Backend,
585        FRender: FnMut(&mut Frame, Rect, &S, RenderContext),
586        FEvent: FnMut(&EventKind, &S) -> R,
587        R: Into<EventOutcome<A>>,
588        FQuit: FnMut(&A) -> bool,
589        FEffect: FnMut(E, &mut EffectContext<A>),
590    {
591        let (mut event_rx, cancel_token) = self.shell.spawn_poller();
592
593        loop {
594            if self.shell.should_render {
595                draw_frame(&mut self.shell, self.store.state(), terminal, &mut render)?;
596            }
597
598            tokio::select! {
599                Some(raw_event) = event_rx.recv() => {
600                    self.shell.process_event(raw_event, self.store.state(), &mut map_event);
601                }
602
603                Some(action) = self.shell.action_rx.recv() => {
604                    if should_quit(&action) {
605                        break;
606                    }
607                    self.shell.debug_log_action(&action);
608                    if self.dispatch_and_handle_effects(action, &mut handle_effect) {
609                        break;
610                    }
611                }
612
613                else => { break; }
614            }
615        }
616
617        self.cleanup(cancel_token);
618        Ok(())
619    }
620}
621
622/// Context passed to effect handlers.
623pub struct EffectContext<'a, A: Action> {
624    action_tx: &'a mpsc::UnboundedSender<A>,
625    #[cfg(feature = "tasks")]
626    tasks: &'a mut TaskManager<A>,
627    #[cfg(feature = "subscriptions")]
628    subscriptions: &'a mut Subscriptions<A>,
629}
630
631impl<'a, A: Action> EffectContext<'a, A> {
632    /// Send an action directly.
633    pub fn emit(&self, action: A) {
634        let _ = self.action_tx.send(action);
635    }
636
637    /// Access the action sender.
638    pub fn action_tx(&self) -> &mpsc::UnboundedSender<A> {
639        self.action_tx
640    }
641
642    /// Access the task manager.
643    #[cfg(feature = "tasks")]
644    pub fn tasks(&mut self) -> &mut TaskManager<A> {
645        self.tasks
646    }
647
648    /// Access subscriptions.
649    #[cfg(feature = "subscriptions")]
650    pub fn subscriptions(&mut self) -> &mut Subscriptions<A> {
651        self.subscriptions
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use crate::store::DispatchLimits;
659    use std::collections::VecDeque;
660
661    #[derive(Clone, Debug)]
662    enum TestAction {
663        Increment,
664    }
665
666    impl Action for TestAction {
667        fn name(&self) -> &'static str {
668            match self {
669                TestAction::Increment => "Increment",
670            }
671        }
672    }
673
674    #[derive(Default)]
675    struct TestState {
676        count: usize,
677    }
678
679    fn reducer(state: &mut TestState, _action: TestAction) -> ReducerResult {
680        state.count += 1;
681        ReducerResult::changed()
682    }
683
684    fn effect_reducer(state: &mut TestState, _action: TestAction) -> ReducerResult<()> {
685        state.count += 1;
686        ReducerResult::changed()
687    }
688
689    struct LoopMiddleware;
690
691    impl Middleware<TestState, TestAction> for LoopMiddleware {
692        fn before(&mut self, _action: &TestAction, _state: &TestState) -> bool {
693            true
694        }
695
696        fn after(
697            &mut self,
698            _action: &TestAction,
699            _state_changed: bool,
700            _state: &TestState,
701        ) -> Vec<TestAction> {
702            vec![TestAction::Increment]
703        }
704    }
705
706    struct MockStore<E> {
707        state: TestState,
708        queued_results: VecDeque<Result<ReducerResult<E>, DispatchError>>,
709    }
710
711    impl<E> MockStore<E> {
712        fn from_results(
713            results: impl IntoIterator<Item = Result<ReducerResult<E>, DispatchError>>,
714        ) -> Self {
715            Self {
716                state: TestState::default(),
717                queued_results: results.into_iter().collect(),
718            }
719        }
720    }
721
722    impl<E> RuntimeStore<TestState, TestAction, E> for MockStore<E> {
723        fn dispatch(&mut self, _action: TestAction) -> ReducerResult<E> {
724            ReducerResult::unchanged()
725        }
726
727        fn try_dispatch(&mut self, _action: TestAction) -> Result<ReducerResult<E>, DispatchError> {
728            self.queued_results
729                .pop_front()
730                .expect("test configured with at least one dispatch result")
731        }
732
733        fn state(&self) -> &TestState {
734            &self.state
735        }
736    }
737
738    fn test_error() -> DispatchError {
739        DispatchError::DepthExceeded {
740            max_depth: 1,
741            action: "Increment",
742        }
743    }
744
745    #[test]
746    fn runtime_continue_policy_keeps_running_without_render() {
747        let store: MockStore<NoEffect> = MockStore::from_results([Err(test_error())]);
748        let mut runtime = Runtime::from_store(store)
749            .with_dispatch_error_handler(|_| DispatchErrorPolicy::Continue);
750        runtime.shell.should_render = false;
751
752        let should_stop = runtime.dispatch_action(TestAction::Increment);
753
754        assert!(!should_stop);
755        assert!(!runtime.shell.should_render);
756    }
757
758    #[test]
759    fn runtime_render_policy_forces_render() {
760        let store: MockStore<NoEffect> = MockStore::from_results([Err(test_error())]);
761        let mut runtime =
762            Runtime::from_store(store).with_dispatch_error_handler(|_| DispatchErrorPolicy::Render);
763        runtime.shell.should_render = false;
764
765        let should_stop = runtime.dispatch_action(TestAction::Increment);
766
767        assert!(!should_stop);
768        assert!(runtime.shell.should_render);
769    }
770
771    #[test]
772    fn runtime_stop_policy_breaks_loop() {
773        let store: MockStore<NoEffect> = MockStore::from_results([Err(test_error())]);
774        let mut runtime =
775            Runtime::from_store(store).with_dispatch_error_handler(|_| DispatchErrorPolicy::Stop);
776        runtime.shell.should_render = false;
777
778        let should_stop = runtime.dispatch_action(TestAction::Increment);
779
780        assert!(should_stop);
781        assert!(!runtime.shell.should_render);
782    }
783
784    #[test]
785    fn runtime_effect_continue_policy_keeps_running_without_render() {
786        let store: MockStore<()> = MockStore::from_results([Err(test_error())]);
787        let mut runtime = Runtime::from_store(store)
788            .with_dispatch_error_handler(|_| DispatchErrorPolicy::Continue);
789        runtime.shell.should_render = false;
790
791        let should_stop =
792            runtime.dispatch_and_handle_effects(TestAction::Increment, &mut |_effect, _ctx| {});
793
794        assert!(!should_stop);
795        assert!(!runtime.shell.should_render);
796    }
797
798    #[test]
799    fn runtime_effect_render_policy_forces_render() {
800        let store: MockStore<()> = MockStore::from_results([Err(test_error())]);
801        let mut runtime =
802            Runtime::from_store(store).with_dispatch_error_handler(|_| DispatchErrorPolicy::Render);
803        runtime.shell.should_render = false;
804
805        let should_stop =
806            runtime.dispatch_and_handle_effects(TestAction::Increment, &mut |_effect, _ctx| {});
807
808        assert!(!should_stop);
809        assert!(runtime.shell.should_render);
810    }
811
812    #[test]
813    fn runtime_effect_stop_policy_breaks_loop() {
814        let store: MockStore<()> = MockStore::from_results([Err(test_error())]);
815        let mut runtime =
816            Runtime::from_store(store).with_dispatch_error_handler(|_| DispatchErrorPolicy::Stop);
817        runtime.shell.should_render = false;
818
819        let should_stop =
820            runtime.dispatch_and_handle_effects(TestAction::Increment, &mut |_effect, _ctx| {});
821
822        assert!(should_stop);
823        assert!(!runtime.shell.should_render);
824    }
825
826    #[test]
827    fn runtime_uses_try_dispatch_for_middleware_overflow() {
828        let store = StoreWithMiddleware::new(TestState::default(), reducer, LoopMiddleware)
829            .with_dispatch_limits(DispatchLimits {
830                max_depth: 1,
831                max_actions: 100,
832            });
833        let mut runtime =
834            Runtime::from_store(store).with_dispatch_error_handler(|_| DispatchErrorPolicy::Stop);
835        runtime.shell.should_render = false;
836
837        let should_stop = runtime.dispatch_action(TestAction::Increment);
838
839        assert!(should_stop);
840        assert_eq!(runtime.state().count, 1);
841    }
842
843    #[test]
844    fn runtime_effect_uses_try_dispatch_for_middleware_overflow() {
845        let store = StoreWithMiddleware::new(TestState::default(), effect_reducer, LoopMiddleware)
846            .with_dispatch_limits(DispatchLimits {
847                max_depth: 1,
848                max_actions: 100,
849            });
850        let mut runtime =
851            Runtime::from_store(store).with_dispatch_error_handler(|_| DispatchErrorPolicy::Stop);
852        runtime.shell.should_render = false;
853
854        let should_stop =
855            runtime.dispatch_and_handle_effects(TestAction::Increment, &mut |_effect, _ctx| {});
856
857        assert!(should_stop);
858        assert_eq!(runtime.state().count, 1);
859    }
860}