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

Client-side js crashes when component with phx-remove set to JS.hide navigates to another LiveView #3139

Open
DaTrader opened this issue Feb 27, 2024 · 6 comments · May be fixed by #3256
Open

Comments

@DaTrader
Copy link

Environment

  • Elixir version (elixir -v): 1.14.4
  • Phoenix version (mix deps): 1.7.11
  • Phoenix LiveView version (mix deps): 0.20.9 (detected all the way back to 0.20.0 - the oldest I can compile with without errors)
  • Operating system: Linux Mint
  • Browsers you attempted to reproduce this bug on (the more the merrier): FF, Chrome
  • Does the problem persist after removing "assets/node_modules" and trying again? Yes/no: Yes

Actual behavior

Setup

LiveView1 has a function component that conditionally renders an element with a child LiveComponent1.1 that switches to LiveView2 on a button click. The conditionally rendered element has the phx-remove set to a JS.hide with a transition.

<div
  :if={@load?}
  role="dialog"
  aria-modal="true"
  phx-remove={
    JS.hide(
      to: "#Content-#{ @id}",
      transition: { "transition ease-in-out duration-150", "opacity-100 scale-100", "opacity-0 scale-[85%]"}
    )
  }
>

Bug reproducing interaction

  1. LiveView1: click on a button to conditionally render the element with the phx-remove that contains LiveComponent1.1
  2. LiveComponent1.1: click to switch/navigate to LiveView2
  3. LiveComponent2.1 in LiveView2: click to open (instantiate) LiveView1 again
  4. repeat from the beginning

Notes:

  • The JS client crashes in point 2. in a second and all subsequent turns, but never in the first turn.
  • There can be more instances of LiveComponent2.1, but the bug manifests only when clicked on the same instance for the second time (same as in with the same module and ID)
  • It makes no difference if the element with the phx-remove has an id or not.

Without the phx-remove, it all works.

Chrome web console crash message

rendered.js:200 Uncaught TypeError: Cannot read properties of undefined (reading 's')
    at Rendered.cachedFindComponent (rendered.js:200:16)
    at Rendered.mergeDiff (rendered.js:177:26)
    at View.update (view.js:531:19)
    at view.js:625:68
    at View.applyDiff (view.js:266:5)
    at view.js:625:14
    at TransitionSet.flushPendingOps (live_socket.js:963:7)
    at TransitionSet.flushPendingOps (live_socket.js:964:12)
    at live_socket.js:950:12

Firefox web console crash message

Uncaught TypeError: tdiff is undefined
    cachedFindComponent http://localhost:4000/assets/app.js:3925
    mergeDiff http://localhost:4000/assets/app.js:3905
    update http://localhost:4000/assets/app.js:4587
    bindChannel http://localhost:4000/assets/app.js:4677
    applyDiff http://localhost:4000/assets/app.js:4340
    bindChannel http://localhost:4000/assets/app.js:4677
    flushPendingOps http://localhost:4000/assets/app.js:6116
    flushPendingOps http://localhost:4000/assets/app.js:6117
    timer http://localhost:4000/assets/app.js:6100
    setTimeout handler*addTransition http://localhost:4000/assets/app.js:6097
    transition http://localhost:4000/assets/app.js:5438
    transition http://localhost:4000/assets/app.js:4316
    toggle http://localhost:4000/assets/app.js:1800
    hide http://localhost:4000/assets/app.js:1783
    exec_hide http://localhost:4000/assets/app.js:1768
    exec http://localhost:4000/assets/app.js:1659
    exec http://localhost:4000/assets/app.js:1658
    exec http://localhost:4000/assets/app.js:1653
    execJS http://localhost:4000/assets/app.js:5394
    owner http://localhost:4000/assets/app.js:5598
    execJS http://localhost:4000/assets/app.js:5394
    transitionRemoves http://localhost:4000/assets/app.js:5584
    transitionRemoves http://localhost:4000/assets/app.js:5583
    transitionPendingRemoves http://localhost:4000/assets/app.js:3715
    perform http://localhost:4000/assets/app.js:3634
    performPatch http://localhost:4000/assets/app.js:4504
    componentPatch http://localhost:4000/assets/app.js:4623
    update http://localhost:4000/assets/app.js:4593
    update http://localhost:4000/assets/app.js:4592
    time http://localhost:4000/assets/app.js:5417
    update http://localhost:4000/assets/app.js:4590
    bindChannel http://localhost:4000/assets/app.js:4677
    applyDiff http://localhost:4000/assets/app.js:4340
    bindChannel http://localhost:4000/assets/app.js:4677
    after http://localhost:4000/assets/app.js:6090
    requestDOMUpdate http://localhost:4000/assets/app.js:5434
    bindChannel http://localhost:4000/assets/app.js:4676
    onChannel http://localhost:4000/assets/app.js:5444
    trigger http://localhost:4000/assets/app.js:566
    onConnMessage http://localhost:4000/assets/app.js:1310
    decode http://localhost:4000/assets/app.js:826
    onConnMessage http://localhost:4000/assets/app.js:1296
    onmessage http://localhost:4000/assets/app.js:1074
@DaTrader
Copy link
Author

PS. The soonest I'll have the time to make a repro app will be several weeks from now. Hopefully, someone figures it out in the meantime.

@josevalim
Copy link
Member

Thank you for the report. I have added a label that we are waiting for a reproduction.

@DaTrader
Copy link
Author

DaTrader commented Apr 2, 2024

Here's the min repro app. Btw, if the "page" of data is "loaded" synchronously (as opposed to async as in this example), the bug no longer manifests. Same if phx-remove is removed (as noted before).

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.4"},
  {:gettext, "~> 0.23"},
  {:phoenix, "~> 1.7.11"},
  {:phoenix_ecto, "~> 4.4"},
  {:phoenix_html, "~> 3.3"},
  {:phoenix_live_view, "0.20.14", override: true},
  { :extructure, "~> 1.0"}
])

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 Async do
  defmacro async( do: code) do
    quote do
      Task.Supervisor.async( Example.TaskSupervisor, fn ->
        unquote( code)
      end)
    end
  end
end

defmodule PickerComponent do
  use Phoenix.LiveComponent
  import Extructure

  def handle_event( event, _params, socket) do
    socket =
      case event do
        "change_selection" ->
          [ update_event] <~ socket.assigns

          { module, assigns} = update_event
          send_update( module, assigns)

          socket
      end

    { :noreply, socket}
  end

  def render( assigns) do
    ~H"""
    <div id={@id} class="p-[32px]">
      <button
        type="button" class="bg-yellow-200"
        phx-click="change_selection" phx-target={@myself}
      >
        Second
      </button>
    </div>
    """
  end
end

defmodule ItemComponent do
  use Phoenix.LiveComponent

  def render( assigns) do
    ~H"""
    <div id={@id} class="flex flex-col gap-[24px]">
      <%= @text %>
    </div>
    """
  end
end

defmodule MainComponent do
  use Phoenix.LiveComponent
  import Extructure
  import Async
  alias Phoenix.LiveView.JS

  def mount( socket) do
    socket =
      socket
      |> stream_configure( :items, dom_id: & &1.id)
      |> stream_init_page()
      |> assign_picker_open( false)
      |> assign_ensuring( :connected)

    { :ok, socket}
  end

  def update( %{ event: event} = new_assigns, socket) do
    socket =
      case event do
        :init_page ->
          display_init_page( new_assigns.page, socket)
          |> assign_ensuring( nil)

        :something_changed ->
          socket
          |> assign_picker_open( false)
          |> reload_items()
      end

    { :ok, socket}
  end

  def update( new_assigns, socket) do
    socket =
      socket
      |> assign( new_assigns)
      |> ensure_assigns_available()

    { :ok, socket}
  end

  def handle_event( event, _params, socket) do
    socket =
      case event do
        "open_picker" ->
          assign_picker_open( socket, true)

        "close_picker" ->
          assign_picker_open( socket, false)
      end

    { :noreply, socket}
  end

  defp ensure_assigns_available( socket)

  defp ensure_assigns_available( %{ assigns: %{ ensuring: :connected}} = socket) do
    if connected?( socket) do
      socket
      |> load_init_page()
      |> assign_ensuring( :load_init)
    else
      socket
    end
  end

  defp ensure_assigns_available( socket) do
    socket
  end

  defp reload_items( socket) do
    socket
    |> stream( :items, [], reset: true)
    |> stream_init_page()
    |> load_init_page()
    |> assign_ensuring( :reload_init)
  end

  defp load_init_page( socket) do
    [ id] <~ socket.assigns

    lv_pid = self()

    async do
      page = Enum.map( 1..10, &%{ id: "item-#{ &1}"})

      send_update( lv_pid, __MODULE__, %{ id: id, event: :init_page, page: page})
    end

    socket
  end

  defp display_init_page( page, socket) do
    stream_page( page, socket)
  end

  defp stream_init_page( socket) do
    stream_page( [], socket)
  end

  def stream_page( page, socket) do
    stream( socket, :items, page)
  end

  defp assign_picker_open( socket, picker_open?) do
    assign( socket, :picker_open?, picker_open?)
  end

  defp assign_ensuring( socket, ensuring) do
    assign( socket, :ensuring, ensuring)
  end

  def render( assigns) do
    ~H"""
    <div id={@id} class="relative flex flex-col outline-none">

      <!-- if div below is removed or the phx-remove in the nested element is removed, the bug no longer manifests -->
      <div class="pt-[40px] flex flex-col items-center">
        <button type="button" class="bg-blue-200" phx-click="open_picker" phx-target={@myself}>
          First
        </button>

        <div
          :if={@picker_open?}
          class="flex flex-col items-center"
          phx-remove={
            JS.hide(
              transition: { "transition ease-in-out duration-150", "opacity-100 scale-100", "opacity-0 scale-[85%]"}
            )
          }
        >
          <.live_component
            module={PickerComponent}
            id={"PickerLive-#{@id}"}
            update_event={{  __MODULE__, id: @id, event: :something_changed}}
          />
        </div>
      </div>

      <button
        type="button" class="bg-red-200"
        phx-click={JS.navigate( "/")}
      >
        Third
      </button>

      <div id={"Stream-#{@id}"} class="w-[954px] flex flex-col gap-[24px]" phx-update="stream">
        <.live_component
          :for={{ dom_id, _item} <- @streams.items}
          module={ItemComponent}
          id={dom_id}
          text="some text"
        />
      </div>
    </div>
    """
  end
end

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

  defp phx_vsn, do: Application.spec(:phoenix, :vsn)

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

  def handle_info( _msg, socket) do
    { :noreply, socket}
  end

  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"""
    <.live_component
      module={MainComponent}
      id="main"
    />
    """
  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, :default)
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)
  plug(Example.Router)
end

children = [
  Example.Endpoint,
  { Task.Supervisor, name: Example.TaskSupervisor}
]

{ :ok, _} = Supervisor.start_link( children, strategy: :one_for_one, name: Example.Supervisor)
Process.sleep( :infinity)

@DaTrader
Copy link
Author

DaTrader commented Apr 2, 2024

Ah, yes, the reproduction sequence:

  1. click on "First"
  2. ["Second" opens]
  3. click on "Second"
  4. [web console: JS crashes]

@DaTrader
Copy link
Author

DaTrader commented Apr 9, 2024

@josevalim Can you please take the needs more info label off? Thanks.

@DaTrader
Copy link
Author

DaTrader commented Apr 9, 2024

Btw, I know we had it settled it wasn't your concern, but for curiosity sake, in the meantime I've discovered that virtually all issues arising from the LiveView/Alpine integration are also related to the JS.hide in phx-remove and JS.show or JS.transition in phx-mounted or any transition combo thereof (like those started from attributes with JS.execJS). Essentially, Alpine fails to initialize the element's x-data while a JS.xxx transition is taking place elsewhere. The InitAlpine tree phx-hook patch solves this but only in the situations where the element's state can afford to be (additionally) reset without some other undesired consequences regarding its intended behavior.

A workaround for all this is essentially removing the phx-remove transition of the auxiliary elements (e.g. popups, menus, ..) whenever it's expected to trigger element insertion into the DOM, and using the phx-mounted transitions only with the elements that can afford the Alpine state re-reset (as described above).

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