Packages

Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.

Current section

Files

Jump to
raxol lib raxol ui state management state_management_server.ex
Raw

lib/raxol/ui/state/management/state_management_server.ex

defmodule Raxol.UI.State.Management.StateManagementServer do
@moduledoc """
Unified GenServer for UI state management, handling both store operations
and component hooks without Process dictionary usage.
This server consolidates:
- Global state store management
- Component state and hooks
- Debounce timer management
- Render context tracking
## Features
- Redux-like state management
- React-like hooks support
- Per-component state isolation
- Automatic cleanup on process termination
"""
use Raxol.Core.Behaviours.BaseManager
alias Raxol.Core.Runtime.Log
# Client API
@doc """
Returns a child specification for this server.
"""
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :permanent,
shutdown: 5000
}
end
# Store API
def dispatch(action) do
GenServer.call(__MODULE__, {:dispatch, action})
end
def get_state(path \\ []) do
GenServer.call(__MODULE__, {:get_state, path})
end
def update_state(path, value) do
GenServer.call(__MODULE__, {:update_state, path, value})
end
def subscribe(path, callback, options \\ []) do
subscription_id = System.unique_integer([:positive, :monotonic])
GenServer.call(
__MODULE__,
{:subscribe, subscription_id, path, callback, options}
)
# Return unsubscribe function
fn -> unsubscribe(subscription_id) end
end
def unsubscribe(subscription_id) do
GenServer.call(__MODULE__, {:unsubscribe, subscription_id})
end
def register_reducer(reducer_fn) do
GenServer.call(__MODULE__, {:register_reducer, reducer_fn})
end
# Hooks API
def get_component_id(pid \\ nil) do
pid =
case pid do
nil -> self()
p -> p
end
GenServer.call(__MODULE__, {:get_component_id, pid})
end
def set_component_id(id, pid \\ nil) do
pid =
case pid do
nil -> self()
p -> p
end
GenServer.call(__MODULE__, {:set_component_id, pid, id})
end
def get_render_context(pid \\ nil) do
pid =
case pid do
nil -> self()
p -> p
end
GenServer.call(__MODULE__, {:get_render_context, pid})
end
def set_render_context(context, pid \\ nil) do
pid =
case pid do
nil -> self()
p -> p
end
GenServer.call(__MODULE__, {:set_render_context, pid, context})
end
def get_component_process(pid \\ nil) do
pid =
case pid do
nil -> self()
p -> p
end
GenServer.call(__MODULE__, {:get_component_process, pid})
end
def set_component_process(process_pid, pid \\ nil) do
pid =
case pid do
nil -> self()
p -> p
end
GenServer.call(__MODULE__, {:set_component_process, pid, process_pid})
end
def get_hook_state(component_id, hook_id) do
GenServer.call(__MODULE__, {:get_hook_state, component_id, hook_id})
end
def set_hook_state(component_id, hook_id, value) do
GenServer.call(__MODULE__, {:set_hook_state, component_id, hook_id, value})
end
# Timer Management API
def schedule_debounced(key, message, delay_ms) do
GenServer.call(
__MODULE__,
{:schedule_debounced, self(), key, message, delay_ms}
)
end
def cancel_debounced(key) do
GenServer.call(__MODULE__, {:cancel_debounced, self(), key})
end
@doc """
Sets context for a component.
"""
def set_context(component_id, context) do
GenServer.call(__MODULE__, {:set_context, component_id, context})
end
@doc """
Gets context for a component.
"""
def get_context(component_id, default \\ nil) do
GenServer.call(__MODULE__, {:get_context, component_id, default})
end
@doc """
Sets a slot for a component.
"""
def set_slot(component_id, slot_data) do
GenServer.call(__MODULE__, {:set_slot, component_id, slot_data})
end
# Missing function stubs to fix compilation warnings
def clear_component_context(_component_id), do: :ok
def get_cache(_key), do: :cache_miss
def get_all_hook_state(_component_id), do: %{}
def set_cache(_key, _value), do: :ok
def clear_hook_state(_component_id), do: :ok
def schedule_update(_component_id), do: :ok
# Server Callbacks
@impl Raxol.Core.Behaviours.BaseManager
def init_manager(opts) do
state = %{
# Store state
store_data: Keyword.get(opts, :initial_state, %{}),
subscribers: %{},
reducers: [],
middleware: [],
history: [],
max_history_size: Keyword.get(opts, :max_history_size, 50),
# Component state
# pid -> component_id
component_ids: %{},
# pid -> context
render_contexts: %{},
# pid -> component_process
component_processes: %{},
# {component_id, hook_id} -> state
hook_states: %{},
# Timer management
# {pid, key} -> timer_ref
debounce_timers: %{},
# Process monitoring
# pid -> monitor_ref
monitors: %{}
}
{:ok, state}
end
# Store handlers
@impl true
def handle_call({:dispatch, action}, _from, state) do
new_store_data = apply_reducers(action, state.store_data, state.reducers)
updated_state = %{state | store_data: new_store_data}
# Add to history if enabled
updated_state =
case state.max_history_size do
size when size > 0 -> add_to_history(updated_state, action)
_ -> updated_state
end
# Notify subscribers
notify_all_subscribers(updated_state)
{:reply, :ok, updated_state}
end
@impl true
def handle_call({:get_state, path}, _from, state) do
path_list =
case path do
p when is_list(p) -> Enum.map(p, &normalize_key/1)
p -> [normalize_key(p)]
end
# Debug logging
Log.debug("get_state path_list: #{inspect(path_list)}")
store_keys =
case state.store_data do
map when is_map(map) -> Map.keys(map)
list when is_list(list) -> Keyword.keys(list)
other -> "Unknown type: #{inspect(other)}"
end
Log.debug("store_data type: #{inspect(state.store_data)}")
Log.debug("store_data keys: #{inspect(store_keys)}")
value = get_in(state.store_data, path_list)
{:reply, value, state}
end
@impl true
def handle_call({:update_state, path, value}, _from, state) do
path_list =
case path do
p when is_list(p) -> Enum.map(p, &normalize_key/1)
p -> [normalize_key(p)]
end
new_data = put_in(state.store_data, path_list, value)
updated_state = %{state | store_data: new_data}
# Notify subscribers
notify_subscribers(path_list, value, updated_state)
{:reply, :ok, updated_state}
end
@impl true
def handle_call({:subscribe, id, path, callback, options}, _from, state) do
normalized_path =
case path do
p when is_list(p) -> Enum.map(p, &normalize_key/1)
p -> [normalize_key(p)]
end
subscription = %{
id: id,
path: normalized_path,
callback: callback,
options: options
}
new_subscribers = Map.put(state.subscribers, id, subscription)
{:reply, :ok, %{state | subscribers: new_subscribers}}
end
@impl true
def handle_call({:unsubscribe, id}, _from, state) do
new_subscribers = Map.delete(state.subscribers, id)
{:reply, :ok, %{state | subscribers: new_subscribers}}
end
@impl true
def handle_call({:register_reducer, reducer_fn}, _from, state) do
new_reducers = [reducer_fn | state.reducers]
{:reply, :ok, %{state | reducers: new_reducers}}
end
# Component/Hooks handlers
@impl true
def handle_call({:get_component_id, pid}, _from, state) do
case Map.get(state.component_ids, pid) do
nil ->
# Generate new ID and monitor the process
id = System.unique_integer([:positive, :monotonic])
state = ensure_monitored(pid, state)
component_ids = Map.put(state.component_ids, pid, id)
{:reply, id, %{state | component_ids: component_ids}}
id ->
{:reply, id, state}
end
end
@impl true
def handle_call({:set_component_id, pid, id}, _from, state) do
state = ensure_monitored(pid, state)
component_ids = Map.put(state.component_ids, pid, id)
{:reply, :ok, %{state | component_ids: component_ids}}
end
@impl true
def handle_call({:get_render_context, pid}, _from, state) do
context = Map.get(state.render_contexts, pid, %{})
{:reply, context, state}
end
@impl true
def handle_call({:set_render_context, pid, context}, _from, state) do
state = ensure_monitored(pid, state)
render_contexts = Map.put(state.render_contexts, pid, context)
{:reply, :ok, %{state | render_contexts: render_contexts}}
end
@impl true
def handle_call({:get_component_process, pid}, _from, state) do
process = Map.get(state.component_processes, pid)
{:reply, process, state}
end
@impl true
def handle_call({:set_component_process, pid, process_pid}, _from, state) do
state = ensure_monitored(pid, state)
component_processes = Map.put(state.component_processes, pid, process_pid)
{:reply, :ok, %{state | component_processes: component_processes}}
end
@impl true
def handle_call({:get_hook_state, component_id, hook_id}, _from, state) do
key = {component_id, hook_id}
value = Map.get(state.hook_states, key)
{:reply, value, state}
end
@impl true
def handle_call({:set_hook_state, component_id, hook_id, value}, _from, state) do
key = {component_id, hook_id}
hook_states = Map.put(state.hook_states, key, value)
{:reply, :ok, %{state | hook_states: hook_states}}
end
# Timer management handlers
@impl true
def handle_call(
{:schedule_debounced, pid, key, message, delay_ms},
_from,
state
) do
timer_key = {pid, key}
# Cancel existing timer if any
state =
case Map.get(state.debounce_timers, timer_key) do
nil ->
state
timer_ref ->
_ = Process.cancel_timer(timer_ref)
state
end
# Schedule new timer
timer_ref =
Process.send_after(
self(),
{:debounce_timeout, pid, key, message},
delay_ms
)
# Store timer reference
state = ensure_monitored(pid, state)
debounce_timers = Map.put(state.debounce_timers, timer_key, timer_ref)
{:reply, :ok, %{state | debounce_timers: debounce_timers}}
end
@impl true
def handle_call({:cancel_debounced, pid, key}, _from, state) do
timer_key = {pid, key}
state =
case Map.get(state.debounce_timers, timer_key) do
nil ->
state
timer_ref ->
_ = Process.cancel_timer(timer_ref)
debounce_timers = Map.delete(state.debounce_timers, timer_key)
%{state | debounce_timers: debounce_timers}
end
{:reply, :ok, state}
end
# Handle timer timeout
@impl true
def handle_info({:debounce_timeout, pid, key, message}, state) do
# Remove timer from tracking
timer_key = {pid, key}
debounce_timers = Map.delete(state.debounce_timers, timer_key)
# Send the message to the target process
case Process.alive?(pid) do
true -> send(pid, message)
false -> :ok
end
{:noreply, %{state | debounce_timers: debounce_timers}}
end
# Handle process termination
@impl true
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
# Clean up all state associated with the dead process
state = %{
state
| component_ids: Map.delete(state.component_ids, pid),
render_contexts: Map.delete(state.render_contexts, pid),
component_processes: Map.delete(state.component_processes, pid),
monitors: Map.delete(state.monitors, pid),
debounce_timers: clean_timers_for_pid(state.debounce_timers, pid)
}
# Also clean up hook states for this component if it had an ID
state =
case Map.get(state.component_ids, pid) do
nil ->
state
component_id ->
hook_states =
state.hook_states
|> Enum.reject(fn {{cid, _}, _} -> cid == component_id end)
|> Enum.into(%{})
%{state | hook_states: hook_states}
end
{:noreply, state}
end
# Private helpers
defp ensure_monitored(pid, state) do
case Map.has_key?(state.monitors, pid) do
true ->
state
false ->
ref = Process.monitor(pid)
%{state | monitors: Map.put(state.monitors, pid, ref)}
end
end
defp apply_reducers(action, data, reducers) do
Enum.reduce(reducers, data, fn reducer_fn, acc_data ->
case Raxol.Core.ErrorHandling.safe_call_with_logging(
fn -> reducer_fn.(action, acc_data) end,
"Error in reducer"
) do
{:ok, result} -> result
{:error, _} -> acc_data
end
end)
end
defp add_to_history(state, action) do
history_entry = %{
state: state.store_data,
action: action,
timestamp: System.monotonic_time(:millisecond)
}
new_history =
[history_entry | state.history]
|> Enum.take(state.max_history_size)
%{state | history: new_history}
end
defp notify_all_subscribers(state) do
Enum.each(state.subscribers, fn {_id, subscription} ->
value = get_in(state.store_data, subscription.path)
notify_subscriber(subscription, value, state)
end)
end
defp notify_subscribers(changed_path, new_value, state) do
Enum.each(state.subscribers, fn {_id, subscription} ->
case path_affects_subscription?(changed_path, subscription.path) do
true -> notify_subscriber(subscription, new_value, state)
false -> :ok
end
end)
end
defp notify_subscriber(subscription, value, _state) do
# Handle debouncing if specified
debounce_ms = Keyword.get(subscription.options, :debounce, 0)
case debounce_ms do
ms when ms > 0 ->
message = {:notify_subscriber, subscription, value}
schedule_debounced(
{:subscription, subscription.id},
message,
debounce_ms
)
_ ->
execute_callback(subscription.callback, value)
end
end
defp execute_callback(callback, value) do
Raxol.Core.ErrorHandling.safe_call_with_logging(
fn -> callback.(value) end,
"Error in subscriber callback"
)
end
defp path_affects_subscription?(changed_path, subscription_path) do
starts_with_path?(changed_path, subscription_path) or
starts_with_path?(subscription_path, changed_path)
end
defp starts_with_path?([], _), do: true
defp starts_with_path?(_, []), do: true
defp starts_with_path?([h | t1], [h | t2]), do: starts_with_path?(t1, t2)
defp starts_with_path?(_, _), do: false
defp clean_timers_for_pid(timers, pid) do
timers
|> Enum.reject(fn {{p, _}, _} -> p == pid end)
|> Enum.into(%{})
end
# Convert PIDs and other non-atom keys to strings for use with Access protocol
defp normalize_key(key) when is_pid(key), do: inspect(key)
defp normalize_key(key) when is_atom(key), do: key
defp normalize_key(key) when is_binary(key), do: key
defp normalize_key(key), do: inspect(key)
end