phoenixframework / phoenix_live_view

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

`JS.focus` (or the associated blur) triggering `click` event or an equivalent undesired behavior potentially causing an infinite loop #3076

Closed DaTrader closed 9 months ago

DaTrader commented 9 months ago

Environment

Actual behavior

Context

A parent LiveComponent form has two text input fields that behave identically (each within its own LiveComponent itself). By clicking on any of the two the parent opens an option list (another live component) related to the clicked input and closes the option list of the other input so the two option list component instances are mutually exclusive (only one can be shown at the time). This is done via the following:

<.live_component 
  module={InputComponent} id={ ..}>
  ..
  on_click={unless @first_list_open?, do: JS.push( "open_first_list", target: @myself) |> JS.focus( to: "#FirstInput")}
  ..
/>

The purpose of the JS.focus is to focus the input field that was clicked on after the related option list live component is open (for otherwise, the focus stays within the open live component).

The option lists are conditionally rendered and what the "open_first_list" handler does is assigns the :first_list_open? to true and :second_list_open? to false and the other way around for the "open_second_list".

Each option list live component can be closed by either "phx-click-away" (which is the case here) or the escape key.

Bug

This used to work perfectly well until one of the commits post v0.20.3.

It now behaves as follows:

Having the JS.focus removed, everything works smoothly again (except for the desired element not being focused obviously).

Commit

The bug manifests first in the 4a76bba5c200eb98ec59970f41445c60d582990b commit (a bump build), but my guess is it's probably 55d50ef3b26494a43cd6489f3337205192fa0ad9 "always emit event on blur, even if throttled (#2318) (#3033)" or 33f6780d72a066786610b776d4d5af94ddf465b2 "check for synthetic click event before dispatching click away (#3042)".

DaTrader commented 9 months ago

Here's the bug demo. I believe something's gone wrong between phx-debounce and JS.focus.

Start the demo then click on the upper input then on the lower -> infinite loop.

PS. Can't tell why it's not showing the list when first clicked. In the real app it does.

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"},
  {:gettext, "~> 0.23"},
  {:phoenix, "~> 1.7.0"},
  {:phoenix_ecto, "~> 4.4"},
  {:phoenix_html, "~> 3.3"},
  {:phoenix_live_view, "0.20.4", override: true}
])

defmodule Repro3071.Gettext do
  use Gettext, otp_app: :sample
end

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

defmodule Example.ConfigComponent do
  use Phoenix.LiveComponent
  alias Phoenix.LiveView.JS

  def mount( socket) do
    socket =
      socket
      |> assign_numbers_open( false)
      |> assign_fruits_open( false)

    { :ok, socket}
  end

  def handle_event( event, _params, socket) do
    socket =
      case event do
        "open_numbers" ->
          socket
          |> assign_fruits_open( false)
          |> assign_numbers_open( true)

        "close_numbers" ->
          assign_numbers_open( socket, false)

        "open_fruits" ->
          socket
          |> assign_numbers_open( false)
          |> assign_fruits_open( true)

        "close_fruits" ->
          assign_fruits_open( socket, false)
      end

    { :noreply, socket}
  end

  defp assign_numbers_open( socket, numbers_open?) do
    assign( socket, :numbers_open?, numbers_open?)
  end

  defp assign_fruits_open( socket, fruits_open?) do
    assign( socket, :fruits_open?, fruits_open?)
  end

  def render( assigns) do
    ~H"""
    <div id={@id}>
      <section class="w-[954px] flex flex-col gap-[32px]">
        <div class="relative flex flex-col gap-[16px]">
          <ul
            :if={@numbers_open?}
            id="PickNumber-config"
            class="absolute bottom-[56px] w-full max-h-[264px] overflow-y-auto focus:outline-none bg-white border-1 border-black rounded-[8px] flex flex-col textL hidden [&amp;_.option]:w-full [&amp;_.option]:text-left [&amp;_.option]:wp-outlined [&amp;_.option]:px-[24px] [&amp;_.option]:py-[12px]"
            phx-mounted={JS.show( transition: { "transition ease-out duration-150", "opacity-0 scale-95", "opacity-100 scale-100"})}
            phx-remove={JS.hide( transition: { "transition ease-in duration-75", "opacity-100 scale-100", "opacity-0 scale-95"})}
            phx-click-away={JS.push( "close_numbers", target: @myself)}
            phx-window-keydown={JS.push( "close_numbers", target: @myself) |> JS.focus( to: "#Input-SearchNumber-#{@id}")}
            phx-key="escape"
          >
            <div class="relative py-[12px]">
              <div id="Stream-PickNumber-config" class="flex flex-col">
                <button id="Option-0c2ea4ce" type="button" class="option">One</button>
                <button id="Option-cb2fcb05" type="button" class="option">Two</button>
                <button id="Option-d12fb30f" type="button" class="option">Three</button>
              </div>
            </div>
          </ul>

          <span class="text-dark70">
            By number
          </span>

          <div id="SearchNumber-config" class="flex-1">
            <form method="post" autocomplete="off">
              <div
                style="min-width: 1px;"
                class="group/search-field flex items-center border border-transparent  bg-dark6 hover:bg-black/10 focus-within:bg-black/10  rounded-[8px] px-[16px] py-[6px] gap-[12px] [&amp;_.child-search-ico]:w-[32px] [&amp;_.child-search-ico]:h-[32px] [&amp;_.child-input]:textL [&amp;_.child-close-ico]:w-[24px] [&amp;_.child-close-ico]:h-[24px]"
              >
                <div class="grow">
                  <div phx-feedback-for="search_input[search_term]">
                    <label for="Input-SearchNumber-config" class=""></label>
                    <input
                      type="text"
                      name="search_input[search_term]"
                      id="Input-SearchNumber-config"
                      value=""
                      class="child-input w-full border-none focus:ring-0 px-0 bg-transparent placeholder:text-black/30  group-hover/search-field:placeholder:text-black/50 group-focus-within/search-field:placeholder:text-black/50"
                      phx-click={unless @numbers_open?, do: JS.push( "open_numbers", target: @myself) |> JS.focus( to: "#Input-SearchNumber-config")}
                      phx-debounce="300"
                      placeholder="Search numbers"
                    >
                  </div>
                </div>
              </div>
            </form>
          </div>
        </div>

        <div class="relative flex flex-col gap-[16px]">
          <ul
            :if={@fruits_open?}
            id="PickFruit-config"
            class="absolute bottom-[56px] w-full max-h-[264px] overflow-y-auto focus:outline-none bg-white border-1 border-black rounded-[8px] flex flex-col textL hidden [&amp;_.option]:w-full [&amp;_.option]:text-left [&amp;_.option]:wp-outlined [&amp;_.option]:px-[24px] [&amp;_.option]:py-[12px]"
            phx-mounted={JS.show( transition: { "transition ease-out duration-150", "opacity-0 scale-95", "opacity-100 scale-100"})}
            phx-remove={JS.hide( transition: { "transition ease-in duration-75", "opacity-100 scale-100", "opacity-0 scale-95"})}
            phx-click-away={JS.push( "close_fruits", target: @myself)}
            phx-window-keydown={JS.push( "close_fruits", target: @myself) |> JS.focus( to: "#Input-SearchFruit-#{@id}")}
            phx-key="escape"
          >
            <div class="relative py-[12px]">
              <div id="Stream-PickFruit-config" class="flex flex-col">
                <button id="Option-4B20XWYO3OKU0EYHKSLT8U995" type="button" class="option">Apple</button>
                <button id="Option-1UWA97LI1LTMC06KSSYC6Y2YM" type="button" class="option">Blueberry</button>
                <button id="Option-3CFBL4N1V8NF3DCJE7JPX38H0" type="button" class="option">Citrus</button>
              </div>
            </div>
          </ul>

          <span class="text-dark70">
            By fruit
          </span>

          <div id="SearchFruit-config" class="flex-1">
            <form method="post" autocomplete="off">
              <div
                style="min-width: 1px;"
                class="group/search-field flex items-center border border-transparent  bg-dark6 hover:bg-black/10 focus-within:bg-black/10  rounded-[8px] px-[16px] py-[6px] gap-[12px] [&amp;_.child-search-ico]:w-[32px] [&amp;_.child-search-ico]:h-[32px] [&amp;_.child-input]:textL [&amp;_.child-close-ico]:w-[24px] [&amp;_.child-close-ico]:h-[24px]"
              >
                <div class="grow">
                  <div phx-feedback-for="search_input[search_term]">
                    <label for="Input-SearchFruit-config" class=""></label>
                    <input
                      type="text"
                      name="search_input[search_term]"
                      id="Input-SearchFruit-config"
                      value=""
                      class="child-input w-full border-none focus:ring-0 px-0 bg-transparent placeholder:text-black/30  group-hover/search-field:placeholder:text-black/50 group-focus-within/search-field:placeholder:text-black/50"
                      phx-click={unless @fruits_open?, do: JS.push( "open_fruits", target: @myself) |> JS.focus( to: "#Input-SearchFruit-config")}
                      phx-debounce="300"
                      placeholder="Search fruits"
                    >
                  </div>
                </div>
              </div>
            </form>
          </div>
        </div>
      </section>
    </div>
    """
  end
end

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

  defp phx_vsn, do: Application.spec(:phoenix, :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/gh/phoenixframework/phoenix_live_view@main/priv/static/phoenix_live_view.min.js"}></script>
    <script src="https://cdn.tailwindcss.com"></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <style>
      * { font-size: 16px; }
    </style>
    <%= @inner_content %>
    """
  end

  def render( assigns) do
    ~H"""
    <div class="w-full h-screen flex flex-col items-center justify-center">
      <.live_component
        module={Example.ConfigComponent}
        id="config-search"
      />
    </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(Example.Router)
end

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

This one is fixed now. Thanks!