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

Input inside component loses focus if another slot changes #3448

Open
sezaru opened this issue Sep 29, 2024 · 6 comments
Open

Input inside component loses focus if another slot changes #3448

sezaru opened this issue Sep 29, 2024 · 6 comments
Assignees
Milestone

Comments

@sezaru
Copy link
Contributor

sezaru commented Sep 29, 2024

Environment

  • Elixir version (elixir -v): 1.17.2
  • Phoenix version (mix deps): 1.7
  • Phoenix LiveView version (mix deps): main
  • Operating system: Fedora Silverblue 41
  • Browsers you attempted to reproduce this bug on (the more the merrier): Firefox, Chrome
  • Does the problem persist after removing "assets/node_modules" and trying again? Yes/no: No

Actual behavior

Consider that I have a component that has a slot that can change, and in the same component I also have an text input.

If I send an update to the server to change that slot, and at the same time focus on that text input, when the request comes back to the client, the input will lose focus.

Here is an example that triggers the issue:

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.HomeLive do
  alias Phoenix.LiveView.JS

  use Phoenix.LiveView, layout: {__MODULE__, :live}

  def mount(_params, _session, socket) do
    form = to_form(%{"a" => []})

    {:ok, assign_new(socket, :form, fn -> form end)}
  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

  slot :left_content

  defp my_component(assigns) do
    ~H"""
    <div>
      <div :for={left_content <- @left_content}>
        <%= render_slot(left_content) %>
      </div>

      <input id="search" type="search" name="value" phx-change="search" />
    </div>
    """
  end

  def render(assigns) do
    ~H"""
    <.form for={@form} id="my_form" phx-change="validate" class="flex flex-col gap-2">
      <.my_component>
        <:left_content :for={value <- @form[:a].value || []}>
          <div><%= value %></div>
        </:left_content>
      </.my_component>

      <div class="flex gap-2">
        <input
          type="checkbox"
          name={@form[:a].name <> "[]"}
          value="settings"
          checked={"settings" in (@form[:a].value || [])}
          phx-click={JS.dispatch("input") |> JS.focus(to: "#search")}
        />

        <input
          type="checkbox"
          name={@form[:a].name <> "[]"}
          value="content"
          checked={"content" in (@form[:a].value || [])}
          phx-click={JS.dispatch("input") |> JS.focus(to: "#search")}
        />
      </div>
    </.form>
    """
  end

  def handle_event("validate", params, socket) do
    {:noreply, assign(socket, form: to_form(params))}
  end

  def handle_event("search", _params, socket) do
    {:noreply, socket}
  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)

As you can see, I have two check-boxes that will dispatch an input event to the form and also focus on the #search input.

Also, the my_component component have a slot called left_content that can change depending on the @form[:a] value (<:left_content :for={value <- @form[:a].value || []}>).

How to reproduce the error:

  1. Add some latency to the socket with liveSocket.enableLatencySim(1000) (This is not mandatory, but it helps to visualize the issue better);
  2. Click in one of the checkboxes;
  3. LV will send a "validate" event to the server and focus in the #search input;
  4. When the update from the "validate" event comes back, a new slot :left_content will be renderized with the value of the clicked checkbox;
  5. At the same time, the #search input will lose focus.

Expected behavior

The #search input is not being re-renderized in any way, so adding or removing a slot that doesn't have anything to do with it shouldn't make it lose focus.

@sezaru
Copy link
Contributor Author

sezaru commented Sep 29, 2024

I did some more testing and it seems that the factor is not the slot itself being updated, but the component having a for loop.

For example, changing the above code to remove the slot also triggers the same issue:

  attr :field, :any, required: true

  defp my_component(assigns) do
    ~H"""
    <div>
      <div :for={value <- @field.value || []}>
        <div><%= value %></div>
      </div>

      <input id="search" type="search" name="value" phx-change="search" />
    </div>
    """
  end

  ...

  <.my_component field={@form[:a]} />

But, if I actually remove the input from inside the my_component, then it works:

  attr :field, :any, required: true

  defp my_component(assigns) do
    ~H"""
    <div>
      <div :for={value <- @field.value || []}>
        <div><%= value %></div>
      </div>
    </div>
    """
  end

  ...
 
  <.my_component field={@form[:a]} />
  <input id="search" type="search" name="value" phx-change="search" />

In the end, it seems that if I have a :for call in an element at the same level as an input, and that for value changes, then the input will lose focus.

So, this will lose focus:

      <div :for={value <- @field.value || []}>
        <div><%= value %></div>
      </div>

      <input id="search" type="search" name="value" phx-change="search" />

But this:

      <div><%= inspect(@field.value) %></div>

      <input id="search" type="search" name="value" phx-change="search" />

Or this:

      <div>
        <div :for={value <- @field.value || []}>
          <div><%= value %></div>
        </div>
      </div>

      <input id="search" type="search" name="value" phx-change="search" />

Will not.

Also, if you invert the order of the rendering (so the input renders first and then the div with the :for loop), then the input doesn't lose focus either:

      <input id="search" type="search" name="value" phx-change="search" />
      
      <div :for={value <- @field.value || []}>
        <div><%= value %></div>
      </div>

@josevalim
Copy link
Member

Yes, this is expected. Morphdom tracks elements by their position in the DOM or by ID. Once you are dynamically adding children, then it can no longer match the element, so they are effectively destroyed and re-added. If you place the for inside the element or add an ID, you are good to go.

@sezaru
Copy link
Contributor Author

sezaru commented Sep 30, 2024

Thanks for the reply @josevalim , can you elaborate a little bit more about adding an ID? The input already has an ID, shouldn't that be enough to make Morphdom track it and keep the input focused?

Also, at least when I look at the data coming back from the server, it never touches the input field anywhere (but I guess it does touch it internally because of the added sibling element on top.

@josevalim
Copy link
Member

You are right, please ignore me. I completely mixed the checkboxes with the search. :)

@sezaru
Copy link
Contributor Author

sezaru commented Sep 30, 2024

Hahaha no problem! So, does that means that this can potentially a bug?

If you think this is a bug/issue with Morphdom and not with LV I can create an issue in their repo.

@SteffenDE
Copy link
Collaborator

Yes, this looks like a bug to me. And don’t mind creating an issue in morphdom, Chris is currently the only one committing there, so it doesn’t really matter. I’ll try looking into this later this week :)

@SteffenDE SteffenDE added this to the v1.0 milestone Oct 16, 2024
@SteffenDE SteffenDE self-assigned this Oct 16, 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

No branches or pull requests

3 participants