Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A misconfigured stream causes different streams in sticky liveviews to be reset by the client. #3681

Closed
petelacey opened this issue Feb 18, 2025 · 0 comments · Fixed by #3683

Comments

@petelacey
Copy link

Environment

  • Elixir version (elixir -v):

Erlang/OTP 25 [erts-13.2.2.4] [source] [64-bit] [smp:20:20] [ds:20:20:10] [async-threads:1] [jit:ns]
Elixir 1.15.7 (compiled with Erlang/OTP 25)

  • Phoenix version (mix deps):

{:phoenix, "~> 1.7.0"},

  • Phoenix LiveView version (mix deps):

{:phoenix_live_view, "~> 1.0.4"}

  • Operating system:

NAME="Pop!_OS"
VERSION="22.04 LTS"
ID=pop
ID_LIKE="ubuntu debian"
PRETTY_NAME="Pop!_OS 22.04 LTS"
VERSION_ID="22.04"
VERSION_CODENAME=jammy
UBUNTU_CODENAME=jammy

  • Browsers you attempted to reproduce this bug on (the more the merrier):

Firefox and Chrome

  • Does the problem persist after removing "assets/node_modules" and trying again? Yes/no:

N/A

Actual behavior

Ultimately, this is issue is related to our code. I am reporting it in case it's indicative of something deeper.

  • We have two liveviews, A and B.
  • These two liveviews share a layout that has its own (nested) sticky liveview that has a stream. It's a chat client.
  • Liveview B also has a stream (an audit log), but we accidentally misconfigured it by declaring the stream twice, the second time using the reset: true option.
  • When navigating from A to B, the nested sticky liveview in the shared layout gets reset on the client, i.e. all the chat messages are removed from the DOM.

A single file app reproducing the issue follows:

Application.put_env(:sample, Example.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install([
  {:plug_cowboy, "~> 2.5"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7.0"},
  {:phoenix_live_view, "~> 1.0.4"}
])

defmodule Example.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule Example.HomeLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}
  use Phoenix.VerifiedRoutes, endpoint: Example.Endpoint, router: Example.Router
  alias Phoenix.LiveView.JS

  ### This LV does nothing but render its layout which includes a sticky LiveView

  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  defp phx_vsn, do: Application.spec(:phoenix, :vsn)
  defp lv_vsn, do: Application.spec(:phoenix_live_view, :vsn)

  def render("live.html", assigns) do
    ~H"""
    <script src={"https://cdn.jsdelivr.net/npm/phoenix@#{phx_vsn()}/priv/static/phoenix.min.js"}></script>
    <script src={"https://cdn.jsdelivr.net/npm/phoenix_live_view@#{lv_vsn()}/priv/static/phoenix_live_view.min.js"}></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>

    <h4>A layout containing a (nested) sticky LiveView with a stream.</h4>

    <%= live_render(
      @socket,
      Example.NestedLive,
      id: "sticky",
      sticky: true
    ) %>

    <hr>
    <%= @inner_content %>
    <hr>
    """
  end

  def render(assigns) do
    ~H"""
      <h3>A LiveView that does nothing but render it's layout.</h3>
      <.link navigate={~p"/away"}>Go to a different LV with a (funcky) stream</.link>
    """
  end
end

defmodule Example.AwayLive do
  use Phoenix.LiveView, layout: {Example.HomeLive, :live}
  use Phoenix.VerifiedRoutes, endpoint: Example.Endpoint, router: Example.Router
  alias Phoenix.LiveView.JS

  def mount(_params, _session, socket) do
    socket =
      socket
      |> stream(:messages, [])
      |> stream(:messages, [msg(4)], reset: true) # <--- This is the root cause
    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <h3>A liveview with a stream configured twice</h3>
    <h4>This causes the nested liveview in the layout above to be reset by the client.</h4>

    <.link navigate={~p"/"}>Go back to (the now borked) LV without a stream</.link>
    <h1>Normal Stream</h1>
    <div id="msgs-normal" phx-update="stream">
      <div
        :for={{dom_id, msg} <- @streams.messages}
        id={dom_id}
      >
        <div><%= msg.msg %></div>
      </div>
    </div>
    """
  end

  defp msg(num) do
    %{id: num, msg: num}
  end
end

defmodule Example.NestedLive do
  use Phoenix.LiveView #, layout: {__MODULE__, :live}

  def mount(_params, _session, socket) do
    {:ok, stream(socket, :messages, [msg(1), msg(2), msg(3)])}
  end

  def render(assigns) do
    ~H"""
    <div id="msgs-sticky" phx-update="stream">
      <div
        :for={{dom_id, msg} <- @streams.messages}
        id={dom_id}
      >
        <div><%= msg.msg %></div>
      </div>
    </div>
    """
  end

  defp msg(num) do
    %{id: num, msg: num}
  end
end

defmodule Example.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug(:accepts, ["html"])
  end

  scope "/", Example do
    pipe_through(:browser)

    live_session :default do
      live("/", HomeLive, :index)
      live("/away", AwayLive, :index)
    end
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)
  plug(Example.Router)
end

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)

Expected behavior

As noted, this is my bug. By removing the second stream initialization the problem goes away. However, I think that it should not have been evident in the first place. That is, stream A should not be interfering with stream B.

SteffenDE added a commit that referenced this issue Feb 19, 2025
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.
SteffenDE added a commit that referenced this issue Feb 25, 2025
* 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
SteffenDE added a commit that referenced this issue Feb 25, 2025
* 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant