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

"No component for CID" error because of duplicate live components #3650

Closed
dvic opened this issue Jan 24, 2025 · 8 comments · Fixed by #3653
Closed

"No component for CID" error because of duplicate live components #3650

dvic opened this issue Jan 24, 2025 · 8 comments · Fixed by #3653
Labels

Comments

@dvic
Copy link
Contributor

dvic commented Jan 24, 2025

Environment

  • Elixir version (elixir -v): 1.18.1
  • Phoenix version (mix deps): 1.7.0
  • Phoenix LiveView version (mix deps): 1.0.2 and main
  • Operating system: Mac OS
  • Browsers you attempted to reproduce this bug on (the more the merrier): Safari
  • Does the problem persist after removing "assets/node_modules" and trying again? N/A

Actual behavior

If you have nested live components with the same ID on the page, no backend error is thrown but you get "No component for CID" after a few interactions.

Expected behavior

I expected to get found duplicate ID "A" for component Example.LiveComp when rendering template in the reproducible example below.

I do get this if I change this line:

<.live_component :for={{child, index} <- Enum.with_index(@children)} module={Example.LiveComp} id={"#{index}_#{child.id}"} child_id={child.id} children={child.children} />

to

<.live_component :for={{child, index} <- Enum.with_index(@children)} module={Example.LiveComp} id={child.id} child_id={child.id} children={child.children} />

So it seems the duplicate detection doesn't check the complete tree?

Reproducible example

Click twice on Open Child A (top to bottom) and then twice Close (from bottom to top).

The console shows no component for CID 2.

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"},
    # please test your issue using the latest version of LV from GitHub!
    {:phoenix_live_view,
     github: "phoenixframework/phoenix_live_view", branch: "main", override: true}
  ],
  force: true
)

# build the LiveView JavaScript assets (this needs mix and npm available in your path!)
path = Phoenix.LiveView.__info__(:compile)[:source] |> Path.dirname() |> Path.join("../")
System.cmd("mix", ["deps.get"], cd: path, into: IO.binstream())
System.cmd("npm", ["install"], cd: Path.join(path, "./assets"), into: IO.binstream())
System.cmd("mix", ["assets.build"], cd: path, into: IO.binstream())

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
    {:ok,
     assign(socket, :children, [
       %{
         id: "A",
         children: [
           %{
             id: "A",
             children: [
               %{id: "A", children: []}
             ]
           },
           %{
             id: "B",
             children: []
           }
         ]
       }
     ])}
  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>
    <%!-- uncomment to use enable tailwind --%>
    <%!-- <script src="https://cdn.tailwindcss.com"></script> --%>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <style>
      * { font-size: 1.1em; }
    </style>
    {@inner_content}
    """
  end

  def render(assigns) do
    ~H"""
    <%= length(@children) %> live components
    <.live_component :for={{child, index} <- Enum.with_index(@children)} module={Example.LiveComp} id={"#{index}_#{child.id}"} child_id={child.id} children={child.children} />
    """
  end

  def handle_event("load_child", %{"child" => child}, socket) do
    child = Jason.decode!(child, keys: :atoms)
    {:noreply, update(socket, :children, &(&1 ++ [child]))}
  end

  def handle_event("close_child", _params, socket) do
    {:noreply, assign(socket, :children, List.delete_at(socket.assigns.children, -1))}
  end

  def handle_info({:echo, id}, socket) do
    send_update(Example.LiveCompTwo, %{id: id, event: :echo})
    {:noreply, socket}
  end

end


defmodule Example.LiveComp do
  use Phoenix.LiveComponent

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

  def render(assigns) do
    ~H"""
    <div style="border: 1px solid red">
      <.live_component module={Example.LiveCompTwo} id={@child_id}  />

      <button type="button" phx-click="close_child">Close</button>
      <ul :for={child <- @children}>
        <li>
           <button type="button" phx-click="load_child" phx-value-child={Jason.encode!(child)}>Open child <%= child.id %></button>
        </li>
      </ul>
    </div>
    """
  end
end

defmodule Example.LiveCompTwo do
  use Phoenix.LiveComponent

  def mount(socket) do
    socket = assign(socket, :counter, 0)
    {:ok, socket}
  end

  def update(%{event: :echo}, socket) do
    socket = update(socket, :counter, &(&1 + 1))
    schedule(socket.assigns.id)
    {:ok, socket}
  end

  def update(assigns, socket) do
    if assigns.id != socket.assigns[:id] do
      schedule(assigns.id)
    end

    socket = assign(socket, assigns)
    {:ok, socket}
  end

  defp schedule(id), do: Process.send_after(self(), {:echo, id}, 1000)

  def render(assigns) do
    ~H"""
        <div style="border: 1px solid blue">
    Inner <%= @id %> (<%= @counter %>)</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: :sample
  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

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

@dvic can you try latest main? I believe this is fixed by #3627.

@dvic
Copy link
Contributor Author

dvic commented Jan 24, 2025

@dvic can you try latest main? I believe this is fixed by #3627.

Thanks for the quick response!

I just checked, the problem is also present on main. If I'm not mistaken, #3627 is when you have multiple LiveViews while this example is with just one LiveView.

Edit: I just realized that the JS is incorrectly loaded when I change to {:phoenix_live_view, github: "phoenixframework/phoenix_live_view"}: it's using the hardcoded CDN version. What's the easiest way to get the JS from the main branch loaded in the exs example?

