Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/live_ui/lib/live_ui/component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,7 @@ defmodule LiveUi.Component do
)

component_module = component_module(module)
# TODO: Add widget-local state when Runtime.State.widget_local_state/2 is implemented
widget_local_state = nil
widget_local_state = fetch_widget_local_state(runtime_state, widget_identity)

~H"""
<.live_component
Expand All @@ -146,6 +145,12 @@ defmodule LiveUi.Component do
"""
end

defp fetch_widget_local_state(%RuntimeState{} = runtime_state, widget_identity) do
RuntimeState.widget_local_state(runtime_state, widget_identity)
end

defp fetch_widget_local_state(_other, _widget_identity), do: %{}

defp runtime_mode(%RuntimeState{mode: mode}), do: mode
defp runtime_mode(_other), do: :native

Expand Down
18 changes: 18 additions & 0 deletions packages/live_ui/lib/live_ui/runtime/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,22 @@ defmodule LiveUi.Runtime.Error do
details: %{screen: inspect(screen), route: route, result: inspect(result)}
}
end

@spec widget_event_failed(term()) :: t()
def widget_event_failed(reason) do
%__MODULE__{
reason: :widget_event_failed,
message: "widget event handler failed",
details: %{reason: inspect(reason)}
}
end

@spec invalid_widget_event_payload() :: t()
def invalid_widget_event_payload do
%__MODULE__{
reason: :invalid_widget_event_payload,
message: "widget event payload must contain widget_component, widget_key, and widget_event",
details: nil
}
end
end
23 changes: 23 additions & 0 deletions packages/live_ui/lib/live_ui/runtime/screen_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,29 @@ defmodule LiveUi.Runtime.ScreenComponent do

@impl true
def handle_event(event, params, socket) when is_binary(event) and is_map(params) do
case event do
"widget_component_event" ->
handle_widget_component_event(params, socket)

_other ->
handle_screen_event(event, params, socket)
end
end

defp handle_widget_component_event(params, socket) do
case State.handle_widget_event(socket.assigns.runtime_state, params) do
{:ok, updated_runtime_state} ->
{:noreply,
socket
|> assign(:runtime_state, updated_runtime_state)
|> assign(:runtime_event_error, nil)}

{:error, reason} ->
{:noreply, assign(socket, :runtime_event_error, reason)}
end
end

