diff --git a/example_project/lib/example_web/live/live_form.ex b/example_project/lib/example_web/live/live_form.ex
new file mode 100644
index 0000000..bae1e43
--- /dev/null
+++ b/example_project/lib/example_web/live/live_form.ex
@@ -0,0 +1,78 @@
+defmodule ExampleWeb.LiveForm do
+ @moduledoc """
+ LiveView demo for `useLiveForm()` composable.
+ Demonstrates server-side validation with Ecto changesets and form reset on success.
+ """
+ use ExampleWeb, :live_view
+
+ # ---------------------------------------------------------------------------
+ # Inline embedded schema (no database required)
+ # ---------------------------------------------------------------------------
+
+ defmodule Schema do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ embedded_schema do
+ field(:name, :string)
+ field(:email, :string)
+ end
+
+ def changeset(schema \\ %__MODULE__{}, attrs) do
+ schema
+ |> cast(attrs, [:name, :email])
+ |> validate_required([:name, :email])
+ |> validate_format(:email, ~r/@/, message: "must contain @")
+ |> validate_length(:name, min: 2, message: "must be at least 2 characters")
+ end
+ end
+
+ defp empty_form do
+ Ecto.Changeset.cast(%Schema{name: "", email: ""}, %{}, [:name, :email])
+ |> to_form(as: "form_data")
+ end
+
+ # ---------------------------------------------------------------------------
+ # LiveView callbacks
+ # ---------------------------------------------------------------------------
+
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, form: empty_form())}
+ end
+
+ def handle_event("validate", params, socket) do
+ attrs = params["form_data"] || %{}
+
+ form =
+ Schema.changeset(%Schema{}, attrs)
+ |> to_form(as: "form_data", action: :validate)
+
+ {:noreply, assign(socket, form: form)}
+ end
+
+ def handle_event("submit", params, socket) do
+ attrs = params["form_data"] || %{}
+ changeset = Schema.changeset(%Schema{}, attrs)
+
+ if changeset.valid? do
+ # Successful submit — tell the client to reset, return a clean form.
+ {:reply, %{reset: true}, assign(socket, form: empty_form())}
+ else
+ form = changeset |> to_form(as: "form_data", action: :validate)
+ {:reply, %{}, assign(socket, form: form)}
+ end
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
Form (useLiveForm)
+
+ Server-side Ecto changeset validation with debounced change events and automatic form reset on success.
+
+ <.svelte name="FormDemo" props={%{form: @form}} socket={@socket} />
+
+ """
+ end
+end
diff --git a/example_project/lib/example_web/live/streams.ex b/example_project/lib/example_web/live/streams.ex
index ee401ac..7be7581 100644
--- a/example_project/lib/example_web/live/streams.ex
+++ b/example_project/lib/example_web/live/streams.ex
@@ -62,6 +62,21 @@ defmodule ExampleWeb.Streams do
{:noreply, stream_insert(socket, :items, updated)}
end
+ def handle_event("add_capped_item", _params, socket) do
+ new_item = %{
+ id: socket.assigns.next_id,
+ name: "Capped #{socket.assigns.next_id}",
+ description: "Stream keeps last 3 items"
+ }
+
+ socket =
+ socket
+ |> stream_insert(:items, new_item, limit: -3)
+ |> assign(:next_id, socket.assigns.next_id + 1)
+
+ {:noreply, socket}
+ end
+
def handle_event("clear_stream", _params, socket) do
{:noreply, stream(socket, :items, [], reset: true)}
end
diff --git a/example_project/lib/example_web/router.ex b/example_project/lib/example_web/router.ex
index c7df5d5..75a042d 100644
--- a/example_project/lib/example_web/router.ex
+++ b/example_project/lib/example_web/router.ex
@@ -41,6 +41,7 @@ defmodule ExampleWeb.Router do
live "/live-client-side-loading", LiveClientSideLoading
# Ecto Examples
live "/live-notes-otp", LiveNotesOtp
+ live "/live-form", LiveForm
# not referenced in app.html.heex:
live "/live-composition", LiveComposition
end
diff --git a/example_project/test/example_web/live/live_chat_test.exs b/example_project/test/example_web/live/live_chat_test.exs
index e11402c..0dfecac 100644
--- a/example_project/test/example_web/live/live_chat_test.exs
+++ b/example_project/test/example_web/live/live_chat_test.exs
@@ -33,6 +33,15 @@ defmodule ExampleWeb.LiveChatTest do
|> assert_has(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
end
+ test "useLiveConnection: reconnecting banner absent when connected (happy path)", %{session: session} do
+ session
+ |> visit("/live-chat")
+ |> fill_in(Query.css("[data-testid='chat-join-name']"), with: "Alice")
+ |> click(Query.css("[data-testid='chat-join-form'] button", text: "Join"))
+ |> assert_has(Query.css("[data-testid='chat-message-input']"))
+ |> refute_has(Query.css("[data-testid='chat-reconnecting']"))
+ end
+
test "joining with a name shows chat UI with message input", %{session: session} do
session =
session
diff --git a/example_project/test/example_web/live/live_form_test.exs b/example_project/test/example_web/live/live_form_test.exs
new file mode 100644
index 0000000..3fb8469
--- /dev/null
+++ b/example_project/test/example_web/live/live_form_test.exs
@@ -0,0 +1,98 @@
+defmodule ExampleWeb.LiveFormTest do
+ @moduledoc """
+ E2E tests for the LiveForm LiveView (/live-form).
+ Validates useLiveForm() composable: initial render, server validation,
+ and successful submit with form reset.
+ """
+ use ExampleWeb.FeatureCase, async: false
+
+ @moduletag :e2e
+
+ # ---------------------------------------------------------------------------
+ # Helpers
+ # ---------------------------------------------------------------------------
+
+ # Wait for an element with the given testid to appear (retries up to 3s).
+ defp wait_for(session, testid, attempts \\ 30)
+ defp wait_for(_session, testid, 0), do: raise("timeout waiting for [data-testid='#{testid}']")
+
+ defp wait_for(session, testid, attempts) do
+ els = session |> all(Query.css("[data-testid='#{testid}']"))
+
+ if length(els) > 0 do
+ session
+ else
+ :timer.sleep(100)
+ wait_for(session, testid, attempts - 1)
+ end
+ end
+
+ # ---------------------------------------------------------------------------
+ # Tests
+ # ---------------------------------------------------------------------------
+
+ test "initial form renders with inputs, no errors, and submit button", %{session: session} do
+ session
+ |> visit("/live-form")
+ |> wait_for("form-name-input")
+ |> assert_has(Query.css("[data-testid='form-name-input']"))
+ |> assert_has(Query.css("[data-testid='form-email-input']"))
+ |> assert_has(Query.css("[data-testid='form-submit-btn']"))
+ |> refute_has(Query.css("[data-testid='form-name-error']"))
+ |> refute_has(Query.css("[data-testid='form-email-error']"))
+ end
+
+ test "server validation: submitting empty form shows required errors", %{session: session} do
+ session =
+ session
+ |> visit("/live-form")
+ |> wait_for("form-submit-btn")
+ |> click(Query.css("[data-testid='form-submit-btn']"))
+
+ # Wait for server round-trip to deliver errors.
+ :timer.sleep(500)
+
+ session
+ |> assert_has(Query.css("[data-testid='form-name-error']"))
+ |> assert_has(Query.css("[data-testid='form-email-error']"))
+ end
+
+ test "server validation: typing invalid email shows error after debounce", %{session: session} do
+ session =
+ session
+ |> visit("/live-form")
+ |> wait_for("form-name-input")
+ |> fill_in(Query.css("[data-testid='form-name-input']"), with: "Alice")
+ |> fill_in(Query.css("[data-testid='form-email-input']"), with: "not-an-email")
+
+ # Wait for debounce (300ms) + server round-trip (allow 600ms total).
+ :timer.sleep(600)
+
+ session
+ |> assert_has(Query.css("[data-testid='form-email-error']"))
+ end
+
+ test "submitting valid form resets fields (re-submit verifies empty values)", %{session: session} do
+ session =
+ session
+ |> visit("/live-form")
+ |> wait_for("form-name-input")
+ |> fill_in(Query.css("[data-testid='form-name-input']"), with: "Alice")
+ |> fill_in(Query.css("[data-testid='form-email-input']"), with: "alice@example.com")
+ |> click(Query.css("[data-testid='form-submit-btn']"))
+
+ # Wait for first submit to complete and form to reset.
+ :timer.sleep(600)
+
+ # Submit again with now-empty fields — server should reject with validation errors.
+ # This proves the form was reset (values are empty, not "Alice" / "alice@...").
+ session =
+ session
+ |> click(Query.css("[data-testid='form-submit-btn']"))
+
+ :timer.sleep(500)
+
+ session
+ |> assert_has(Query.css("[data-testid='form-name-error']"))
+ end
+end
diff --git a/example_project/test/example_web/live/streams_test.exs b/example_project/test/example_web/live/streams_test.exs
index d14c3ca..5cf5776 100644
--- a/example_project/test/example_web/live/streams_test.exs
+++ b/example_project/test/example_web/live/streams_test.exs
@@ -103,7 +103,6 @@ defmodule ExampleWeb.StreamsTest do
|> click(Query.css("[data-testid='update-1']"))
# Still exactly 3 items — no duplication
- items = all(session, Query.css("[data-testid^='item-'][data-testid$='-1'], [data-testid='item-1'], [data-testid='item-2'], [data-testid='item-3']"))
session |> find(Query.css("[data-testid='item-1']"))
session |> find(Query.css("[data-testid='item-2']"))
session |> find(Query.css("[data-testid='item-3']"))
@@ -115,6 +114,27 @@ defmodule ExampleWeb.StreamsTest do
session |> find(Query.css("[data-testid='item-count']", text: "Items (3)"))
end
+ test "stream limit enforced: capped insert keeps only last 3 items", %{session: session} do
+ session =
+ session
+ |> visit("/streams")
+ # Initial state: items 1, 2, 3. Click "Add Capped (max 3)" once to add item 4.
+ # With limit: -3, the stream keeps the last 3 → items 2, 3, 4.
+ |> click(Query.css("[data-testid='add-capped-button']"))
+
+ # Items 2, 3, 4 are present
+ session |> find(Query.css("[data-testid='item-2']"))
+ session |> find(Query.css("[data-testid='item-3']"))
+ session |> find(Query.css("[data-testid='item-4']"))
+
+ # Item 1 was evicted by the limit
+ items_1 = all(session, Query.css("[data-testid='item-1']"))
+ assert items_1 == []
+
+ # Total item count is 3
+ session |> find(Query.css("[data-testid='item-count']", text: "Items (3)"))
+ end
+
test "sequential add and remove maintains correct state", %{session: session} do
session =
session
diff --git a/lib/live_svelte.ex b/lib/live_svelte.ex
index 0f94ac2..832ce15 100644
--- a/lib/live_svelte.ex
+++ b/lib/live_svelte.ex
@@ -348,7 +348,7 @@ defmodule LiveSvelte do
end
# Generates JSON Patch ops for a single %LiveStream{}.
- # Handles both LV 0.18.x (3-tuple inserts, no reset?) and LV 1.0.x (4-tuple inserts, has reset?/limit).
+ # Handles LV 0.18.x (3-tuple), LV 1.0.x (4-tuple), and LV ≥ 1.0.x (5-tuple with update_only).
# Op order: reset → deletes → inserts (each prepended, then list reversed).
defp generate_stream_patches(stream_name, stream) do
reset? = Map.get(stream, :reset?, false)
@@ -368,14 +368,27 @@ defmodule LiveSvelte do
|> Enum.reverse()
|> Enum.reduce(patches, fn insert, acc ->
case insert do
+ {dom_id, at, item, limit, update_only} ->
+ item_map = encode_stream_item(item, dom_id)
+
+ acc =
+ if update_only do
+ [%{op: "replace", path: "/#{stream_name}/$$#{dom_id}", value: item_map} | acc]
+ else
+ at_path = if at == -1, do: "-", else: to_string(at)
+ [%{op: "upsert", path: "/#{stream_name}/#{at_path}", value: item_map} | acc]
+ end
+
+ if limit, do: [%{op: "limit", path: "/#{stream_name}", value: limit} | acc], else: acc
+
{dom_id, at, item, limit} ->
- item_map = Map.put(item, :__dom_id, dom_id)
+ item_map = encode_stream_item(item, dom_id)
at_path = if at == -1, do: "-", else: to_string(at)
acc = [%{op: "upsert", path: "/#{stream_name}/#{at_path}", value: item_map} | acc]
if limit, do: [%{op: "limit", path: "/#{stream_name}", value: limit} | acc], else: acc
{dom_id, at, item} ->
- item_map = Map.put(item, :__dom_id, dom_id)
+ item_map = encode_stream_item(item, dom_id)
at_path = if at == -1, do: "-", else: to_string(at)
[%{op: "upsert", path: "/#{stream_name}/#{at_path}", value: item_map} | acc]
end
@@ -384,6 +397,15 @@ defmodule LiveSvelte do
Enum.reverse(patches)
end
+ # Encodes a stream item via LiveSvelte.Encoder before attaching __dom_id.
+ # Encoding MUST happen first so that @derive {only: [...]} restrictions are applied
+ # before __dom_id is added (otherwise __dom_id could be stripped by the struct encoder).
+ defp encode_stream_item(item, dom_id) do
+ item
+ |> LiveSvelte.Encoder.encode([])
+ |> Map.put(:__dom_id, dom_id)
+ end
+
# Encodes structs via LiveSvelte.Encoder so Jsonpatch can compare them.
defp encode_for_diff(struct) when is_struct(struct), do: LiveSvelte.Encoder.encode(struct)
defp encode_for_diff(other), do: other
diff --git a/test/streams_test.exs b/test/streams_test.exs
index ede7e87..efd9807 100644
--- a/test/streams_test.exs
+++ b/test/streams_test.exs
@@ -1,7 +1,15 @@
+# Struct used to test @derive {LiveSvelte.Encoder, only: [...]} in stream items.
+# Must be defined at file level (compile-time) for @derive to work.
+defmodule LiveSvelte.StreamsTest.SecretItem do
+ @derive {LiveSvelte.Encoder, only: [:id, :name]}
+ defstruct [:id, :name, :secret]
+end
+
defmodule LiveSvelte.StreamsTest do
use ExUnit.Case, async: true
alias Phoenix.LiveView.LiveStream
+ alias LiveSvelte.StreamsTest.SecretItem
# Build a %LiveStream{} compatible with library's phoenix_live_view 0.18.15.
# Inserts are 3-tuples {dom_id, at, item} in 0.18.15 and 4-tuples {dom_id, at, item, limit}
@@ -150,6 +158,28 @@ defmodule LiveSvelte.StreamsTest do
upsert_ops = Enum.filter(diff, fn op -> Enum.at(op, 0) == "upsert" end)
assert length(upsert_ops) == 2
end
+
+ test "4-tuple insert with non-nil limit emits limit op" do
+ stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "Alice"}, 10}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
+ assert limit_op != nil, "expected limit op"
+ assert Enum.at(limit_op, 1) == "/items"
+ assert Enum.at(limit_op, 2) == 10
+ end
+
+ test "4-tuple insert with nil limit does not emit limit op" do
+ stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "Alice"}, nil}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
+ assert limit_op == nil, "must not emit limit op when limit is nil"
+ end
end
describe "delete ops" do
@@ -175,6 +205,130 @@ defmodule LiveSvelte.StreamsTest do
end
end
+ describe "struct encoding in stream items" do
+ test "struct with @derive only: [...] does not expose restricted fields in upsert value" do
+ item = %SecretItem{id: 1, name: "Alice", secret: "hidden_password"}
+ stream = make_stream(inserts: [{"items-1", -1, item}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
+ assert upsert_op != nil, "expected upsert op"
+ value = Enum.at(upsert_op, 2)
+
+ assert value["id"] == 1
+ assert value["name"] == "Alice"
+ assert value["__dom_id"] == "items-1"
+ refute Map.has_key?(value, "secret"), "sensitive field must not appear in stream diff"
+ end
+
+ test "struct encoding does not lose __dom_id even when only: [...] is used" do
+ item = %SecretItem{id: 42, name: "Bob", secret: "top_secret"}
+ stream = make_stream(inserts: [{"items-42", -1, item}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
+ value = Enum.at(upsert_op, 2)
+ assert value["__dom_id"] == "items-42", "__dom_id must always be present"
+ end
+
+ test "plain map stream items still work after encoder integration" do
+ item = %{id: 99, name: "Plain", extra: "visible"}
+ stream = make_stream(inserts: [{"items-99", -1, item}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
+ value = Enum.at(upsert_op, 2)
+ assert value["id"] == 99
+ assert value["name"] == "Plain"
+ assert value["extra"] == "visible"
+ assert value["__dom_id"] == "items-99"
+ end
+ end
+
+ describe "5-tuple inserts (update_only)" do
+ test "update_only: true generates replace op at $$dom_id path" do
+ stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "Updated"}, nil, true}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ replace_op = Enum.find(diff, fn op ->
+ Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-1")
+ end)
+ assert replace_op != nil, "expected replace op at $$dom_id path for update_only: true"
+ assert Enum.at(replace_op, 1) == "/items/$$items-1"
+
+ # Must NOT generate upsert for update_only: true
+ upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
+ assert upsert_op == nil, "must NOT generate upsert when update_only: true"
+ end
+
+ test "update_only: false with 5-tuple generates upsert op" do
+ stream = make_stream(inserts: [{"items-2", -1, %{id: 2, name: "New"}, nil, false}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ upsert_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "upsert" end)
+ assert upsert_op != nil, "expected upsert op when update_only: false"
+ assert Enum.at(upsert_op, 1) == "/items/-"
+ end
+
+ test "5-tuple with limit emits limit op" do
+ stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "A"}, 5, false}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
+ assert limit_op != nil, "expected limit op"
+ assert Enum.at(limit_op, 1) == "/items"
+ assert Enum.at(limit_op, 2) == 5
+ end
+
+ test "5-tuple with nil limit does not emit limit op" do
+ stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "A"}, nil, false}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
+ assert limit_op == nil, "must not emit limit op when limit is nil"
+ end
+
+ test "5-tuple with negative limit emits negative limit op (keep last N)" do
+ stream = make_stream(inserts: [{"items-1", -1, %{id: 1, name: "A"}, -3, false}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ limit_op = Enum.find(diff, fn op -> Enum.at(op, 0) == "limit" end)
+ assert limit_op != nil, "expected limit op"
+ assert Enum.at(limit_op, 1) == "/items"
+ assert Enum.at(limit_op, 2) == -3
+ end
+
+ test "update_only: true value includes __dom_id" do
+ stream = make_stream(inserts: [{"items-5", -1, %{id: 5, name: "X"}, nil, true}])
+ assigns = base_assigns() |> Map.put(:items, stream)
+ html = render_html(assigns)
+ diff = decode_streams_diff(html)
+
+ replace_op = Enum.find(diff, fn op ->
+ Enum.at(op, 0) == "replace" && String.contains?(Enum.at(op, 1) || "", "$$items-5")
+ end)
+ value = Enum.at(replace_op, 2)
+ assert value["__dom_id"] == "items-5"
+ assert value["id"] == 5
+ end
+ end
+
describe "multiple stream assigns" do
test "two stream assigns both appear in streams-diff" do
stream_a = make_stream(inserts: [{"a-1", -1, %{id: 1, name: "A"}}])