@SteffenDE
Copy link
Collaborator

We have an example that uses the bundled JS linked in our issue template: https://github.com/phoenixframework/phoenix_live_view/blob/main/.github/single-file-samples/main.exs :)

@dvic
Copy link
Contributor Author

dvic commented Jan 24, 2025

We have an example that uses the bundled JS linked in our issue template: https://github.com/phoenixframework/phoenix_live_view/blob/main/.github/single-file-samples/main.exs :)

Thanks! I've updated the example to use this template and I can still reproduce it.

@SteffenDE
Copy link
Collaborator

Thank you! I'm at my computer now and can reproduce it with your example. I'm working on another bug at the moment, but I'll look into this in detail soon!

@SteffenDE SteffenDE added the bug label Jan 24, 2025
SteffenDE added a commit that referenced this issue Jan 25, 2025
…Views

Adds a check for duplicate LiveComponents (Fixes #3650).
@SteffenDE
Copy link
Collaborator

@dvic this is tricky, because neither on the server, nor on the client we can easily detect the case when the duplicate LiveComponent is created during an update. The following happens:

Initially, the page looks like this:

<.live_component id="0_A">
  <.live_component id="A" />
</.live_component>

Then, it is updated to

<.live_component id="0_A">
  <.live_component id="A" />
</.live_component>
<.live_component id="1_A">
  <.live_component id="A" />
</.live_component>

The problem here is that unchanged trees ignored for such updates, therefore LiveView doesn't know that the component "A" was already rendered in "0_A", because the update only affects "1_A".

We have a similar issue on the client, where we skip unchanged trees when patching. Finally, morphdom patches the DOM and moves the duplicate child to the new position.

The best thing we can do is detect this in LiveViewTest. I've opened #3653 which will fail when testing like this:

  1) test works properly (Example.HomeLiveTest)
     /Users/steffen/oss/liveview_bugs/e3650.exs:147
     ** (EXIT from #PID<0.436.0>) an exception was raised:
         ** (RuntimeError) Duplicate live component found while testing LiveView:

         <div data-phx-component="3">I am LiveComponent2</div>
         <div data-phx-component="3">I am LiveComponent2</div>


     This most likely means that you are conditionally rendering the same
     LiveComponent multiple times with the same ID in the same LiveView.
     This is not supported and will lead to broken behavior on the client.

     You can prevent this from raising by passing `handle_errors: :warn` to
     `Phoenix.LiveViewTest.live/3` or `Phoenix.LiveViewTest.live_isolated/3`.

             (phoenix_live_view 1.0.2) lib/phoenix_live_view/test/client_proxy.ex:486: Phoenix.LiveViewTest.ClientProxy.handle_info/2
             (stdlib 6.0) gen_server.erl:2173: :gen_server.try_handle_info/3
             (stdlib 6.0) gen_server.erl:2261: :gen_server.handle_msg/6
             (stdlib 6.0) proc_lib.erl:329: :proc_lib.init_p_do_apply/3

@dvic
Copy link
Contributor Author

dvic commented Jan 25, 2025

I see, thanks for looking into it! However, I have one question:

Finally, morphdom patches the DOM and moves the duplicate child to the new position.

Just to double check: then the markup in the old position is removed right? (that's what I'm seeing in the example in this issue)

Is it maybe worth considering to allow live components to be rendered in two positions? I somehow always thought a live component is rendered twice on the page if it has the same id 🙈

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html currently mentions

You must always pass the module and id attributes. The id will be available as an assign and it must be used to uniquely identify the component. All other attributes will be available as assigns inside the LiveComponent.

The best thing we can do is detect this in LiveViewTest. I've opened #3653 which will fail when testing like this:

👍

@SteffenDE
Copy link
Collaborator

Just to double check: then the markup in the old position is removed right? (that's what I'm seeing in the example in this issue)

Yes! When we patch the DOM the following happens:

<div id="0_A" data-phx-id="c1-phx-GB2rSZ9ucjX8AgAG" data-phx-component="1" data-phx-skip></div>
<div data-phx-id="c2-phx-GB2rSZ9ucjX8AgAG" data-phx-component="2" id="0_B">
  <div id="A" data-phx-id="c3-phx-GB2rSZ9ucjX8AgAG" data-phx-component="3" data-phx-skip></div>
</div>

The first component is skipped (data-phx-skip), the second one renders the duplicate component. In this example morphdom moves the A component into 0_B, as it does not know that it would actually be on the page two times. When the ID is absent from the DOM, the data-phx-id is used instead, which again means that the element is moved, as the duplicates also have the same data-phx-id.

Is it maybe worth considering to allow live components to be rendered in two positions? I somehow always thought a live component is rendered twice on the page if it has the same id 🙈

I don't think this is something we want to do. Can you think of any use cases that would require this?

If you try to render a live component with the same id twice on the page and this happens in the same render, you'll already get a warning:

     ** (RuntimeError) found duplicate ID "duplicate" for component Example.LiveComponent2 when rendering template

This has actually been the case since LV 0.5.0, see d5c4368.

SteffenDE added a commit that referenced this issue Jan 26, 2025
* allow to configure if detected errors raise or warn when testing LiveViews

Adds a check for duplicate LiveComponents (Fixes #3650).

* test duplicate id / component errors
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants