As part of work on headless I hit a bug that in certain conditions when updating the UI from LiveView an Alpine.js component crash.
This happenes on various occasions, but I reduced the problem to the smalles example possible that is also 100% reproducible.
Here’s the scene:
- There is a URL param (
?color=red
) and based on the value of this param one of two components is rendered - either a blue counter or a red counter. - Both Alpine “componentes” are registerd using
Alpine.data()
function.
When things are OK: When using elixir functions to generate components, like this:
# define component as function
def blue_counter(assigns) do
~H"""
<div x-data="blue_counter">
<div style="color: blue" x-bind="ns"></div>
<button x-bind="button">+</button>
</div>
"""
end
# and then in render()
<%= case @color do %>
<% "blue" -> %> <.blue_counter/>
<% "red" -> %> <.red_counter/>
<% end %>
When things are not OK: When putting the code inline, like this:
<%= case @color do %>
<% "blue" -> %>
<div x-data="blue_counter">
<div style="color: blue" x-bind="ns"></div>
<button x-bind="button">+</button>
</div>
<% "red" -> %>
...
<% end %>
When using inline code, whn switching between red and blue there is a crash with Alpine Expression Error: ns is not defined
error.
Here’s a video showing the issue: switching between components generated with functions is fine, switching between inline components crashes.
I’ve looked into WebSocket messages and the only difference I’ve found is that the good ones include "r": 1
part, while the bad ones do not. Unfortunately I have no idea what that means, so here’s where my research reached a dead end.
The other thing I’ve noticed is that in the good case in onBeforeElUpdated
callback the from._x_dataStack
is NULL
and Alpine.clone()
is never called, while in the bad case it is called and crashes.
I’ve included a single-file example app that can be run with elixir file.exs
and also dump of console and WebSockets logs.
If anyone has any idea what could be wrong here, either with Alpine or LiveView please let me know. Without solving this the whole headless project can’t happen.
Sing-file app example
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},
{:ecto, "~> 3.0"},
{:phoenix_ecto, "~> 4.4"}
])
# 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
use Phoenix.LiveView, layout: {__MODULE__, :live}
def handle_params(params, _url, socket) do
{:noreply,
assign(socket,
color: Map.get(params, "color", "blue"),
type: Map.get(params, "type", "function")
)}
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>
<script src="//unpkg.com/alpinejs" defer></script>
<script type="text/javascript">
document.addEventListener('alpine:init', () => {
console.log("init")
Alpine.data('red_counter', () => {
return {
count: 0,
display: {
["x-text"](){
return this.count;
}
},
button: {
["x-on:click"](){
return this.count++;
}
}
}
});
Alpine.data('blue_counter', () => {
return {
n: 0,
ns: {
["x-text"](){
return this.n;
}
},
button: {
["x-on:click"](){
return this.n++;
}
}
}
})
})
</script>
<script>
let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket, {
dom: {
onBeforeElUpdated(from, to) {
if (from._x_dataStack) {
console.log("onBeforeElUpdated", from._x_dataStack)
window.Alpine.clone(from, to);
}
},
}
})
liveSocket.connect()
</script>
<%= @inner_content %>
"""
end
def render(assigns) do
~H"""
<div>
<h1>Alpine Counter Example</h1>
<nav>
<div>
Function (works):
<.link patch="/?color=blue&type=function">Blue Counter</.link> |
<.link patch="/?color=red&type=function">Red Counter</.link>
</div>
<div style="padding: 10px 0">
Inline (crash):
<.link patch="/?color=blue&type=inline">Blue Counter</.link> |
<.link patch="/?color=red&type=inline">Red Counter</.link>
</div>
</nav>
<%= case @type do %>
<% "function" -> %>
<%= case @color do %>
<% "blue" -> %> <.blue_counter/>
<% "red" -> %> <.red_counter/>
<% end %>
<% "inline" -> %>
<%= case @color do %>
<% "blue" -> %>
<div x-data="blue_counter">
<div style="color: blue" x-bind="ns"></div>
<button x-bind="button">+</button>
</div>
<% "red" -> %>
<div>
<div x-data="red_counter" x-ref="root">
<div style="color: red"} x-bind="display"></div>
<button x-bind="button">+</button>
</div>
</div>
<% end %>
<% end %>
</div>
"""
end
def blue_counter(assigns) do
~H"""
<div x-data="blue_counter">
<div style="color: blue" x-bind="ns"></div>
<button x-bind="button">+</button>
</div>
"""
end
def red_counter(assigns) do
~H"""
<div>
<div x-data="red_counter" x-ref="root">
<div style="color: red"} x-bind="display"></div>
<button x-bind="button">+</button>
</div>
</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(Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix")
plug(Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view")
plug(Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
)
plug(Example.Router)
end
{:ok, _} = Example.Endpoint.start_link()
Process.sleep(:infinity)
Console log: functions (good)
phx-F9MC-AB8-YqApANh update: - {0: {…}}
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
phx-F9MC-AB8-YqApANh update: - {0: {…}}
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
phx-F9MC-AB8-YqApANh update: - {0: {…}}
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
phx-F9MC-AB8-YqApANh update: - {0: {…}}
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
WS Log: functions (good)
[
"4",
"8",
"lv:phx-F9MBWHzvtDjF2AIi",
"phx_reply",
{
"status": "ok",
"response": {
"diff": {
"0": {
"4": {
"0": {
"0": {
"s": [
"<div x-data=\"blue_counter\">\n <div style=\"color: blue\" x-bind=\"ns\"></div>\n <button x-bind=\"button\">+</button>\n</div>"
],
"r": 1
},
"s": [
" ",
"\n "
]
},
"s": [
"\n ",
"\n\n "
]
}
}
}
}
}
]
[
"4",
"9",
"lv:phx-F9MBWHzvtDjF2AIi",
"phx_reply",
{
"status": "ok",
"response": {
"diff": {
"0": {
"4": {
"0": {
"0": {
"s": [
"<div>\n <div x-data=\"red_counter\" x-ref=\"root\">\n <div style=\"color: red\" } x-bind=\"display\"></div>\n <button x-bind=\"button\">+</button>\n </div>\n</div>"
],
"r": 1
},
"s": [
" ",
"\n "
]
}
}
}
}
}
}
]
[
"4",
"10",
"lv:phx-F9MBWHzvtDjF2AIi",
"phx_reply",
{
"status": "ok",
"response": {
"diff": {
"0": {
"4": {
"0": {
"0": {
"s": [
"<div x-data=\"blue_counter\">\n <div style=\"color: blue\" x-bind=\"ns\"></div>\n <button x-bind=\"button\">+</button>\n</div>"
],
"r": 1
},
"s": [
" ",
"\n "
]
}
}
}
}
}
}
]
Console log: inline (bad)
phx-F9MDEe4D9aSenQAF update: - {0: {…}}
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated stack [Proxy(Object)]
onBeforeElUpdated called
phx-F9MDEe4D9aSenQAF update: - {0: {…}}
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated called
onBeforeElUpdated stack []
onBeforeElUpdated called
onBeforeElUpdated stack [Proxy(Object)]
Alpine Expression Error: ns is not defined <- crash
Alpine Expression Error: button is not defined
WS Log: inline (bad)
[
"4",
"13",
"lv:phx-F9MBWHzvtDjF2AIi",
"phx_reply",
{
"status": "ok",
"response": {
"diff": {
"0": {
"4": {
"0": {
"s": [
"\n <div x-data=\"blue_counter\">\n <div style=\"color: blue\" x-bind=\"ns\"></div>\n <button x-bind=\"button\">+</button>\n </div>\n "
]
},
"s": [
"\n ",
"\n "
]
}
}
}
}
}
]
[
"4",
"14",
"lv:phx-F9MBWHzvtDjF2AIi",
"phx_reply",
{
"status": "ok",
"response": {
"diff": {
"0": {
"4": {
"0": {
"s": [
"\n <div>\n <div x-data=\"red_counter\" x-ref=\"root\">\n <div style=\"color: red\" } x-bind=\"display\"></div>\n <button x-bind=\"button\">+</button>\n </div>\n </div>\n "
]
}
}
}
}
}
}
]
[
"4",
"15",
"lv:phx-F9MBWHzvtDjF2AIi",
"phx_reply",
{
"status": "ok",
"response": {
"diff": {
"0": {
"4": {
"0": {
"s": [
"\n <div x-data=\"blue_counter\">\n <div style=\"color: blue\" x-bind=\"ns\"></div>\n <button x-bind=\"button\">+</button>\n </div>\n "
]
}
}
}
}
}
}
]