Skip to content

Infinite scroll breaks after closing conditional modal due to morphdom sibling matching #4169

@andrewtimberlake

Description

@andrewtimberlake

When a conditional element (using :if) is rendered as a sibling before the scroll container used by phx-viewport-bottom, closing that element causes morphdom to incorrectly morph the scroll container. The InfiniteScroll hook’s this.scrollContainer then points to a detached node, and infinite scroll stops working.

Description

phx-viewport-bottom uses the InfiniteScroll hook, which calls findScrollContainer(this.el) in mounted() and stores the result in this.scrollContainer. The scroll listener is attached to that container.

When a conditional sibling before the scroll container is removed (e.g. a modal closed), morphdom matches nodes by position. Because the sibling order changes, morphdom morphs the wrong nodes:

  1. The conditional element is morphed into the scroll container.
  2. The scroll container is morphed into the next sibling (e.g. footer/loading).
  3. The original scroll container node is effectively replaced.
  4. The InfiniteScroll hook still holds a reference to the old scroll container node, which is now detached or has different content. The scroll listener no longer fires on the real scroll container, so infinite scroll stops working.

Conditions for the bug

  • A conditional element (:if) is a sibling before the scroll container.
  • The scroll container has no id (so morphdom falls back to position-based matching).
  • The conditional element is removed (e.g. modal closed).

Workarounds

  • If the scroll container has an id, morphdom matches it by id and the bug does not occur.
  • If the conditional element is below the scroll container the bug does not occur.

Possible fix

Reconfigure the scrollContainer during hook update if it has changed.

Minimal reproduction

Application.put_env(:demo, Demo.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "infinite_scroll_bug_repro"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install([
  {:plug_cowboy, "~> 2.0"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7", override: true},
  {:phoenix_live_view, "~> 1.0", override: true}
])

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

defmodule Demo.InfiniteScrollReproLive do
  use Phoenix.LiveView, layout: {__MODULE__, :layout}
  alias Phoenix.LiveView.JS

  @cards_per_load 10
  @max_cards 100

  def mount(_params, _session, socket) do
    cards = build_cards(0, @cards_per_load)

    {:ok,
     socket
     |> assign(:card_count, length(cards))
     |> assign(:end_of_transactions?, false)
     |> assign(:show_overlay, false)
     |> stream(:cards, cards)}
  end

  def render(assigns) do
    ~H"""
    <div style="padding: 1rem; max-width: 600px; margin: 0 auto;">
      <h1 style="margin-bottom: 0.5rem;">Infinite Scroll Bug Reproduction</h1>
      <p style="color: #666; margin-bottom: 1rem; font-size: 0.9rem;">
        Scroll to load more → Click "Show overlay" → Close overlay → Scroll again (broken)
      </p>

      <%!-- 1. Conditional overlay - MUST be BEFORE scroll container to trigger bug --%>
      <div
        :if={@show_overlay}
        id="repro-overlay"
        style="position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5);"
        role="dialog"
        aria-modal="true"
      >
        <div
          style="position: absolute; inset: 0; cursor: pointer;"
          phx-click="close_overlay"
        >
        </div>
        <div style="position: relative; z-index: 10; background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); max-width: 400px;">
          <p style="margin-bottom: 1rem;">Click the dark backdrop to close. This will break infinite scroll.</p>
          <button type="button" phx-click="close_overlay" style="padding: 0.5rem 1rem; cursor: pointer;">
            Close
          </button>
        </div>
      </div>

      <%!-- 2. Scroll container - NO id (required to trigger the morphdom bug) --%>
      <div
        style="overflow: auto; max-height: 400px; margin-bottom: 1rem; border: 1px solid #ddd; border-radius: 4px; padding: 0.5rem;"
      >
        <p :if={@card_count > 0} style="font-size: 0.875rem; color: #666; margin-bottom: 0.5rem;">
          Cards loaded: {@card_count}
        </p>

        <div
          id="repro-cards"
          phx-update="stream"
          phx-viewport-bottom={!@end_of_transactions? && JS.push("load_more", page_loading: true)}
          style={[
            "display: flex; flex-direction: column; gap: 0.5rem;",
            if(@end_of_transactions?, do: "padding-bottom: 1rem;", else: "padding-bottom: 200vh;")
          ]}
        >
          <div
            :for={{id, card} <- @streams.cards}
            id={id}
            tabindex="-1"
            style="background: #f5f5f5; padding: 1rem; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;"
          >
            <span style="font-family: monospace;">Card #{card.index + 1}</span>
            <button
              type="button"
              phx-click="show_overlay"
              style="padding: 0.25rem 0.5rem; cursor: pointer; background: #e0e0e0; border: none; border-radius: 4px;"
            >
              Show overlay
            </button>
          </div>
        </div>
      </div>

      <%!-- 3. Footer --%>
      <div style="text-align: center; font-size: 0.875rem; color: #999;">
        Scroll container has no id — required to trigger the bug
      </div>
    </div>
    """
  end

  def layout(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Infinite Scroll Bug Reproduction</title>
        <meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
        <script src="https://cdn.jsdelivr.net/npm/phoenix@1.8.5/priv/static/phoenix.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/phoenix_live_view@1.1.27/priv/static/phoenix_live_view.min.js"></script>
        <script>
          let liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {
            params: {_csrf_token: document.querySelector("meta[name='csrf-token']").content}
          })
          liveSocket.connect()
        </script>
      </head>
      <body>
        <meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
        <%= @inner_content %>
      </body>
    </html>
    """
  end

  def handle_event("load_more", _params, socket) do
    current_count = socket.assigns.card_count
    new_cards = build_cards(current_count, @cards_per_load)
    new_count = current_count + length(new_cards)

    {:noreply,
     socket
     |> assign(:card_count, new_count)
     |> assign(:end_of_transactions?, new_count >= @max_cards)
     |> stream(:cards, new_cards)}
  end

  def handle_event("show_overlay", _params, socket) do
    {:noreply, assign(socket, :show_overlay, true)}
  end

  def handle_event("close_overlay", _params, socket) do
    {:noreply, assign(socket, :show_overlay, false)}
  end

  defp build_cards(from_index, count) do
    Enum.map(from_index..(from_index + count - 1), fn idx ->
      %{id: "card-#{idx}", index: idx}
    end)
  end
end

defmodule Demo.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  scope "/", Demo do
    pipe_through :browser

    live "/", InfiniteScrollReproLive, :index
  end
end

defmodule Demo.Endpoint do
  use Phoenix.Endpoint, otp_app: :demo

  plug Plug.Session,
    store: :cookie,
    key: "_demo_key",
    signing_salt: "demo_salt"

  socket "/live", Phoenix.LiveView.Socket
  plug Demo.Router
end

{:ok, _} = Supervisor.start_link([Demo.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions