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.

Gif

Link to video

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        "
              ]
            }
          }
        }
      }
    }
  }
]

Update 1

I’ve found out that the same issue occurs when using inline x-data={...} too.

Single-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>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket, {
        dom: {
          onBeforeElUpdated(from, to) {
            console.log("onBeforeElUpdated called")
            if (from._x_dataStack) {
              console.log("onBeforeElUpdated stack", 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="{n: 0}">
                <div style="color: blue" x-text="n"></div>
                <button x-on:click="n++">+</button>
              </div>
            <% "red" -> %>
              <div>
                <div x-data="{count: 0}" x-ref="root">
                  <div style="color: red"} x-text="count"></div>
                  <button x-on:click="count++">+</button>
                </div>
              </div>
          <% end %>
      <% end %>

    </div>
    """
  end

  def blue_counter(assigns) do
    ~H"""
    <div x-data="{n: 0}">
      <div style="color: blue" x-text="n"></div>
      <button x-on:click="n++">+</button>
    </div>
    """
  end

  def red_counter(assigns) do
    ~H"""
    <div>
      <div x-data="{count: 0}" x-ref="root">
        <div style="color: red"} x-text="count"></div>
        <button x-on:click="count++">+</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)

Update 2

Adding id attributes seems to solve the issue. :thinking: :thinking:

<%= case @color do %>
  <% "blue" -> %>
    <div id="b" x-data="blue_counter">
      <div style="color: blue" x-bind="ns"></div>
      <button x-bind="button">+</button>
    </div>
  <% "red" -> %>
    <div id="r">
      <div x-data="red_counter" x-ref="root">
        <div style="color: red"} x-bind="display"></div>
        <button x-bind="button">+</button>
      </div>
    </div>
<% end %>

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-id attribute in the component root element, while the rendered inline content doesn’t have this attribute. Here I’ve also added a data-debug attribute to the root element of the Alpine component (the element that has x-data attribute) 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, morphdom will morph the existing DOM to match the content of the newly updated HTML fragment.

During the process, morphdom will 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.

let liveSocket = new LiveSocket("/live", Socket, {
	dom: {
		onNodeAdded(node) {
			console.log("node added: ", node);
		},
		onBeforeElUpdated(from, to) {
                        // check if the element is an Alpine component root
			if (from.dataset.debug != null) {
				console.log("node updated: ");
				console.log(from.outerHTML);
				console.log(to.outerHTML);
			}

			if (from._x_dataStack) {
				console.log("datastack cloned");
				Alpine.clone(from, to);
			}
		},
	},
});

For the component type, 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 component type to inline type, and you can see that all nodes are recreated too.

For the inline type, switch from blue to red, and you can see that the outermost div element 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-debug attribute to the outermost div element of the red counter component.

From the logging results above, we can see that two div elements were reused, and the error happens when dealing with the second node updates. From the trace stacks, the error happens when calling Alpine.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_dataStack property of a DOM element. So it first copy all the reactive states from the old element to the new element. After that, it’s calling initTree() on the new element. This function basically scans and initializes all the x- prefixed Alpine directives throughout the whole DOM tree.
image

And then let’s get back to the debugging results where error happened.

During the first node update, the outermost <div> element (the from param of onBeforeElUpdated ) is morphed to <div x-data="blue_counter">. But the from element is not an Alpine component root, which has no _x_dataStack property so that it fails if (from._x_dataStack) check. And Alpine.clone() is not called, the x-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 the ns binding from its root. And that’s why the error says Alpine Expression Error: ns is not defined.

The cause of the Alpine error should be clear now. Then let’s talk about why the component and inline rendering has different behavior when doing DOM patching. Why does morphdom always create new nodes when rendering component and why does it try to reuse existing nodes when rendering inline content?

morphdom also has a getNodeKey() option:

getNodeKey (Function(node) ) - Called to get the Node 's unique identifier. This is used by morphdom to rearrange elements rather than creating and destroying an element that already exists. This defaults to using the Node ‘s id property. (Note that form fields must not have a name corresponding to forms’ DOM properties, e.g. id .)

If you have experience in React, Vue or other frontend libraries, you may be familiar with the key attribute when doing list rendering. And the getNodeKey() 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_ID as the node key. And PHX_MAGIC_ID is a constant with value data-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 an id attribute to the root element.

As for the "r": 1 field from the diff message, it is marked as reply in 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?

And this is all the explanations to your problem. Hope that I made it clear enough for you to understand. :no_mouth:

2 Likes

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:

let liveSocket = new LiveSocket("/live", Socket, {
	dom: {
		onBeforeElUpdated(from, to) {
			if (from._x_dataStack) {
				Alpine.clone(from, to);
			}
		},
	},
});

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 morphdom for DOM changes and then use Alpine.clone() inside morphdom lifecycle hooks to integrate with Alpine.

But as the author stated in the issue above:

Something that may actually be a deal-breaker for the integration:

Livewire is simple. When an update happens, the server sends an entire chunk of HTML and uses morphdom to patch the existing DOM.

I’ve seen that LiveView has a much more advanced system where the server only sends the dynamic pieces of HTML that have changed.

It’s possible that you are doing something so advanced that you can’t use the simple morphdom hook as I have.

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 morphdom for 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.

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.

@TunkShif Whoa, that’s an amazingly detailed response, thank you! :purple_heart::purple_heart::purple_heart::purple_heart::purple_heart:

So far adding ids where necessary did solve my immediate issues.

I wonder what are the rules to include data-phx-id or not :thinking:

I guess Alpine’s morph plugin duplicates the functionality of morphdom. I can only hope that Alpine won’t remove the clone() functionaly completely :sweat_smile: