Skip to content

Commit

Permalink
restore actor based reducer hook impl
Browse files Browse the repository at this point in the history
add reducer cmd processing
  • Loading branch information
eliknebel committed Oct 23, 2024
1 parent 322d24a commit 93ed863
Show file tree
Hide file tree
Showing 4 changed files with 268 additions and 42 deletions.
1 change: 1 addition & 0 deletions src/sprocket/context.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ pub type Hook {
prev: Option(EffectResult),
)
Handler(id: Unique, handler_fn: HandlerFn)
Reducer(id: Unique, reducer: Dynamic, cleanup: fn() -> Nil)
State(id: Unique, value: Dynamic)
Client(id: Unique, name: String, handle_event: Option(ClientEventHandler))
}
Expand Down
128 changes: 105 additions & 23 deletions src/sprocket/hooks.gleam
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import gleam/dict
import gleam/dynamic
import gleam/erlang/process.{type Subject}
import gleam/function.{identity}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/otp/actor
import gleam/result
import sprocket/context.{
type Attribute, type ClientDispatcher, type ClientEventHandler, type Context,
type EffectCleanup, type Element, type HandlerFn, type HookDependencies,
type IdentifiableHandler, Callback, CallbackResult, Changed, Client,
ClientHook, Context, Effect, Handler, IdentifiableHandler, Unchanged,
compare_deps,
}
import sprocket/internal/constants.{call_timeout}
import sprocket/internal/exceptions.{throw_on_unexpected_hook_result}
import sprocket/internal/logger
import sprocket/internal/utils/unique
import sprocket/internal/utils/unsafe_coerce.{unsafe_coerce}

Expand Down Expand Up @@ -204,51 +211,126 @@ pub fn provider(
cb(ctx, value)
}

type Dispatcher(msg) =
fn(msg) -> Nil

pub type Cmd(msg) =
fn(Dispatcher(msg)) -> Nil

type Reducer(model, msg) =
fn(model, msg) -> model
fn(model, msg) -> #(model, List(Cmd(msg)))

type ReducerMsg(model, msg) {
Shutdown
GetState(reply_with: Subject(model))
ReducerDispatch(r: Reducer(model, msg), m: msg)
}

/// Reducer Hook
/// ------------
/// Creates a reducer hook that can be used to manage more complex state. The reducer hook will
/// return the current model of the reducer and a dispatch function that can be used
/// to update the reducer's model. Dispatching a message to the reducer will result
/// Creates a reducer hook that can be used to manage state. The reducer hook will
/// return the current state of the reducer and a dispatch function that can be used
/// to update the reducer's state. Dispatching a message to the reducer will result
/// in a re-render of the component.
pub fn reducer(
ctx: Context,
initial: model,
reducer: Reducer(model, msg),
cb: fn(Context, model, fn(msg) -> Nil) -> #(Context, Element),
) -> #(Context, Element) {
let Context(render_update: render_update, update_hook: update_hook, ..) = ctx
let Context(render_update: render_update, ..) = ctx

let init_state = fn() {
context.State(unique.cuid(ctx.cuid_channel), dynamic.from(initial))
// create a dispatch function for updating the reducer's state and triggering a render update
let dispatch = fn(msg, a) -> Nil {
// this will update the reducer's state and trigger a re-render. To ensure we re-render
// with the latest state, this message must be processed before the next render cycle. However,
// because we also use a process.call to the same reducer actor to get the current state, we should
// be guaranteed to have this message processed before that call during the next render cycle.
actor.send(a, ReducerDispatch(r: reducer, m: msg))

render_update()
}

let assert #(ctx, context.State(hook_id, value), _index) =
context.fetch_or_init_hook(ctx, init_state)
// Creates an actor process for a reducer that handles two types of messages:
// 1. GetState msg, which simply returns the state of the reducer
// 2. ReducerDispatch msg, which will update the reducer state when a dispatch is triggered
let reducer_init = fn() {
// Create the reducer actor initializer
let reducer_actor_init = fn() {
let self = process.new_subject()

// create a dispatch function for updating the reducer's state and triggering a render update
let dispatch = fn(msg) -> Nil {
update_hook(hook_id, fn(hook) {
case hook {
context.State(id, model) if id == hook_id -> {
let model = unsafe_coerce(model)
let selector = process.selecting(process.new_selector(), self, identity)

actor.Ready(#(self, initial), selector)
}

context.State(id, dynamic.from(reducer(model, msg)))
// Define the message handler for the reducer actor. There are two levels of state being
// addressed here: the actor state and the reducer state. The actor state is a tuple of
// the actor's subject and the reducer's state. The reducer state is the current state of
// the reducer's model.
let reducer_actor_handle_message = fn(
message: ReducerMsg(model, msg),
state,
) -> actor.Next(
ReducerMsg(model, msg),
#(Subject(ReducerMsg(model, msg)), model),
) {
case message {
Shutdown -> actor.Stop(process.Normal)

GetState(reply_with) -> {
let #(_self, model) = state

process.send(reply_with, model)
actor.continue(state)
}
_ -> {
// this should never happen and could be an indication that a hook is being
// used incorrectly
throw_on_unexpected_hook_result(hook)

ReducerDispatch(r, m) -> {
let #(self, model) = 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
// are functions that will be called with the dispatcher function which may trigger
// additional messages to the reducer.
let #(updated_model, cmds) = r(model, m)

list.each(cmds, fn(cmd) { cmd(dispatch(_, self)) })

actor.continue(#(self, updated_model))
}
}
})
}

render_update()
// Finally, start the actor process
let assert Ok(reducer_actor) =
actor.start_spec(actor.Spec(
reducer_actor_init,
call_timeout,
reducer_actor_handle_message,
))
|> result.map_error(fn(error) {
logger.error("hooks.reducer: failed to start reducer actor")
error
})

context.Reducer(
unique.cuid(ctx.cuid_channel),
dynamic.from(reducer_actor),
fn() { process.send(reducer_actor, Shutdown) },
)
}

cb(ctx, unsafe_coerce(value), dispatch)
let assert #(ctx, context.Reducer(_id, dyn_reducer_actor, _cleanup), _index) =
context.fetch_or_init_hook(ctx, reducer_init)

// we dont know what types of reducer messages a component will implement so the best we can do is
// store the actors as dynamic and coerce them back when updating
let reducer_actor = unsafe_coerce(dyn_reducer_actor)

// get the current state of the reducer
let state = process.call(reducer_actor, GetState(_), call_timeout)

cb(ctx, state, dispatch(_, reducer_actor))
}

/// State Hook
Expand Down
9 changes: 8 additions & 1 deletion src/sprocket/runtime.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import sprocket/context.{
type ComponentHooks, type Context, type Dispatcher, type EffectCleanup,
type EffectResult, type Element, type Hook, type HookDependencies,
type IdentifiableHandler, type Updater, Callback, Changed, Client, Context,
Dispatcher, Effect, EffectResult, Handler, IdentifiableHandler, Memo,
Dispatcher, Effect, EffectResult, Handler, IdentifiableHandler, Memo, Reducer,
Unchanged, Updater, callback_param_from_string, compare_deps,
}
import sprocket/internal/constants.{call_timeout}
Expand Down Expand Up @@ -418,6 +418,8 @@ fn cleanup_hooks(rendered: ReconciledElement) {
}
}

Reducer(_, _, cleanup) -> cleanup()

_ -> Nil
}
})
Expand Down Expand Up @@ -446,6 +448,8 @@ fn run_cleanup_for_disposed_hooks(
}
}

Ok(Reducer(_, _, cleanup)) -> cleanup()

_ -> Nil
}
})
Expand Down Expand Up @@ -477,6 +481,9 @@ fn build_hooks_map(
context.State(id, _) -> {
dict.insert(acc, id, hook)
}
Reducer(id, _, _) -> {
dict.insert(acc, id, hook)
}
context.Client(id, _, _) -> {
dict.insert(acc, id, hook)
}
Expand Down
Loading

0 comments on commit 93ed863

Please sign in to comment.