defp handle_screen_event(event, params, socket) do
case State.handle_event(socket.assigns.runtime_state, event, params) do
{:ok, updated_runtime_state} ->
{:noreply,
Expand Down
133 changes: 131 additions & 2 deletions packages/live_ui/lib/live_ui/runtime/state.ex
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
defmodule LiveUi.Runtime.State do
@moduledoc """
Server-authoritative runtime state for mounted `live_ui` screens.

Widget-local state is bounded to specific widget component instances and
remains subordinate to server-authoritative screen or application state.
"""

alias LiveUi.Runtime.{BrowserBridge, Error}
alias LiveUi.Widget.Identity

@enforce_keys [:screen, :assigns, :mode, :event_routes, :bridge_hooks]
defstruct [:screen, :assigns, :mode, :event_routes, :bridge_hooks]
defstruct [:screen, :assigns, :mode, :event_routes, :bridge_hooks, widget_local_state: %{}]

@type mode :: :native | :canonical

@type widget_local_state :: %{optional(Identity.key()) => map()}

@type t :: %__MODULE__{
screen: module(),
assigns: map(),
mode: mode(),
event_routes: %{optional(String.t()) => atom()},
bridge_hooks: [atom()]
bridge_hooks: [atom()],
widget_local_state: widget_local_state()
}

@spec mount(module(), keyword()) :: {:ok, t()} | {:error, Error.t()}
Expand Down Expand Up @@ -74,4 +81,126 @@ defmodule LiveUi.Runtime.State do

defp normalize_defaults(_screen, defaults) when is_map(defaults), do: {:ok, defaults}
defp normalize_defaults(screen, _other), do: {:error, Error.invalid_mount_defaults(screen)}

@doc """
Gets the widget-local state for a given widget identity.

Returns the stored widget-local state map, or an empty map if none exists.
"""
@spec widget_local_state(t(), Identity.key() | Identity.t()) :: map()
def widget_local_state(%__MODULE__{} = state, widget_key) when is_binary(widget_key) do
Map.get(state.widget_local_state, widget_key, %{})
end

def widget_local_state(%__MODULE__{} = state, %Identity{} = widget_identity) do
widget_local_state(state, Identity.key(widget_identity))
end

@doc """
Puts widget-local state for a given widget identity.

Replaces any existing widget-local state for the widget with the provided map.
"""
@spec put_widget_local_state(t(), Identity.key() | Identity.t(), map()) :: t()
def put_widget_local_state(%__MODULE__{} = state, widget_key, local_state)
when is_binary(widget_key) and is_map(local_state) do
%{state | widget_local_state: Map.put(state.widget_local_state, widget_key, local_state)}
end

def put_widget_local_state(%__MODULE__{} = state, %Identity{} = widget_identity, local_state) do
put_widget_local_state(state, Identity.key(widget_identity), local_state)
end

@doc """
Updates widget-local state for a given widget identity using a function.

The function receives the current widget-local state (or an empty map if none exists)
and must return the updated widget-local state map.
"""
@spec update_widget_local_state(t(), Identity.key() | Identity.t(), (map() -> map())) :: t()
def update_widget_local_state(%__MODULE__{} = state, widget_key, fun)
when is_binary(widget_key) and is_function(fun, 1) do
current_state = Map.get(state.widget_local_state, widget_key, %{})
updated_state = fun.(current_state)
put_widget_local_state(state, widget_key, updated_state)
end

def update_widget_local_state(%__MODULE__{} = state, %Identity{} = widget_identity, fun) do
update_widget_local_state(state, Identity.key(widget_identity), fun)
end

@doc """
Deletes widget-local state for a given widget identity.

This is useful for cleanup when a widget is unmounted.
"""
@spec delete_widget_local_state(t(), Identity.key() | Identity.t()) :: t()
def delete_widget_local_state(%__MODULE__{} = state, widget_key) when is_binary(widget_key) do
%{state | widget_local_state: Map.delete(state.widget_local_state, widget_key)}
end

def delete_widget_local_state(%__MODULE__{} = state, %Identity{} = widget_identity) do
delete_widget_local_state(state, Identity.key(widget_identity))
end

@doc """
Handles a widget-targeted event by routing it to the widget component's
handle_widget_event callback and updating the widget-local state.

The event payload should contain:
- "widget_component" - The widget component module (as a string from inspect/1)
- "widget_key" - The widget identity key
- "widget_event" - The event name to route to the widget

Returns {:ok, updated_state} on success, {:error, reason} on failure.
"""
@spec handle_widget_event(t(), map()) :: {:ok, t()} | {:error, Error.t()}
def handle_widget_event(%__MODULE__{} = state, %{
"widget_component" => widget_component_str,
"widget_key" => widget_key,
"widget_event" => widget_event
}) do
with {:ok, widget_component} <- parse_widget_component(widget_component_str),
{:ok, current_local_state} <- fetch_widget_local_state(state, widget_key),
{:ok, updated_local_state} <-
apply_widget_event(widget_component, widget_event, %{}, current_local_state) do
{:ok, put_widget_local_state(state, widget_key, updated_local_state)}
else
{:error, %Error{} = error} -> {:error, error}
{:error, reason} -> {:error, Error.widget_event_failed(reason)}
end
end

def handle_widget_event(%__MODULE__{}, _other) do
{:error, Error.invalid_widget_event_payload()}
end

defp parse_widget_component(widget_component_str) when is_binary(widget_component_str) do
try do
{:ok, Code.eval_string(widget_component_str) |> elem(0)}
rescue
_ -> {:error, :invalid_widget_component}
end
end

defp fetch_widget_local_state(%__MODULE__{} = state, widget_key) do
{:ok, Map.get(state.widget_local_state, widget_key, %{})}
end

defp apply_widget_event(widget_component, event, payload, local_state) do
# Convert event string to atom for the callback
event_atom = if is_binary(event), do: String.to_existing_atom(event), else: event

if function_exported?(widget_component, :handle_widget_event, 3) do
case widget_component.handle_widget_event(event_atom, payload, local_state) do
{:ok, updated_state} when is_map(updated_state) -> {:ok, updated_state}
{:error, reason} -> {:error, reason}
other -> {:error, {:invalid_event_result, other}}
end
else
{:error, :widget_component_not_implemented}
end
rescue
ArgumentError -> {:error, :invalid_event_name}
end
end
Loading
Loading