Skip to main content

rust_elm/
reducer.rs

1use crate::cmd::Cmd;
2use crate::store::{catch_reduce, catch_reduce_panic, ReducePanic};
3
4/// Composable update logic (UDF `Reducer` parity).
5pub trait Reducer {
6    type State;
7    type Action;
8
9    fn reduce(&self, state: &mut Self::State, action: Self::Action) -> Cmd<Self::Action>;
10}
11
12/// Blanket impl — existing `update` fn pointers are reducers (zero-cost hot path).
13impl<S, A> Reducer for fn(&mut S, A) -> Cmd<A> {
14    type State = S;
15    type Action = A;
16
17    fn reduce(&self, state: &mut S, action: A) -> Cmd<A> {
18        self(state, action)
19    }
20}
21
22/// Coerce a function item to an fn pointer for use in [`CombineReducers`].
23#[inline]
24pub const fn coerce_fn<S, A>(f: fn(&mut S, A) -> Cmd<A>) -> fn(&mut S, A) -> Cmd<A> {
25    f
26}
27
28/// Named inline reducer wrapping a fn pointer.
29#[derive(Copy, Clone, Debug, PartialEq, Eq)]
30pub struct Reduce<S, A> {
31    reduce: fn(&mut S, A) -> Cmd<A>,
32}
33
34impl<S, A> Reduce<S, A> {
35    pub const fn new(reduce: fn(&mut S, A) -> Cmd<A>) -> Self {
36        Self { reduce }
37    }
38
39    pub const fn none() -> Self
40    where
41        S: Default,
42    {
43        Self::new(|_, _| Cmd::none())
44    }
45}
46
47impl<S, A> Reducer for Reduce<S, A> {
48    type State = S;
49    type Action = A;
50
51    fn reduce(&self, state: &mut S, action: A) -> Cmd<A> {
52        (self.reduce)(state, action)
53    }
54}
55
56/// Runs multiple reducers in order and merges their commands.
57#[derive(Copy, Clone, Debug, PartialEq, Eq)]
58pub struct CombineReducers<R>(pub R);
59
60macro_rules! impl_combine_reducers {
61    ($($R:ident),+) => {
62        impl<S, A, $($R),+> Reducer for CombineReducers<($($R,)+)>
63        where
64            $($R: Reducer<State = S, Action = A>),+,
65            A: Clone,
66        {
67            type State = S;
68            type Action = A;
69
70            fn reduce(&self, state: &mut S, action: A) -> Cmd<A> {
71                let ($($R,)+) = &self.0;
72                Cmd::batch([$( $R.reduce(state, action.clone()) ),+])
73            }
74        }
75    };
76}
77
78impl_combine_reducers!(R1);
79impl_combine_reducers!(R1, R2);
80impl_combine_reducers!(R1, R2, R3);
81impl_combine_reducers!(R1, R2, R3, R4);
82impl_combine_reducers!(R1, R2, R3, R4, R5);
83
84/// Wraps a reducer with panic recovery — default behavior matches the runtime: panics are
85/// caught and state is **not** reverted (see [`catch_reduce_panic`]).
86#[derive(Clone, Debug)]
87pub struct CatchReducer<R, F> {
88    inner: R,
89    recover: F,
90}
91
92impl<R, F> CatchReducer<R, F> {
93    pub fn new(inner: R, recover: F) -> Self {
94        Self { inner, recover }
95    }
96}
97
98impl<R, F, S, A> Reducer for CatchReducer<R, F>
99where
100    R: Reducer<State = S, Action = A>,
101    F: Fn(ReducePanic) -> Cmd<A>,
102{
103    type State = S;
104    type Action = A;
105
106    fn reduce(&self, state: &mut S, action: A) -> Cmd<A> {
107        match catch_reduce_panic(state, |s, a| self.inner.reduce(s, a), action) {
108            Ok(cmd) => cmd,
109            Err(panic) => (self.recover)(panic),
110        }
111    }
112}
113
114/// Opt-in rollback — like [`CatchReducer`], but restores the last committed state on panic
115/// via [`catch_reduce`].
116pub struct RollbackCatchReducer<R, F, S> {
117    inner: R,
118    recover: F,
119    checkpoint: parking_lot::Mutex<S>,
120}
121
122impl<R, F, S> RollbackCatchReducer<R, F, S>
123where
124    S: Clone,
125{
126    pub fn new(inner: R, recover: F, committed: &S) -> Self {
127        Self {
128            inner,
129            recover,
130            checkpoint: parking_lot::Mutex::new(committed.clone()),
131        }
132    }
133}
134
135impl<R, F, S> Clone for RollbackCatchReducer<R, F, S>
136where
137    R: Clone,
138    F: Clone,
139    S: Clone,
140{
141    fn clone(&self) -> Self {
142        Self {
143            inner: self.inner.clone(),
144            recover: self.recover.clone(),
145            checkpoint: parking_lot::Mutex::new(self.checkpoint.lock().clone()),
146        }
147    }
148}
149
150impl<R, F, S> std::fmt::Debug for RollbackCatchReducer<R, F, S>
151where
152    R: std::fmt::Debug,
153    F: std::fmt::Debug,
154{
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("RollbackCatchReducer")
157            .field("inner", &self.inner)
158            .field("recover", &self.recover)
159            .finish_non_exhaustive()
160    }
161}
162
163impl<R, F, S, A> Reducer for RollbackCatchReducer<R, F, S>
164where
165    R: Reducer<State = S, Action = A>,
166    S: Clone,
167    F: Fn(ReducePanic) -> Cmd<A>,
168{
169    type State = S;
170    type Action = A;
171
172    fn reduce(&self, state: &mut S, action: A) -> Cmd<A> {
173        let mut checkpoint = self.checkpoint.lock();
174        match catch_reduce(state, &mut *checkpoint, |s, a| self.inner.reduce(s, a), action) {
175            Ok(cmd) => cmd,
176            Err(panic) => (self.recover)(panic),
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::effect::Effect;
185    use crate::panic_on_state_clone;
186    use crate::test_support::allow_state_clones;
187
188    panic_on_state_clone! {
189        #[derive(Default, Debug, PartialEq, Eq)]
190        struct App {
191            a: i32,
192            b: i32,
193        }
194    }
195
196    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
197    enum Action {
198        Tick,
199    }
200
201    fn reduce_a(state: &mut App, _: Action) -> Cmd<Action> {
202        state.a += 1;
203        Cmd::none()
204    }
205
206    fn reduce_b(state: &mut App, _: Action) -> Cmd<Action> {
207        state.b += 10;
208        Cmd::none()
209    }
210
211    fn effect_a(_: &mut App, _: Action) -> Cmd<Action> {
212        Cmd::single(Effect::task(1, || Box::pin(async { Ok(Action::Tick) })))
213    }
214
215    #[test]
216    fn fn_pointer_is_reducer() {
217        let mut app = App::default();
218        let cmd = coerce_fn(reduce_a).reduce(&mut app, Action::Tick);
219        assert_eq!(app.a, 1);
220        assert!(cmd.is_none());
221    }
222
223    #[test]
224    fn reduce_wrapper_delegates() {
225        let r = Reduce::new(reduce_b);
226        let mut app = App::default();
227        r.reduce(&mut app, Action::Tick);
228        assert_eq!(app.b, 10);
229    }
230
231    #[test]
232    fn combine_runs_in_order_and_merges_cmds() {
233        let combined = CombineReducers((coerce_fn(reduce_a), coerce_fn(effect_a)));
234        let mut app = App::default();
235        let cmd = combined.reduce(&mut app, Action::Tick);
236        assert_eq!(app.a, 1);
237        assert_eq!(cmd.effect_count(), 1);
238    }
239
240    #[test]
241    fn combine_three_reducers() {
242        fn inc_a(s: &mut App, _: Action) -> Cmd<Action> {
243            s.a += 1;
244            Cmd::none()
245        }
246        fn inc_b(s: &mut App, _: Action) -> Cmd<Action> {
247            s.b += 1;
248            Cmd::none()
249        }
250        fn noop(_: &mut App, _: Action) -> Cmd<Action> {
251            Cmd::none()
252        }
253
254        let combined = CombineReducers((coerce_fn(inc_a), coerce_fn(inc_b), coerce_fn(noop)));
255        let mut app = App::default();
256        combined.reduce(&mut app, Action::Tick);
257        assert_eq!(app.a, 1);
258        assert_eq!(app.b, 1);
259    }
260
261    #[test]
262    fn reducers_macro_builds_combine() {
263        let combined = crate::reducers![reduce_a, reduce_b];
264        let mut app = App::default();
265        combined.reduce(&mut app, Action::Tick);
266        assert_eq!(app.a, 1);
267        assert_eq!(app.b, 10);
268    }
269
270    #[test]
271    fn catch_reducer_recovers_without_reverting_state() {
272        fn panicking(s: &mut App, _: Action) -> Cmd<Action> {
273            s.a = 99;
274            panic!("boom");
275        }
276
277        fn recover(_: ReducePanic) -> Cmd<Action> {
278            Cmd::none()
279        }
280
281        let mut app = App::default();
282        let caught = CatchReducer::new(coerce_fn(panicking), recover);
283        let cmd = caught.reduce(&mut app, Action::Tick);
284        assert_eq!(app.a, 99);
285        assert!(cmd.is_none());
286    }
287
288    #[test]
289    fn rollback_catch_reducer_unwinds_state_on_panic() {
290        fn panicking(s: &mut App, _: Action) -> Cmd<Action> {
291            s.a = 99;
292            panic!("boom");
293        }
294
295        fn recover(_: ReducePanic) -> Cmd<Action> {
296            Cmd::none()
297        }
298
299        let mut app = App::default();
300        let caught = allow_state_clones(1, || {
301            RollbackCatchReducer::new(coerce_fn(panicking), recover, &app)
302        });
303        let cmd = caught.reduce(&mut app, Action::Tick);
304        assert_eq!(app.a, 0);
305        assert!(cmd.is_none());
306    }
307}