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:
- The conditional element is morphed into the scroll container.
- The scroll container is morphed into the next sibling (e.g. footer/loading).
- The original scroll container node is effectively replaced.
- 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)
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’sthis.scrollContainerthen points to a detached node, and infinite scroll stops working.Description
phx-viewport-bottomuses the InfiniteScroll hook, which callsfindScrollContainer(this.el)inmounted()and stores the result inthis.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:
Conditions for the bug
Workarounds
Possible fix
Reconfigure the scrollContainer during hook update if it has changed.
Minimal reproduction