phoenixframework / phoenix_live_view

Rich, real-time user experiences with server-rendered HTML
https://hex.pm/packages/phoenix_live_view
MIT License
6.12k stars 917 forks source link

Stream enumerated via slots, throws error only in tests #3129

Closed nathanmalishev closed 7 months ago

nathanmalishev commented 7 months ago

Environment

Actual behavior

Related to https://github.com/phoenixframework/phoenix_live_view/issues/3098 & the change here https://github.com/phoenixframework/phoenix_live_view/pull/3078.

After version 0.20.3, introduced the 'child stream error' - I believe it's also incorrectly being displayed when when wrapping a stream in a slot that's enumerated over. For example see the introduction of a wrapper component: https://github.com/nathanmalishev/async_stream_test/blob/3754e4da9e02a2dc8145ac001aeb9bb6b5f50da3/lib/lv_async_stream_test_web/live/user_live/index.html.heex#L10

And the component: https://github.com/nathanmalishev/async_stream_test/blob/3754e4da9e02a2dc8145ac001aeb9bb6b5f50da3/lib/lv_async_stream_test_web/components/wrapper.ex#L12

Results in an error like an exception was raised: ** (ArgumentError) a container with phx-update="stream" must only contain stream children with the id set to thedom_idof the stream item. Got:

Replacing this code <div :for={{tab, _i} <- Enum.with_index(@tab)}> <%= render_slot(tab) %> </div> with <%= render_slot(@tab) %>

removes the error

Expected behavior

I don't believe this should throw an error. In my particular case, i'm using a wrapper component like this to render different tabs or visible content on the screen.

Thanks

SteffenDE commented 7 months ago

This is not expected to work and will cause other problems on the client, even if it first looks like it’s working.

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#stream/4

Note: Failing to place phx-update="stream" on the immediate parent for each stream will result in broken behavior.

nathanmalishev commented 7 months ago

Okay thanks @SteffenDE , I believe the note should read differently as in the examples i still have the stream directly under the immediate parent. The only change in the producing the error throw is how content of the slot is rendered.

With the wrapper component, but without Enum.with_index

  def render(assigns) do
    ~H"""
      <%= render_slot(@tab) %>
    """
  end
end

Results in the following HTML & no test errors:

image

But with the code with the Enum.with_index

defmodule AsyncStreamTestWeb.Wrapper do
  def render(assigns) do
    ~H"""
    <div :for={{tab, _i} <- Enum.with_index(@tab)}>
      <%= render_slot(tab) %>
    </div>
    """
  end
end

Results in the HTML (extra div) & test errors:

image

But if i were to just add the extra div, results in no test errors without enum'ing over the slots

defmodule AsyncStreamTestWeb.Wrapper do
  def render(assigns) do
    ~H"""
    <div>
      <%= render_slot(t@ab) %>
    </div>
    """
  end
end
SteffenDE commented 7 months ago

Sorry, I misunderstood the placement of the wrapper. This does look like it should work and not throw an error. I'll look into it.

barkerja commented 7 months ago

I am seeing the same error in our tests for a slightly different but similar issue after upgrading to 0.20.9.

** (EXIT from #PID<0.1202.0>) an exception was raised:
  ** (ArgumentError) a container with phx-update="stream" must only contain stream children with the id set to the `dom_id` of the stream item. Got:

<div id="test-no-options" class="tw-p-2 tw-hidden last:tw-block">
  No Options Available
</div>

(phoenix_live_view 0.20.9) lib/phoenix_live_view/test/dom.ex:525: Phoenix.LiveViewTest.DOM.verify_only_stream_children!/3
(phoenix_live_view 0.20.9) lib/phoenix_live_view/test/dom.ex:426: Phoenix.LiveViewTest.DOM.apply_phx_update/4

The code:

<div id={"#{@id}-options"} phx-update="stream">
  <div id="#{@id}-no-options" class="tw-p-2 tw-hidden last:tw-block">
    No Options Available
  </div>
  <div :for={{option_id, option} <- @stream.options} id={option_id}>...</div>
</div>

If I remove the first inner div with the id of @id-no-options then the error goes away. This was working in 0.20.3

SteffenDE commented 7 months ago

@barkerja https://github.com/phoenixframework/phoenix_live_view/pull/3078#issuecomment-1944377073

barkerja commented 6 months ago

@SteffenDE just to confirm, is 0.20.10 supposed to resolve this issue? If so, I am still seeing this particular issue in tests.

SteffenDE commented 6 months ago

@barkerja I tested with the repo from @nathanmalishev and the PR fixed this problem. If you still see this please try to provide a minimal example to reproduce. You can use this as a staring point:

Application.put_env(:phoenix, 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.10"},
  {:phoenix_live_view, "~> 0.20.6"},
  {:floki, ">= 0.30.0"}
])

