Skip to content

Commit a27a25e

Browse files
authored
stream_insert update_only aka stream_update (#3573)
Fixes #2690. Relates to: https://elixirforum.com/t/add-stream-update-to-liveview-streams/68107
1 parent 5a9f0af commit a27a25e

File tree

9 files changed

+120
-14
lines changed

9 files changed

+120
-14
lines changed

assets/js/phoenix_live_view/dom_patch.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ export default class DOMPatch {
152152
}
153153
},
154154
onBeforeNodeAdded: (el) => {
155+
// don't add update_only nodes if they did not already exist
156+
if (
157+
this.getStreamInsert(el)?.updateOnly &&
158+
!this.streamComponentRestore[el.id]
159+
) {
160+
return false;
161+
}
162+
155163
DOM.maintainPrivateHooks(el, el, phxViewportTop, phxViewportBottom);
156164
this.trackBefore("added", el);
157165

@@ -382,8 +390,8 @@ export default class DOMPatch {
382390

383391
liveSocket.time("morphdom", () => {
384392
this.streams.forEach(([ref, inserts, deleteIds, reset]) => {
385-
inserts.forEach(([key, streamAt, limit]) => {
386-
this.streamInserts[key] = { ref, streamAt, limit, reset };
393+
inserts.forEach(([key, streamAt, limit, updateOnly]) => {
394+
this.streamInserts[key] = { ref, streamAt, limit, reset, updateOnly };
387395
});
388396
if (reset !== undefined) {
389397
DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, (child) => {

lib/phoenix_live_view.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1957,6 +1957,9 @@ defmodule Phoenix.LiveView do
19571957
here as well in order to be enforced. See `stream/4` for more information on
19581958
limiting streams.
19591959
1960+
* `:update_only` - A boolean to only update the item in the stream. If the item does not
1961+
exist on the client, it will not be inserted. Defaults to `false`.
1962+
19601963
## Examples
19611964
19621965
Imagine you define a stream on mount with a single item:
@@ -2006,8 +2009,9 @@ defmodule Phoenix.LiveView do
20062009
def stream_insert(%Socket{} = socket, name, item, opts \\ []) do
20072010
at = Keyword.get(opts, :at, -1)
20082011
limit = Keyword.get(opts, :limit)
2012+
update_only = Keyword.get(opts, :update_only, false)
20092013

2010-
update_stream(socket, name, &LiveStream.insert_item(&1, item, at, limit))
2014+
update_stream(socket, name, &LiveStream.insert_item(&1, item, at, limit, update_only))
20112015
end
20122016

20132017
@doc """

lib/phoenix_live_view/engine.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ defmodule Phoenix.LiveView.Comprehension do
8484

8585
@doc false
8686
def __annotate__(comprehension, %Phoenix.LiveView.LiveStream{} = stream) do
87-
inserts = for {id, at, _item, limit} <- stream.inserts, do: [id, at, limit]
87+
inserts =
88+
for {id, at, _item, limit, update_only} <- stream.inserts, do: [id, at, limit, update_only]
89+
8890
data = [stream.ref, inserts, stream.deletes]
8991

9092
if stream.reset? do

lib/phoenix_live_view/live_stream.ex

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ defmodule Phoenix.LiveView.LiveStream do
2525
# with manually calling stream_insert multiple times, as stream_insert prepends.
2626
items_list =
2727
for item <- items, reduce: [] do
28-
items -> [{dom_id.(item), -1, item, opts[:limit]} | items]
28+
items -> [{dom_id.(item), -1, item, opts[:limit], opts[:update_only]} | items]
2929
end
3030

3131
%LiveStream{
@@ -64,10 +64,10 @@ defmodule Phoenix.LiveView.LiveStream do
6464
%{stream | deletes: [dom_id | stream.deletes]}
6565
end
6666

67-
def insert_item(%LiveStream{} = stream, item, at, limit) do
67+
def insert_item(%LiveStream{} = stream, item, at, limit, update_only) do
6868
item_id = stream.dom_id.(item)
6969

70-
%{stream | inserts: [{item_id, at, item, limit} | stream.inserts]}
70+
%{stream | inserts: [{item_id, at, item, limit, update_only} | stream.inserts]}
7171
end
7272

7373
defimpl Enumerable, for: LiveStream do
@@ -81,7 +81,7 @@ defmodule Phoenix.LiveView.LiveStream do
8181
# before rendering; we also remove duplicates to only use the most recent
8282
# inserts, which, as the items are reversed, are first
8383
{inserts, _} =
84-
for {id, _, _, _} = insert <- stream.inserts, reduce: {[], MapSet.new()} do
84+
for {id, _, _, _, _} = insert <- stream.inserts, reduce: {[], MapSet.new()} do
8585
{inserts, ids} ->
8686
if MapSet.member?(ids, id) do
8787
# skip duplicates
@@ -106,7 +106,7 @@ defmodule Phoenix.LiveView.LiveStream do
106106
defp do_reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &do_reduce(list, &1, fun)}
107107
defp do_reduce([], {:cont, acc}, _fun), do: {:done, acc}
108108

109-
defp do_reduce([{dom_id, _at, item, _limit} | tail], {:cont, acc}, fun) do
109+
defp do_reduce([{dom_id, _at, item, _limit, _update_only} | tail], {:cont, acc}, fun) do
110110
do_reduce(tail, fun.({dom_id, item}, acc), fun)
111111
end
112112

lib/phoenix_live_view/test/tree_dom.ex

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,13 @@ defmodule Phoenix.LiveViewTest.TreeDOM do
425425

426426
streamInserts =
427427
Enum.reduce(streams, %{}, fn %{ref: ref, inserts: inserts}, acc ->
428-
Enum.reduce(inserts, acc, fn [id, stream_at, limit], acc ->
429-
Map.put(acc, id, %{ref: ref, stream_at: stream_at, limit: limit})
428+
Enum.reduce(inserts, acc, fn [id, stream_at, limit, update_only], acc ->
429+
Map.put(acc, id, %{
430+
ref: ref,
431+
stream_at: stream_at,
432+
limit: limit,
433+
update_only: update_only
434+
})
430435
end)
431436
end)
432437

@@ -457,6 +462,10 @@ defmodule Phoenix.LiveViewTest.TreeDOM do
457462
# update stream item in place
458463
List.replace_at(acc, current_index, set_attr(node, "data-phx-stream", insert.ref))
459464

465+
insert[:update_only] ->
466+
# skip item if it is not already in the DOM
467+
acc
468+
460469
true ->
461470
# stream item to be inserted at specific position
462471
List.insert_at(acc, insert.stream_at, set_attr(node, "data-phx-stream", insert.ref))

test/e2e/tests/streams.spec.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,3 +921,35 @@ test("JS commands are applied when re-joining", async ({ page }) => {
921921
// should still be hidden
922922
await expect(page.locator("#users-1")).toBeHidden();
923923
});
924+
925+
test("update_only", async ({ page }) => {
926+
await page.goto("/stream/reset");
927+
await syncLV(page);
928+
929+
expect(await listItems(page)).toEqual([
930+
{ id: "items-a", text: "A" },
931+
{ id: "items-b", text: "B" },
932+
{ id: "items-c", text: "C" },
933+
{ id: "items-d", text: "D" },
934+
]);
935+
936+
await page.getByRole("button", { name: "Add E (update only)" }).click();
937+
await syncLV(page);
938+
939+
expect(await listItems(page)).toEqual([
940+
{ id: "items-a", text: "A" },
941+
{ id: "items-b", text: "B" },
942+
{ id: "items-c", text: "C" },
943+
{ id: "items-d", text: "D" },
944+
]);
945+
946+
await page.getByRole("button", { name: "Update C (update only)" }).click();
947+
await syncLV(page);
948+
949+
expect(await listItems(page)).toEqual([
950+
{ id: "items-a", text: "A" },
951+
{ id: "items-b", text: "B" },
952+
{ id: "items-c", text: expect.stringMatching(/C .*/) },
953+
{ id: "items-d", text: "D" },
954+
]);
955+
});

test/phoenix_live_view/integrations/stream_test.exs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,35 @@ defmodule Phoenix.LiveView.StreamTest do
867867
]
868868
end
869869

870+
test "update_only", %{conn: conn} do
871+
{:ok, lv, html} = live(conn, "/stream/reset")
872+
873+
assert ul_list_children(html) == [
874+
{"items-a", "A"},
875+
{"items-b", "B"},
876+
{"items-c", "C"},
877+
{"items-d", "D"}
878+
]
879+
880+
html = assert lv |> element("button", "Add E (update only)") |> render_click()
881+
882+
assert ul_list_children(html) == [
883+
{"items-a", "A"},
884+
{"items-b", "B"},
885+
{"items-c", "C"},
886+
{"items-d", "D"}
887+
]
888+
889+
html = assert lv |> element("button", "Update C (update only)") |> render_click()
890+
891+
assert [
892+
{"items-a", "A"},
893+
{"items-b", "B"},
894+
{"items-c", "C " <> _},
895+
{"items-d", "D"}
896+
] = ul_list_children(html)
897+
end
898+
870899
defp assert_pruned_stream(lv) do
871900
stream = StreamLive.run(lv, fn socket -> {:reply, socket.assigns.streams.users, socket} end)
872901
assert stream.inserts == []

test/phoenix_live_view/live_stream_test.exs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,20 @@ defmodule Phoenix.LiveView.LiveStreamTest do
1313

1414
test "default dom_id" do
1515
stream = LiveStream.new(:users, 0, [%{id: 1}, %{id: 2}], [])
16-
assert stream.inserts == [{"users-2", -1, %{id: 2}, nil}, {"users-1", -1, %{id: 1}, nil}]
16+
17+
assert stream.inserts == [
18+
{"users-2", -1, %{id: 2}, nil, nil},
19+
{"users-1", -1, %{id: 1}, nil, nil}
20+
]
1721
end
1822

1923
test "custom dom_id" do
2024
stream = LiveStream.new(:users, 0, [%{name: "u1"}, %{name: "u2"}], dom_id: &"u-#{&1.name}")
21-
assert stream.inserts == [{"u-u2", -1, %{name: "u2"}, nil}, {"u-u1", -1, %{name: "u1"}, nil}]
25+
26+
assert stream.inserts == [
27+
{"u-u2", -1, %{name: "u2"}, nil, nil},
28+
{"u-u1", -1, %{name: "u1"}, nil, nil}
29+
]
2230
end
2331

2432
test "default dom_id without struct or map with :id" do
@@ -31,7 +39,7 @@ defmodule Phoenix.LiveView.LiveStreamTest do
3139

3240
test "inserts are deduplicated (last insert wins)" do
3341
assert stream = LiveStream.new(:users, 0, [%{id: 1}, %{id: 2}], [])
34-
stream = LiveStream.insert_item(stream, %{id: 2, updated: true}, -1, nil)
42+
stream = LiveStream.insert_item(stream, %{id: 2, updated: true}, -1, nil, nil)
3543
stream = %{stream | consumable?: true}
3644
assert Enum.to_list(stream) == [{"users-1", %{id: 1}}, {"users-2", %{id: 2, updated: true}}]
3745
end

test/support/live_views/streams.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,8 @@ defmodule Phoenix.LiveViewTest.Support.StreamResetLive do
359359
<button phx-click="delete-insert-existing-at-one">Delete C and insert at 1</button>
360360
<button phx-click="prepend-existing">Prepend C</button>
361361
<button phx-click="append-existing">Append C</button>
362+
<button phx-click="new-update-only">Add E (update only)</button>
363+
<button phx-click="existing-update-only">Update C (update only)</button>
362364
"""
363365
end
364366

@@ -490,6 +492,18 @@ defmodule Phoenix.LiveViewTest.Support.StreamResetLive do
490492
at: -1
491493
)}
492494
end
495+
496+
def handle_event("new-update-only", _, socket) do
497+
{:noreply, stream_insert(socket, :items, %{id: "e", name: "E"}, at: -1, update_only: true)}
498+
end
499+
500+
def handle_event("existing-update-only", _, socket) do
501+
{:noreply,
502+
stream_insert(socket, :items, %{id: "c", name: "C #{System.unique_integer()}"},
503+
at: -1,
504+
update_only: true
505+
)}
506+
end
493507
end
494508

495509
defmodule Phoenix.LiveViewTest.Support.StreamResetLCLive do

0 commit comments

Comments
 (0)