From a7aaff18b43872dd416dce025a01e399c510735b Mon Sep 17 00:00:00 2001 From: Eli Knebel Date: Sun, 10 Nov 2024 22:38:01 -0500 Subject: [PATCH] process reducer cmds during effects reconciliation --- src/sprocket/internal/reducer.gleam | 36 +++++++++++++++---- src/sprocket/runtime.gleam | 10 ++++++ test/sprocket/hooks_test.gleam | 24 +++++++++++++ test/sprocket/internal/reducer_test.gleam | 44 +++++++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 test/sprocket/internal/reducer_test.gleam diff --git a/src/sprocket/internal/reducer.gleam b/src/sprocket/internal/reducer.gleam index e603248..1332376 100644 --- a/src/sprocket/internal/reducer.gleam +++ b/src/sprocket/internal/reducer.gleam @@ -24,6 +24,8 @@ pub type ReducerMessage(model, msg) { Shutdown GetState(reply_with: Subject(model)) ReducerDispatch(msg: msg) + PushCommands(cmds: List(Cmd(msg))) + ProcessCommands } pub opaque type State(model, msg) { @@ -32,6 +34,7 @@ pub opaque type State(model, msg) { model: model, update: UpdateFn(model, msg), notify: fn(model) -> Nil, + pending_commands: List(Cmd(msg)), ) } @@ -44,7 +47,7 @@ pub fn init( let self = process.new_subject() let selector = process.selecting(process.new_selector(), self, identity) - actor.Ready(State(self, initial, update, notify), selector) + actor.Ready(State(self, initial, update, notify, []), selector) } } @@ -64,7 +67,7 @@ pub fn handle_message( } ReducerDispatch(msg) -> { - let State(self:, model:, update:, notify:) = state + let State(self:, model:, update:, notify:, ..) = state // This is the main logic for updating the reducer's state. The reducer function will // return the updated model and a list of zero or more commands to execute. The commands @@ -72,7 +75,7 @@ pub fn handle_message( // additional messages to the reducer. let #(updated_model, cmds) = update(model, msg) - async_process_commands(cmds, dispatch(self, _)) + process.send(self, PushCommands(cmds)) // Notiify the parent component that the state has been updated. In the case // of a view component, this will trigger a re-render. The re-render will call @@ -83,6 +86,25 @@ pub fn handle_message( actor.continue(State(..state, model: updated_model)) } + + PushCommands(cmds) -> { + let State(pending_commands:, ..) = state + + let pending_commands = list.append(pending_commands, cmds) + + actor.continue(State(..state, pending_commands:)) + } + + ProcessCommands -> { + let State(self:, pending_commands:, ..) = state + + pending_commands + |> list.each(fn(cmd) { + process.start(fn() { cmd(dispatch(self, _)) }, False) + }) + + actor.continue(State(..state, pending_commands: [])) + } } } @@ -108,7 +130,7 @@ pub fn start( )), ) - async_process_commands(cmds, dispatch(self, _)) + process.send(self, PushCommands(cmds)) self } @@ -132,7 +154,7 @@ pub fn dispatch(subject: Subject(ReducerMessage(model, msg)), msg: msg) -> Nil { actor.send(subject, ReducerDispatch(msg)) } -fn async_process_commands(cmds, dispatcher) -> Nil { - cmds - |> list.each(fn(cmd) { process.start(fn() { cmd(dispatcher) }, False) }) +/// Processes any pending commands in the reducer actor. +pub fn process_commands(subject) -> Nil { + process.send(subject, ProcessCommands) } diff --git a/src/sprocket/runtime.gleam b/src/sprocket/runtime.gleam index 31f8f4d..ff2e254 100644 --- a/src/sprocket/runtime.gleam +++ b/src/sprocket/runtime.gleam @@ -24,11 +24,13 @@ import sprocket/internal/reconcile.{ ReconciledFragment, ReconciledResult, } import sprocket/internal/reconcilers/recursive +import sprocket/internal/reducer import sprocket/internal/utils/ordered_map.{ type KeyedItem, type OrderedMapIter, KeyedItem, } import sprocket/internal/utils/timer import sprocket/internal/utils/unique.{type Unique} +import sprocket/internal/utils/unsafe_coerce pub type Runtime = Subject(Message) @@ -514,6 +516,14 @@ fn run_effects(rendered: ReconciledElement) -> ReconciledElement { Effect(id, effect_fn, deps, Some(result)) } + Reducer(id, reducer_actor, cleanup) -> { + reducer_actor + |> unsafe_coerce.unsafe_coerce() + |> reducer.process_commands() + + Reducer(id, reducer_actor, cleanup) + } + other -> other } }) diff --git a/test/sprocket/hooks_test.gleam b/test/sprocket/hooks_test.gleam index 78b1a58..c70c9df 100644 --- a/test/sprocket/hooks_test.gleam +++ b/test/sprocket/hooks_test.gleam @@ -295,6 +295,18 @@ pub fn reducer_should_run_cmds_test() { let spkt = render_event(spkt, ClickEvent, "random") + // reducer commands are run asynchonously so we need to wait for the command to be processed + // before we can test the initial state + test_helpers.wait_until( + fn() { + let #(_spkt, rendered) = render_html(spkt) + + rendered + |> string.starts_with("current count is: 42") + }, + 1000, + ) + let #(_spkt, rendered) = render_html(spkt) let assert True = @@ -375,6 +387,18 @@ pub fn reducer_should_initialize_with_cmds_test() { let spkt = connect(view) + // reducer commands are run asynchonously so we need to wait for the command to be processed + // before we can test the initial state + test_helpers.wait_until( + fn() { + let #(_spkt, rendered) = render_html(spkt) + + rendered + |> string.starts_with("current count is: 42") + }, + 1000, + ) + let #(spkt, rendered) = render_html(spkt) let assert True = diff --git a/test/sprocket/internal/reducer_test.gleam b/test/sprocket/internal/reducer_test.gleam new file mode 100644 index 0000000..bb44a02 --- /dev/null +++ b/test/sprocket/internal/reducer_test.gleam @@ -0,0 +1,44 @@ +import sprocket/internal/reducer + +type Model = + Int + +type Msg { + Increment + Set(Int) + Reset +} + +fn init(value) -> #(Model, List(reducer.Cmd(Msg))) { + #(value, []) +} + +fn update(model, msg) -> #(Model, List(reducer.Cmd(Msg))) { + case msg { + Increment -> #(model + 1, []) + Set(value) -> #(value, []) + Reset -> #(0, []) + } +} + +pub fn reducer_should_dispatch() { + let assert Ok(reducer_actor) = reducer.start(init(0), update, fn(_) { Nil }) + + reducer.dispatch(reducer_actor, Increment) + + let state = reducer.get_state(reducer_actor) + + let assert True = state == 1 + + reducer.dispatch(reducer_actor, Increment) + + let state = reducer.get_state(reducer_actor) + + let assert True = state == 2 + + reducer.dispatch(reducer_actor, Reset) + + let state = reducer.get_state(reducer_actor) + + let assert True = state == 0 +}