ExUnit.start()

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}

  def mount(_params, _session, socket) do
    socket
    |> then(&{:ok, &1})
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="/assets/phoenix/phoenix.js"></script>
    <script src="/assets/phoenix_live_view/phoenix_live_view.js"></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <%= @inner_content %>
    """
  end

  def render(assigns) do
    ~H"""
    <p>The LiveView content goes here</p>
    """
  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("/", HomeLive, :index)
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :phoenix
  socket("/live", Phoenix.LiveView.Socket)
  plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix"
  plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view"
  plug(Example.Router)
end

defmodule Example.HomeLiveTest do
  use ExUnit.Case

  import Phoenix.ConnTest
  import Plug.Conn
  import Phoenix.LiveViewTest

  @endpoint Example.Endpoint

  test "works properly" do
    conn = Phoenix.ConnTest.build_conn()

    {:ok, _view, html} = live(conn, "/")

    assert html =~ "The LiveView content goes here"
  end
end

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
ExUnit.run()
Process.sleep(:infinity)
barkerja commented 6 months ago

@SteffenDE After a lot of debugging and head scratching, I was finally able to isolate the issue to being a case of when the DOM is updated to show a hidden stream element. Here is an example:

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

Mix.install([
  {:bandit, "~> 1.0"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7.10"},
  {:phoenix_live_view, "~> 0.20.8"},
  {:floki, ">= 0.30.0"}
])

ExUnit.start()

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

defmodule Example.LiveComponent do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <div id="example">
      <button id="show-btn" phx-click="show" phx-target={@myself}>Show</button>

      <div :if={@show?}>
        <div id={"#{@id}-example"} phx-update="stream">
          <div id={"#{@id}-example-a"} class="hidden only:block">
            "Example"
          </div>
          <div :for={{example_id, example} <- @streams.stream_example} id={example_id}>
            <%= example.value %>
          </div>
        </div>
      </div>
    </div>
    """
  end

  def handle_event("show", _params, socket) do
    {:noreply, assign(socket, show?: !socket.assigns.show?)}
  end

  def mount(socket) do
    socket = 
      socket
      |> assign(:show?, false)
      |> stream_configure(:stream_example, dom_id: &"options-#{&1.id}")
      |> stream(:stream_example, [%{id: "one", value: "Example 1"}, %{id: "two", value: "Example 2"}])

    {:ok, socket}
  end
end

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

  def mount(_params, _session, socket) do
    socket
    |> then(&{:ok, &1})
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="/assets/phoenix/phoenix.js"></script>
    <script src="/assets/phoenix_live_view/phoenix_live_view.js"></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <%= @inner_content %>
    """
  end

  def render(assigns) do
    ~H"""
    <div>
      <.live_component module={Example.LiveComponent} example id="example" />
    </div>
    """
  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("/", HomeLive, :index)
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :phoenix
  socket("/live", Phoenix.LiveView.Socket)
  plug(Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix")
  plug(Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view")
  plug(Example.Router)
end

defmodule Example.HomeLiveTest do
  use ExUnit.Case

  import Phoenix.ConnTest
  import Plug.Conn
  import Phoenix.LiveViewTest

  @endpoint Example.Endpoint

  test "works properly" do
    conn = Phoenix.ConnTest.build_conn()

    {:ok, lv, html} = live(conn, "/")

    lv
    |> element("#show-btn")
    |> render_click()

    assert true
  end
end

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
ExUnit.run()
Process.sleep(:infinity)
SteffenDE commented 6 months ago

@barkerja you are indeed rendering a non stream item:

<div id={"#{@id}-example-a"} class="hidden only:block">
            "Example"
          </div>

That’s why the error is thrown. This is the same as https://github.com/phoenixframework/phoenix_live_view/issues/3129#issuecomment-1957745739. So maybe this was a misunderstanding. The specific issue was resolved, the one you are seeing with rendering a leading non stream element is currently still not supported, but planned to change. 0.20.10/0.20.11 does not allow this yet.

barkerja commented 6 months ago

So maybe this was a misunderstanding. The specific issue was resolved, the one you are seeing with rendering a leading non stream element is currently still not supported, but planned to change. 0.20.10/0.20.11 does not allow this yet.

Ah my apologies! I misunderstood and thought the recent updates also "fixed" this.