Skip to content

Commit c1658d3

Browse files
authored
fix streams in sticky LV being reset when same ref (#3683)
* fix streams in sticky LV being reset when same ref Fixes #3681. Child LiveViews would use the same data-phx-stream-ref, so it could happen that they were cleared unexpectedly because we did not always check the owner of the stream element when pruning. There was another issue at play though: because we used assign_new to set the streams, nested LiveViews would copy a parent's streams, instead of creating a fresh one. This would lead to mixed up streams in the dead render. * fix test on latest elixir * add test
1 parent 573141b commit c1658d3

File tree

5 files changed

+139
-11
lines changed

5 files changed

+139
-11
lines changed

assets/js/phoenix_live_view/dom_patch.js

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -295,14 +295,8 @@ export default class DOMPatch {
295295
// clear stream items from the dead render if they are not inserted again
296296
if(isJoinPatch){
297297
DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`, el => {
298-
// make sure to only remove elements owned by the current view
299-
// see https://github.com/phoenixframework/phoenix_live_view/issues/3047
300-
this.liveSocket.owner(el, (view) => {
301-
if(view === this.view){
302-
Array.from(el.children).forEach(child => {
303-
this.removeStreamChildElement(child)
304-
})
305-
}
298+
Array.from(el.children).forEach(child => {
299+
this.removeStreamChildElement(child)
306300
})
307301
})
308302
}
@@ -359,6 +353,11 @@ export default class DOMPatch {
359353
}
360354

361355
removeStreamChildElement(child){
356+
// make sure to only remove elements owned by the current view
357+
// see https://github.com/phoenixframework/phoenix_live_view/issues/3047
358+
// and https://github.com/phoenixframework/phoenix_live_view/issues/3681
359+
if(!this.view.ownsElement(child)){ return }
360+
362361
// we need to store the node if it is actually re-added in the same patch
363362
// we do NOT want to execute phx-remove, we do NOT want to call onNodeDiscarded
364363
if(this.streamInserts[child.id]){

lib/phoenix_live_view.ex

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1888,9 +1888,19 @@ defmodule Phoenix.LiveView do
18881888
end
18891889

18901890
defp ensure_streams(%Socket{} = socket) do
1891-
Phoenix.LiveView.Utils.assign_new(socket, :streams, fn ->
1892-
%{__ref__: 0, __changed__: MapSet.new(), __configured__: %{}}
1893-
end)
1891+
# don't use assign_new here because we DON'T want to copy parent streams
1892+
# during the dead render of nested or sticky LiveViews
1893+
case socket.assigns do
1894+
%{streams: _} ->
1895+
socket
1896+
1897+
_ ->
1898+
Phoenix.LiveView.Utils.assign(socket, :streams, %{
1899+
__ref__: 0,
1900+
__changed__: MapSet.new(),
1901+
__configured__: %{}
1902+
})
1903+
end
18941904
end
18951905

18961906
@doc """
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
defmodule Phoenix.LiveViewTest.E2E.Issue3681Live do
2+
# https://github.com/phoenixframework/phoenix_live_view/issues/3681
3+
4+
use Phoenix.LiveView, layout: {__MODULE__, :live}
5+
6+
alias Phoenix.LiveView.JS
7+
8+
def mount(_params, _session, socket) do
9+
{:ok, socket}
10+
end
11+
12+
def render("live.html", assigns) do
13+
~H"""
14+
{apply(Phoenix.LiveViewTest.E2E.Layout, :render, [
15+
"live.html",
16+
Map.put(assigns, :inner_content, [])
17+
])}
18+
19+
{live_render(
20+
@socket,
21+
Phoenix.LiveViewTest.E2E.Issue3681.StickyLive,
22+
id: "sticky",
23+
sticky: true
24+
)}
25+
26+
<hr />
27+
{@inner_content}
28+
<hr />
29+
"""
30+
end
31+
32+
def render(assigns) do
33+
~H"""
34+
<h3>A LiveView that does nothing but render it's layout.</h3>
35+
<.link navigate="/issues/3681/away">Go to a different LV with a (funcky) stream</.link>
36+
"""
37+
end
38+
end
39+
40+
defmodule Phoenix.LiveViewTest.E2E.Issue3681.AwayLive do
41+
use Phoenix.LiveView, layout: {Phoenix.LiveViewTest.E2E.Issue3681Live, :live}
42+
43+
alias Phoenix.LiveView.JS
44+
45+
def mount(_params, _session, socket) do
46+
socket =
47+
socket
48+
|> stream(:messages, [])
49+
# <--- This is the root cause
50+
|> stream(:messages, [msg(4)], reset: true)
51+
52+
{:ok, socket}
53+
end
54+
55+
def render(assigns) do
56+
~H"""
57+
<h3>A liveview with a stream configured twice</h3>
58+
<h4>This causes the nested liveview in the layout above to be reset by the client.</h4>
59+
60+
<.link navigate="/issues/3681">Go back to (the now borked) LV without a stream</.link>
61+
<h1>Normal Stream</h1>
62+
<div id="msgs-normal" phx-update="stream">
63+
<div :for={{dom_id, msg} <- @streams.messages} id={dom_id}>
64+
<div>{msg.msg}</div>
65+
</div>
66+
</div>
67+
"""
68+
end
69+
70+
defp msg(num) do
71+
%{id: num, msg: num}
72+
end
73+
end
74+
75+
defmodule Phoenix.LiveViewTest.E2E.Issue3681.StickyLive do
76+
use Phoenix.LiveView, layout: false
77+
78+
def mount(_params, _session, socket) do
79+
{:ok, stream(socket, :messages, [msg(1), msg(2), msg(3)])}
80+
end
81+
82+
def render(assigns) do
83+
~H"""
84+
<div id="msgs-sticky" phx-update="stream">
85+
<div :for={{dom_id, msg} <- @streams.messages} id={dom_id}>
86+
<div>{msg.msg}</div>
87+
</div>
88+
</div>
89+
"""
90+
end
91+
92+
defp msg(num) do
93+
%{id: num, msg: num}
94+
end
95+
end

test/e2e/test_helper.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ defmodule Phoenix.LiveViewTest.E2E.Router do
191191
live "/3169", Issue3169Live
192192
live "/3530", Issue3530Live
193193
live "/3647", Issue3647Live
194+
live "/3681", Issue3681Live
195+
live "/3681/away", Issue3681.AwayLive
194196
end
195197
end
196198

test/e2e/tests/issues/3681.spec.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const {test, expect} = require("../../test-fixtures")
2+
const {syncLV} = require("../../utils")
3+
4+
// https://github.com/phoenixframework/phoenix_live_view/issues/3681
5+
test("streams in nested LiveViews are not reset when they share the same stream ref", async ({page, request}) => {
6+
// this was a separate bug where child LiveViews accidentally shared the parent streams
7+
// check that the initial render does not contain the messages-4 element twice
8+
expect((await (await request.get("/issues/3681/away")).text()).match(/messages-4/g).length).toBe(1)
9+
10+
await page.goto("/issues/3681")
11+
await syncLV(page)
12+
13+
await expect(page.locator("#msgs-sticky > div")).toHaveCount(3)
14+
15+
await page.getByRole("link", {name: "Go to a different LV with a (funcky) stream"}).click()
16+
await syncLV(page)
17+
await expect(page.locator("#msgs-sticky > div")).toHaveCount(3)
18+
19+
await page.getByRole("link", {name: "Go back to (the now borked) LV without a stream"}).click()
20+
await syncLV(page)
21+
await expect(page.locator("#msgs-sticky > div")).toHaveCount(3)
22+
})

0 commit comments

Comments
 (0)