From 62c0653ccc50dd2756e19301f60826b64c476a8e Mon Sep 17 00:00:00 2001 From: martosaur Date: Fri, 13 Mar 2026 14:13:00 -0700 Subject: [PATCH 1/2] Bump Elixir version --- .tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index 0800bec..3a55fed 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.19.0-rc.0-otp-27 +elixir 1.19.5-otp-27 erlang 27.2.4 \ No newline at end of file From 200d089b33da4a1c2f87f694bc6698a3347eecfa Mon Sep 17 00:00:00 2001 From: martosaur Date: Fri, 13 Mar 2026 15:20:21 -0700 Subject: [PATCH 2/2] Replace `message_builder` with `diff_function` and store encoded state in socket assigns to improve performance --- lib/live_state/channel.ex | 96 ++++++++++++++----------------- lib/live_state/message_builder.ex | 29 ---------- test/live_state_channel_test.exs | 24 ++++---- test/message_builder_test.exs | 49 ---------------- test/support/patch_channel.ex | 2 +- test/support/todo_channel.ex | 33 +++-------- 6 files changed, 65 insertions(+), 168 deletions(-) delete mode 100644 lib/live_state/message_builder.ex delete mode 100644 test/message_builder_test.exs diff --git a/lib/live_state/channel.ex b/lib/live_state/channel.ex index 80465d3..5020a68 100644 --- a/lib/live_state/channel.ex +++ b/lib/live_state/channel.ex @@ -5,10 +5,10 @@ defmodule LiveState.Channel do ``` use LiveState.Channel, web_module: MyAppWeb, max_version: 100 ``` - - `message_builder` optional, defaults to `{LiveState.MessageBuilder, ignore_keys: [:__meta__]}`. If - set, should be a tuple whose first element is a module that defines `update_state_message/4` and `new_state_message/3` and - and second element contains any options. Options are passed as final arg to both functions when invoked. - See `LiveState.MessageBuilder` for details + - `encoder_opts` optional, defaults to `[ignore_keys: [:__meta__]]`. Options passed to + `LiveState.Encoder.encode/2` when encoding state for diffing. + - `diff_function` optional, defaults to `&JSONDiff.diff/2`. A function that takes two encoded + states (old, new) and returns a JSON patch list. - `max_version` optional, defaults to 1000. This is the maximum version number, after which it will reset to 0 and begin incrementing again. Version numbers are used to detect a patch message arriving out of order. If such a condition is detected by `phx-live-state` a new copy of state is requested. @@ -16,6 +16,7 @@ defmodule LiveState.Channel do import Phoenix.Socket alias LiveState.Event + alias LiveState.Encoder @doc """ Returns the initial application state. Called just after connection @@ -94,16 +95,8 @@ defmodule LiveState.Channel do @dialyzer {:nowarn_function, update_state: 2} @behaviour unquote(__MODULE__) - @message_builder (case unquote( - Keyword.get( - opts, - :message_builder, - {LiveState.MessageBuilder, ignore_keys: [:__meta__]} - ) - ) do - {mod, opts} when is_atom(mod) -> {mod, opts} - mod when is_atom(mod) -> {mod, []} - end) + @encoder_opts unquote(Keyword.get(opts, :encoder_opts, [ignore_keys: [:__meta__]])) + @diff_function unquote(Keyword.get(opts, :diff_function, &JSONDiff.diff/2)) @max_version unquote(Keyword.get(opts, :max_version, 1000)) def join(channel, payload, socket) do @@ -131,9 +124,13 @@ defmodule LiveState.Channel do end defp initialize_state(state, socket) do - {event_name, message} = build_new_state_message(state, 0) - push(socket, event_name, message) - socket |> assign(state_key(), state) |> assign(state_version_key(), 0) + encoded = Encoder.encode(state, @encoder_opts) + + socket + |> assign(state_key(), state) + |> assign(state_version_key(), 0) + |> assign(state_cache_key(), encoded) + |> tap(&push_state_change/1) end def handle_info(message, %{assigns: assigns} = socket) do @@ -145,8 +142,8 @@ defmodule LiveState.Channel do |> maybe_handle_reply(socket) end - def handle_in("lvs_refresh", _payload, %{assigns: assigns} = socket) do - push_state_change(socket, Map.get(assigns, state_key()), Map.get(assigns, state_version_key())) + def handle_in("lvs_refresh", _payload, socket) do + push_state_change(socket) {:noreply, socket} end @@ -170,37 +167,33 @@ defmodule LiveState.Channel do def state_version_key, do: :version + def state_cache_key, do: :_encoded_state_cache + def handle_message(_message, state), do: {:noreply, state} def handle_event(_message, _payload, state), do: {:noreply, state} defp update_state(%{assigns: assigns} = socket, new_state) do - current_state = Map.get(assigns, state_key()) - new_state_version = increment_version(assigns) - {event_name, message} = build_update_message(current_state, new_state, new_state_version) - push(socket, event_name, message) - - {:noreply, - socket - |> assign(state_key(), new_state) - |> assign(state_version_key(), new_state_version)} - end - - defp build_update_message(current_state, new_state, version) do - {mod, opts} = @message_builder - if function_exported?(mod, :update_state_message, 4) do - mod.update_state_message(current_state, new_state, version, opts) - else - mod.update_state_message(current_state, new_state, version) - end - end - - defp build_new_state_message(new_state, version) do - {mod, opts} = @message_builder - if function_exported?(mod, :new_state_message, 3) do - mod.new_state_message(new_state, version, opts) - else - mod.new_state_message(new_state, version) + old_encoded = Map.fetch!(assigns, state_cache_key()) + new_encoded = Encoder.encode(new_state, @encoder_opts) + patch = @diff_function.(old_encoded, new_encoded) + + case patch do + [] -> + {:noreply, + socket + |> assign(state_key(), new_state) + |> assign(state_cache_key(), new_encoded)} + + _ -> + new_state_version = increment_version(assigns) + push(socket, "state:patch", %{patch: patch, version: new_state_version}) + + {:noreply, + socket + |> assign(state_key(), new_state) + |> assign(state_version_key(), new_state_version) + |> assign(state_cache_key(), new_encoded)} end end @@ -247,15 +240,10 @@ defmodule LiveState.Channel do push(socket, "error", error) end - defp push_state_change(socket, state, version) do - payload = %{} |> Map.put(state_key(), state) |> Map.put(state_version_key(), version) - push(socket, "state:change", payload) - end - - defp push_json_patch(socket, current_state, new_state, version) do - push(socket, "state:patch", %{ - patch: JSONDiff.diff(current_state, new_state), - version: version + defp push_state_change(%{assigns: assigns} = socket) do + push(socket, "state:change", %{ + state: Map.fetch!(assigns, state_cache_key()), + version: Map.fetch!(assigns, state_version_key()) }) end diff --git a/lib/live_state/message_builder.ex b/lib/live_state/message_builder.ex deleted file mode 100644 index b2d5cb8..0000000 --- a/lib/live_state/message_builder.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule LiveState.MessageBuilder do - - @moduledoc """ - This is the default message builder for LiveState. It will send [json_patch](https://datatracker.ietf.org/doc/html/rfc6902) - messages for state updates. The Elixir terms are compared using the `JSONDiff` library to create the - JSON patch. Because JSONDiff is not aware of any impls of the JSON Encoder protocol, the patch - may or may not match the JSON encoding in certain cases. Best effort has been made to handle common - cases such ecto schemas and DateTime. To gain further control over this process, you - may implement the `LiveState.Encoder` protocol which will allow you to define a pre-diff representation. - - For a slower, but potentially more correct approach to the problem, there is a [branch](https://github.com/launchscout/live_state/tree/rust-json-diff) containing - an alternate json diff implemenation in rust. - - """ - alias LiveState.Encoder - - def update_state_message(old_state, new_state, version, opts \\ []) do - old_state_encoded = Encoder.encode(old_state, opts) - new_state_encoded = Encoder.encode(new_state, opts) - {"state:patch", %{ - patch: JSONDiff.diff(old_state_encoded, new_state_encoded), - version: version - }} - end - - def new_state_message(new_state, version, opts \\ []) do - {"state:change", %{state: Encoder.encode(new_state, opts), version: version}} - end -end diff --git a/test/live_state_channel_test.exs b/test/live_state_channel_test.exs index 86113b9..b96051f 100644 --- a/test/live_state_channel_test.exs +++ b/test/live_state_channel_test.exs @@ -29,7 +29,9 @@ defmodule LiveState.LiveStateChannelTest do test "handle_event", %{socket: socket} do send_event(socket, "add_todo", %{"description" => "Do the thing"}) - assert_state_change %{todos: [%{"description" => "Do the thing"}]} + assert_state_patch [ + %{"op" => "add", "path" => "/todos/0", "value" => %{"description" => "Do the thing"}} + ] end test "handle_message" do @@ -41,27 +43,27 @@ defmodule LiveState.LiveStateChannelTest do assert_push("reply_event", %{foo: "bar"}) - assert_push("state:change", %{ - state: %{todos: [%{"description" => "And another one"}]}, + assert_push("state:patch", %{ + patch: [ + %{"op" => "add", "path" => "/todos/0", "value" => %{"description" => "And another one"}} + ], version: 1 }) end test "handle_event with reply", %{socket: socket} do push(socket, "lvs_evt:add_todo_with_one_reply", %{"description" => "Do the thing"}) - assert_push("state:change", %{ - state: %{todos: [%{"description" => "Do the thing"}]}, - version: 1 - }) + assert_state_patch [ + %{"op" => "add", "path" => "/todos/0", "value" => %{"description" => "Do the thing"}} + ] assert_push("reply_event", %{foo: "bar"}) end test "handle_event with multi event reply", %{socket: socket} do push(socket, "lvs_evt:add_todo_with_two_replies", %{"description" => "Do the thing"}) - assert_push("state:change", %{ - state: %{todos: [%{"description" => "Do the thing"}]}, - version: 1 - }) + assert_state_patch [ + %{"op" => "add", "path" => "/todos/0", "value" => %{"description" => "Do the thing"}} + ] assert_push("reply_event1", %{foo: "bar"}) assert_push("reply_event2", %{bing: "baz"}) end diff --git a/test/message_builder_test.exs b/test/message_builder_test.exs deleted file mode 100644 index 92d0373..0000000 --- a/test/message_builder_test.exs +++ /dev/null @@ -1,49 +0,0 @@ -defmodule LiveState.Test.MessageBuilderTest do - use ExUnit.Case - - alias LiveState.MessageBuilder - - test "update_state_message" do - assert {"state:patch", - %{ - patch: [%{"op" => "replace", "path" => "/foo", "value" => "baz"}], - version: 1 - }} = MessageBuilder.update_state_message(%{foo: "bar"}, %{foo: "baz"}, 1) - end - - test "build_state_change_message" do - assert {"state:change", - %{ - state: %{foo: "baz"}, - version: 1 - }} = - MessageBuilder.new_state_message(%{foo: "baz", wut: "bar"}, 1, ignore_keys: [:wut]) - end - - test "update_state_message with datetime" do - assert {"state:patch", - %{ - patch: [%{"op" => "replace", "path" => "/time", "value" => _value}], - version: 1 - }} = - MessageBuilder.update_state_message( - %{time: DateTime.utc_now()}, - %{time: DateTime.utc_now() |> DateTime.add(10, :minute)}, - 1 - ) - end - - test "update_state_message with date" do - assert {"state:patch", - %{ - patch: [%{"op" => "replace", "path" => "/date", "value" => _value}], - version: 1 - }} = - MessageBuilder.update_state_message( - %{date: Date.utc_today()}, - %{date: Date.utc_today() |> Date.add(10)}, - 1 - ) - end - -end diff --git a/test/support/patch_channel.ex b/test/support/patch_channel.ex index 8d17c9f..e33b360 100644 --- a/test/support/patch_channel.ex +++ b/test/support/patch_channel.ex @@ -1,7 +1,7 @@ defmodule LiveState.Test.PatchChannel do @moduledoc false - use LiveState.Channel, web_module: LiveState.Test.Web, json_patch: true, max_version: 10 + use LiveState.Channel, web_module: LiveState.Test.Web, max_version: 10 def init(_channel, _params, _socket) do {:ok, %{foo: "bar"}} diff --git a/test/support/todo_channel.ex b/test/support/todo_channel.ex index d1026d3..6d27c20 100644 --- a/test/support/todo_channel.ex +++ b/test/support/todo_channel.ex @@ -1,22 +1,7 @@ defmodule LiveState.Test.TodoChannel do @moduledoc false - alias LiveState.MessageBuilder - - defmodule MyMessageBuilder do - @moduledoc false - def update_state_message(_current_state, new_state, version), - do: MessageBuilder.new_state_message(new_state, version) - - def new_state_message(new_state, version), - do: MessageBuilder.new_state_message(new_state, version) - end - - alias LiveState.MessageBuilder - - use LiveState.Channel, - web_module: LiveState.Test.Web, - message_builder: MyMessageBuilder + use LiveState.Channel, web_module: LiveState.Test.Web alias LiveState.Event @@ -27,26 +12,26 @@ defmodule LiveState.Test.TodoChannel do end @impl true - def handle_event("add_todo", todo, %{todos: todos}) do - {:noreply, %{todos: [todo | todos]}} + def handle_event("add_todo", todo, %{todos: todos} = state) do + {:noreply, %{state | todos: [todo | todos]}} end @impl true - def handle_event("add_todo_with_one_reply", todo, %{todos: todos}) do - {:reply, %Event{name: "reply_event", detail: %{foo: "bar"}}, %{todos: [todo | todos]}} + def handle_event("add_todo_with_one_reply", todo, %{todos: todos} = state) do + {:reply, %Event{name: "reply_event", detail: %{foo: "bar"}}, %{state | todos: [todo | todos]}} end @impl true - def handle_event("add_todo_with_two_replies", todo, %{todos: todos}) do + def handle_event("add_todo_with_two_replies", todo, %{todos: todos} = state) do {:reply, [ %Event{name: "reply_event1", detail: %{foo: "bar"}}, %Event{name: "reply_event2", detail: %{bing: "baz"}} - ], %{todos: [todo | todos]}} + ], %{state | todos: [todo | todos]}} end @impl true - def handle_message({:todo_added, todo}, %{todos: todos}) do - {:reply, %Event{name: "reply_event", detail: %{foo: "bar"}}, %{todos: [todo | todos]}} + def handle_message({:todo_added, todo}, %{todos: todos} = state) do + {:reply, %Event{name: "reply_event", detail: %{foo: "bar"}}, %{state | todos: [todo | todos]}} end end