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