Skip to main content

rust_elm/
reducer.rs

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