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

Conditional rendering seems to cause events to sometimes be sent to the wrong component #3200

Open
mattnenterprise opened this issue Apr 4, 2024 · 1 comment · May be fixed by #3201
Open
Assignees

Comments

@mattnenterprise
Copy link

mattnenterprise commented Apr 4, 2024

Environment

  • Elixir version (elixir -v): Elixir 1.14.0 (compiled with Erlang/OTP 25)
  • Phoenix version (mix deps): 1.7.11
  • Phoenix LiveView version (mix deps): 0.20.14
  • Operating system: Ubuntu
  • Browsers you attempted to reproduce this bug on (the more the merrier): Firefox
  • Does the problem persist after removing "assets/node_modules" and trying again? Yes/no: Yes

Actual behavior

Conditional rendering seems to cause events to sometimes be sent to the wrong component.

To reproduce using the following single file example:

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},
])

# 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.PanelLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}
  use Phoenix.VerifiedRoutes, endpoint: Example.Endpoint, router: Example.Router

  alias Phoenix.LiveView.JS

  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>
    <style>
      * { font-size: 1.1em; }
    </style>
    <%= @inner_content %>
    """
  end

  def render(assigns) do
    ~H"""
    <div>
      <div>
        <div>
          <.tab_button text="Messages tab" route={~p"/messages_tab"} />
          <.tab_button text="Settings tab" route={~p"/settings_tab"} />
        </div>

        <aside>
          <div>
            <div :if={@live_action == :messages_tab}>
              <.live_component module={Example.MessagesTab} id="messages_tab" />
            </div>
            <div :if={@live_action == :settings_tab}>
              <.live_component module={Example.SettingsTab} id="settings_tab" />
            </div>
          </div>
        </aside>
      </div>
    </div>
    """
  end

  def handle_params(_params, _uri, socket), do: {:noreply, socket}

  defp tab_button(assigns) do
    ~H"""
    <button type="button" phx-click={JS.patch(@route)}>
      <%= @text %>
    </button>
    """
  end
end

defmodule Example.SettingsTab do
  use Phoenix.LiveComponent

  @impl Phoenix.LiveComponent
  def render(assigns) do
    ~H"""
    <div>Settings</div>
    """
  end
end

defmodule Example.MessagesTab do
  use Phoenix.LiveComponent

  def update(assigns, socket) do
    {
      :ok,
      assign(socket, id: assigns.id, value: "")
    }
  end

  def render(assigns) do
    ~H"""
    <div>
      <.live_component
        module={Example.MessageComponent}
        id="some_unique_message_id"
        message="Example message"
      />
      <form
        id="full_add_message_form"
        phx-change="add_message_change"
        phx-submit="add_message"
        phx-target="#full_add_message_form"
      >
        <.input id="new_message_input" name="new_message" value={@value} />
      </form>
    </div>
    """
  end

  def input(assigns) do
    ~H"""
    <div phx-feedback-for={@name}>
      <input
        name={@name}
        id={@id}
        value={Phoenix.HTML.Form.normalize_value("text", @value)}
      />
    </div>
    """
  end

  def handle_event("add_message_change", %{"new_message" => value}, socket) do
    {:noreply, assign(socket, :value, value)}
  end
end

defmodule Example.MessageComponent do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~H"""
    <div><%= @message %></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("/messages_tab", PanelLive, :messages_tab)
    live("/settings_tab", PanelLive, :settings_tab)
  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)

Do the following steps.

  1. Save the example file as main.exs
  2. Run the example file with elixir main.exs
  3. Go to http://localhost:5001/settings_tab in the browser.
  4. Click on the Messages tab button.
  5. Enter some text into the input field.
  6. Kill the elixir main.exs command running the server.
  7. Start the server back up with elixir main.exs
  8. When the server starts back up the live socket will reconnect and form recovery will trigger the phx-change event. The event will be sent to the wrong component. You will get a stacktrace like the following:
23:47:29.384 [error] GenServer #PID<0.445.0> terminating
** (UndefinedFunctionError) function Example.MessageComponent.handle_event/3 is undefined or private
    Example.MessageComponent.handle_event("add_message_change", %{"_target" => ["new_message"], "new_message" => "ajskdfkj;ldskf;kdal;f"}, #Phoenix.LiveView.Socket<id: "phx-F8L3CCRaB37PHABI", endpoint: Example.Endpoint, view: Example.PanelLive, parent_pid: nil, root_pid: #PID<0.445.0>, router: Example.Router, assigns: %{__changed__: %{}, flash: %{}, id: "some_unique_message_id", message: "Example message", myself: %Phoenix.LiveComponent.CID{cid: 2}}, transport_pid: #PID<0.443.0>, ...>)
    (phoenix_live_view 0.20.14) lib/phoenix_live_view/channel.ex:734: anonymous fn/4 in Phoenix.LiveView.Channel.inner_component_handle_event/4
    (telemetry 1.2.1) /home/matt/.cache/mix/installs/elixir-1.14.0-erts-13.0.4/54e1eefbf0138c49990e6660ac11ca01/deps/telemetry/src/telemetry.erl:321: :telemetry.span/3
    (phoenix_live_view 0.20.14) lib/phoenix_live_view/diff.ex:209: Phoenix.LiveView.Diff.write_component/4
    (phoenix_live_view 0.20.14) lib/phoenix_live_view/channel.ex:661: Phoenix.LiveView.Channel.component_handle/4
    (stdlib 4.0.1) gen_server.erl:1120: :gen_server.try_dispatch/4
    (stdlib 4.0.1) gen_server.erl:1197: :gen_server.handle_msg/6
    (stdlib 4.0.1) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
Last message: %Phoenix.Socket.Message{topic: "lv:phx-F8L3CCRaB37PHABI", event: "event", payload: %{"cid" => 2, "event" => "add_message_change", "type" => "form", "uploads" => %{}, "value" => "new_message=ajskdfkj%3Bldskf%3Bkdal%3Bf&_target=new_message"}, ref: "33", join_ref: "32"}
State: %{components: {%{1 => {Example.MessagesTab, "messages_tab", %{__changed__: %{}, flash: %{}, id: "messages_tab", myself: %Phoenix.LiveComponent.CID{cid: 1}, value: ""}, %{children_cids: [2], lifecycle: %Phoenix.LiveView.Lifecycle{after_render: [], handle_async: [], handle_event: [], handle_info: [], handle_params: [], mount: []}, live_temp: %{}, root_view: Example.PanelLive}, {210466503517054276859819191889704952721, %{1 => {146951180617304675310538223870145813961, %{}}}}}, 2 => {Example.MessageComponent, "some_unique_message_id", %{__changed__: %{}, flash: %{}, id: "some_unique_message_id", message: "Example message", myself: %Phoenix.LiveComponent.CID{cid: 2}}, %{children_cids: [], lifecycle: %Phoenix.LiveView.Lifecycle{after_render: [], handle_async: [], handle_event: [], handle_info: [], handle_params: [], mount: []}, live_temp: %{}, root_view: Example.PanelLive}, {12134921015163570839893465356329525864, %{}}}}, %{Example.MessageComponent => %{"some_unique_message_id" => 2}, Example.MessagesTab => %{"messages_tab" => 1}}, 3}, join_ref: "32", serializer: Phoenix.Socket.V2.JSONSerializer, socket: #Phoenix.LiveView.Socket<id: "phx-F8L3CCRaB37PHABI", endpoint: Example.Endpoint, view: Example.PanelLive, parent_pid: nil, root_pid: #PID<0.445.0>, router: Example.Router, assigns: %{__changed__: %{}, flash: %{}, live_action: :messages_tab}, transport_pid: #PID<0.443.0>, ...>, topic: "lv:phx-F8L3CCRaB37PHABI", upload_names: %{}, upload_pids: %{}}

If the following code in the example file

           <div :if={@live_action == :messages_tab}>
              <.live_component module={Example.MessagesTab} id="messages_tab" />
            </div>
            <div :if={@live_action == :settings_tab}>
              <.live_component module={Example.SettingsTab} id="settings_tab" />
            </div>

if changed to

           <div style={if @live_action != :messages_tab, do: "display: none;"}>
              <.live_component module={Example.MessagesTab} id="messages_tab" />
            </div>
            <div style={if @live_action != :settings_tab, do: "display: none;"}>
              <.live_component module={Example.SettingsTab} id="settings_tab" />
            </div>

then the form recovery event is sent to the correct component. This seems to suggest some issue with conditional rendering.

Expected behavior

For the phx-change event to be sent to the correct component during form recovery.

@mattnenterprise mattnenterprise changed the title Conditional rendering seems to cause events to sometimes be sent to the wrong component. Conditional rendering seems to cause events to sometimes be sent to the wrong component Apr 4, 2024
@SteffenDE SteffenDE self-assigned this Apr 6, 2024
@SteffenDE
Copy link
Collaborator

@mattnenterprise this shouldn't happen if you change the phx-target on the form to phx-target={@myself}.

@chrismccord I think we have a deeper problem when performing form recovery on forms with phx-target="selector". It could be that the selector points to multiple DOM elements. How should we handle this? Currently we never find the new component ID here:

return [form, newForm, this.targetComponentID(newForm)]

SteffenDE added a commit that referenced this issue Apr 6, 2024
This commit refactors the form recovery feature to run after the initial
mount patch. This has two benefits:

1. We can use the default withinTargets handling to ensure that we
   always send the event to the correct target.
2. It allows form recovery to work in nested LiveViews!

Fixes #2281.
Fixes #3200.
@SteffenDE SteffenDE linked a pull request Apr 6, 2024 that will close this issue
SteffenDE added a commit that referenced this issue Apr 14, 2024
This commit refactors the form recovery feature to fix a problem with
phx-target='selector' not working. It also stores form elements until
all child views have been mounted, which allows form recovery to work
in nested LiveViews.

Fixes #2281.
Fixes #3200.
SteffenDE added a commit that referenced this issue Apr 14, 2024
SteffenDE added a commit that referenced this issue Apr 14, 2024
This commit refactors the form recovery feature to fix a problem with
phx-target='selector' not working. It also stores form elements until
all child views have been mounted, which allows form recovery to work
in nested LiveViews.

Fixes #2281.
Fixes #3200.
SteffenDE added a commit that referenced this issue Apr 14, 2024
SteffenDE added a commit that referenced this issue Apr 27, 2024
This commit refactors the form recovery feature to fix a problem with
phx-target='selector' not working. It also stores form elements until
all child views have been mounted, which allows form recovery to work
in nested LiveViews.

Fixes #2281.
Fixes #3200.
SteffenDE added a commit that referenced this issue Apr 27, 2024
SteffenDE added a commit that referenced this issue May 3, 2024
This commit refactors the form recovery feature to fix a problem with
phx-target='selector' not working. It also stores form elements until
all child views have been mounted, which allows form recovery to work
in nested LiveViews.

Fixes #2281.
Fixes #3200.
SteffenDE added a commit that referenced this issue May 3, 2024
SteffenDE added a commit that referenced this issue May 3, 2024
This commit refactors the form recovery feature to fix a problem with
phx-target='selector' not working. It also stores form elements until
all child views have been mounted, which allows form recovery to work
in nested LiveViews.

Fixes #2281.
Fixes #3200.
SteffenDE added a commit that referenced this issue May 3, 2024
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.

2 participants