teamon
Different behaviour of Alpine.js (crash) when using LiveView component functions or inline code
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 "
]
}
}
}
}
}
}
]
Trending in Questions
Other Trending Topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #elixirconf-us
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #performance
- #security










First 4 of 4 Posts
teamon
Update 1
I’ve found out that the same issue occurs when using inline
x-data={...}too.Single-file app example
Update 2
Adding

idattributes seems to solve the issue.TunkShif
I’ve been doing some projects with Alpine and LiveView recently. And here’s my experimenting and debugging process for your problem. Hope that this can help you.
First, let’s take a closer look at the differences of the rendered html between the function components and thje inline content.
From the rendered results shown in the images below, you can see that the rendered component has a
data-phx-idattribute in the component root element, while the rendered inline content doesn’t have this attribute. Here I’ve also added adata-debugattribute to the root element of the Alpine component (the element that hasx-dataattribute) to easily identify it later.Next, we need to have a general idea of how LiveView DOM patch works. It uses the morphdom library under the hood for doing DOM mutations.
Given an existing DOM tree that is already rendered in the browser and a new updated HTML fragment sent from the server,
morphdomwill morph the existing DOM to match the content of the newly updated HTML fragment.During the process,
morphdomwill try to reuse existing DOM element and only create new DOM nodes when needed, rather than simply replacing the whole old DOM tree with a completely newly created one.It supports custom lifecycle hooks so that we can inspect the morphing process. Add the following code for debugging the morph process.
For the
componenttype, switch between blue and red, and you can see that the whole component is always recreated. Each DOM node of the component is created and added to replace the old DOM content.Switch from the
componenttype toinlinetype, and you can see that all nodes are recreated too.For the
inlinetype, switch from blue to red, and you can see that the outermostdivelement is reused through the morph process. It is morphed from<div x-data="blue_counter" data-debug="">to<div>with only attributes changed, there’s no new node created for it. And so far, everything works fine.However, if we switch from red to blue, then error happens. To better demonstrate the morphing process, here I’ve also added the
data-debugattribute to the outermostdivelement of the red counter component.From the logging results above, we can see that two
divelements were reused, and the error happens when dealing with the second node updates. From the trace stacks, the error happens when callingAlpine.clone()at the second DOM node update.Then let’s have a look at what
Alpine.clone()actually did. Alpine stores reactive states in the_x_dataStackproperty of a DOM element. So it first copy all the reactive states from the old element to the new element. After that, it’s callinginitTree()on the new element. This function basically scans and initializes all thex-prefixed Alpine directives throughout the whole DOM tree.And then let’s get back to the debugging results where error happened.
During the first node update, the outermost
<div>element (thefromparam ofonBeforeElUpdated) is morphed to<div x-data="blue_counter">. But thefromelement is not an Alpine component root, which has no_x_dataStackproperty so that it failsif (from._x_dataStack)check. AndAlpine.clone()is not called, thex-data="blue_counter"directive in the new element is not initialized.During the second node update, the element is morphed to
<div x-bind="ns">. But its parent element<div x-data="blue_counter">didn’t get a chance to initialize the Alpine data, so it cannot find thensbinding from its root. And that’s why the error saysAlpine Expression Error: ns is not defined.The cause of the Alpine error should be clear now. Then let’s talk about why the
componentandinlinerendering has different behavior when doing DOM patching. Why doesmorphdomalways create new nodes when renderingcomponentand why does it try to reuse existing nodes when renderinginlinecontent?morphdom also has a
getNodeKey()option:If you have experience in React, Vue or other frontend libraries, you may be familiar with the
keyattribute when doing list rendering. And thegetNodeKey()function for morphdom works in a similar way.And the implementation of this option in LiveView is shown below. It is using the element id or an attribute named
PHX_MAGIC_IDas the node key. AndPHX_MAGIC_IDis a constant with valuedata-phx-id. And that’s just what we saw in the rendered component in the beginning. Also, this is why it works if you manually add anidattribute to the root element.https://github.com/phoenixframework/phoenix_live_view/blob/v0.20.14/assets/js/phoenix_live_view/dom_patch.js#L105
As for the
"r": 1field from the diff message, it is marked asreplyin the source code. And I’m not sure what this field means, either. But I think it is not related to your problem, I guess?https://github.com/phoenixframework/phoenix_live_view/blob/v0.20.14/lib/phoenix_live_view/diff.ex#L13
And this is all the explanations to your problem. Hope that I made it clear enough for you to understand.
TunkShif
Also I want to write more notes and thoughts on Alpine and LiveView.
When the two are used together, they’re both trying to control DOM updates. And the code below is almost every tutorial does to integrate Alpine with LiveView:
The source of this integration is from the author of Alpine in this issue: Alpine.js stopped working from LiveView Component on state change. · Issue #809 · phoenixframework/phoenix_live_view · GitHub
Alpine was originally created for Laravel LiveWire, a framework that is kind of similar to Phoenix LiveView. At the time when the issue was posted, LiveWire was also using
morphdomfor DOM changes and then useAlpine.clone()insidemorphdomlifecycle hooks to integrate with Alpine.But as the author stated in the issue above:
And indeed, with the current way to integrate Alpine with LiveView, you can encounter some unexpected weird behaviors like yours. And some other related problems can also be found in the forum, like what this post mentioned: Proper AlpineJS Integration with Liveview .
But now, LiveWire doesn’t use
morphdomfor DOM changes anymore. It is using the Alpine Morph plugin, which has the same functionality as morphdom, but it is Alpine-aware. So LiveWire can have seamless integration with Alpine.And the
Alpine.clone()function is marked as deprecated in the latest version. I’m not sure if there’s plan to completely remove it in future releases.https://github.com/alpinejs/alpine/blob/v3.14.0/packages/alpinejs/src/clone.js#L44
Also, there used to be an instruction on how to integrate Alpine with LiveView in the JavaScript interoperability section of the LivewView doc. But I don’t know when and why it was removed.
teamon
@TunkShif Whoa, that’s an amazingly detailed response, thank you!




So far adding
ids where necessary did solve my immediate issues.I wonder what are the rules to include
data-phx-idor notI guess Alpine’s morph plugin duplicates the functionality of morphdom. I can only hope that Alpine won’t remove the
clone()functionaly completely