Liveview blowing up Alpine state?

I’m having an issue with alpinejs where I am seemingly loosing alpine after performing a task where liveview basically validates a changeset and reassigns/updates @changeset, I don’t think this part is important to the issue. While this changeset is getting validated I have a loader that uses x-show to show it or hide it based on a bool value from an Alpine.store. The loader is turn on with an @click and turned off by the liveview sending a message to a hook that changes the bool in Alpine.store. The loader is getting stuck to on/show (or its natural state of display: block) because I am loosing that x-show. Typing Alpine.start() into the console fixes the issue so I know it is alpine getting uninitialized or not loading back up or something. Anyone else having this issue with liveview and alpine?

Alpine setup:

import Alpine from 'alpinejs'

window.Alpine = Alpine
Alpine.start()

...

const liveSocket = new LiveSocket('/live', Socket, {
      params: {_csrf_token: csrfToken},
      dom: {
        onBeforeElUpdated(from, to) {
          if (from._x_dataStack) {
            window.Alpine.clone(from, to)
            window.Alpine.initTree(to)
          }
        },
      },
      hooks: Hooks,
    })

May or may not be relevant to this issue but I am also getting this error sometimes in the console on this liveview:

module.esm.js:2288 Uncaught (in promise) TypeError: i is not a function at module.esm.js:2288

the function, which seems to be coming from alpine, the error is referring to with the call to i() (23 lines down)

window.Element.prototype._x_toggleAndCascadeWithTransitions = function(el, value, show, hide) {
  let clickAwayCompatibleShow = () => requestAnimationFrame(show);
  if (value) {
    el._x_transition ? el._x_transition.in(show) : clickAwayCompatibleShow();
    return;
  }
  el._x_hidePromise = el._x_transition ? new Promise((resolve, reject) => {
    el._x_transition.out(() => {
    }, () => resolve(hide));
    el._x_transitioning.beforeCancel(() => reject({isFromCancelledTransition: true}));
  }) : Promise.resolve(hide);
  queueMicrotask(() => {
    let closest = closestHide(el);
    if (closest) {
      if (!closest._x_hideChildren)
        closest._x_hideChildren = [];
      closest._x_hideChildren.push(el);
    } else {
      queueMicrotask(() => {
        let hideAfterChildren = (el2) => {
          let carry = Promise.all([
            el2._x_hidePromise,
            ...(el2._x_hideChildren || []).map(hideAfterChildren)
          ]).then(([i]) => i());
          delete el2._x_hidePromise;
          delete el2._x_hideChildren;
          return carry;
        };
        hideAfterChildren(el).catch((e) => {
          if (!e.isFromCancelledTransition)
            throw e;
        });
      });
    }
  });
};
2 Likes

Try removing Alpine.initTree(to) from onBeforeElUpdated. I had similar problems with it. The errors you are seeing might be easier to debug if you enable source maps.

I am guessing that since you are using a changeset, that the DOM element that you are interacting with is a form. In the past, I have had issues with Alpine state stored on the form tags (iirc Morphdom deals with patching forms differently that it does patching other HTML tags).

To circumvent the Morphdom form issues, I leveraged Spruce to store the Alphine state outside of the form tag GitHub - ryangjchandler/spruce: A lightweight state management layer for Alpine.js. 🌲. With the release of Alpine 3.x though, what functionality Spruce offered is now supported directly in AlpineJS.

So I am actually using Alpine.state('loader', false) to manage state here, initialized in a hook. My loader is inside of a form though so this could be the issue. I’ll have to do a bit of refactoring to test it out, but thanks for the lead!

Have you tried adding phx-update=“ignore” to the loader element tag? I’m Not actually sure if this would work on an element inside the form component, but might be worth trying

Yes, I tried pretty much everything back then. We ended up abandoning Apline because of too many issues at the time. It may be more stable now, this was quite a while ago and they seemed to be constantly evolving.

Sorry, I just realized how old this was! It showed up on my suggested feed and didn’t realize the date : ) I hope you found something that works for you. Alpine is working well for me today but honestly I just started with it.

This is vaguely similar to what I’m experiencing. Here’s a minimal project that reproduces the issue. I’d be grateful if anyone can take a look. :pray:

The issue is that when
A) rendering directly from a LiveView, everything works. But when
B) the rendering is moved to a LiveComponent, Alpine’s x-show (or something else) “breaks”: the search results still show, but not immediately on typing (only after users also presses the Enter key).

In other words, moving the render/1 shown below (as well as some few handle_event/3) from

  • A) a :live_view to
  • B) a :live_component

somehow breaks and Alpine’s x-show doesn’t show the <ul> immediately. It does show up but only if one also then presses say the Enter key, or defocuses-and-refocuses the search <input> with a mouse, etc.

def render_from_live_view(assigns) do
  ~H"""
  <div class="flex-col relative" x-data="{open: false}">
    <p>Using LiveView, the result will immediately show below, without needing to also press Enter key.</p>
    <form id="search-form" phx-submit="submit">
      <input
        placeholder="type 'a'"
        name="search-input"
        id="search-input"
        class="block border border-black"
        phx-change="search"
        phx-hook="FocusOnMounted"
        x-on:focus="open = true"
      />
    </form>
    <ul :if={@search_results != []} x-show="open">
      <li :for={result <- @search_results}><%= result %></li>
    </ul>
  </div>
  """
end

And the relevant lines from app.js:

import Alpine from "alpinejs"
window.Alpine = Alpine
Alpine.start()

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  hooks: {
    FocusOnMounted: {
      mounted() {
        this.el.focus();
      }
    },
  },
  dom: {
    onBeforeElUpdated(from, to) {
      if (from._x_dataStack) {
        window.Alpine.clone(from, to);
      }
    }
  }
})

Visually, I only see the following differences in the relevant HTML between the :live_view (left) and the :live_component (right):

Other somewhat related threads:

I’ve fixed the error reported in my message above by merely adding an id attribute on the element that also has Alpine’s x-data attribute. Why was/is the missing id attribute an issue for Alpine? (It seems like any id attribute will do.)

It’s unclear why is it problematic if an id attribute isn’t explicitly provided. In other words, how come the LiveComponent-generated “id” doesn’t suffice? This is seen in my previous message above, on the image diff of two HTMLs; specifically, the auto-generated id is in the top red rectangle.

Investigating the LiveSocket’s dom object, specifically the onBeforeElUpdated(from, to) callback (as seen in app.js above) does suggest there’s some difference already on the initial load, before any user interaction: on load, the from and to elements only get printed in the console when the mentioned id is given (otherwise not).

Seems like I should first figure out why onBeforeElUpdated does (not) get called when id is (not) provided. Please let me know if you see where I’m misusing Alpine and/or LV; I’ll admit already that having both LV’s :if= and Alpine’s x-show on the same element seems superfluous and asking for trouble. :slight_smile:


PS. For Alpine, I’ve looked into docs at 1) the “Essentials” section, 2) on $id, 3) on x-id directive, and 4) on Alpine.data (and glanced at other sections as well) and couldn’t find any mention of a required id attribute, or anything that would explain this behaviour.

Or put an id on all alpine scopes.

And yes, you’re asking for (and getting) trouble. Personally, I ripped alpine out first chance I got. There is a certain elegance to alpine but it’s not worth the headache for me.

Thanks for the reply! Indeed, I’m already eyeing if/how JS Commands & Hooks might be able to replace Alpine.

Still, I’d be curious to read from you or anyone that knows why exactly is the above behaviour different between
A) a LiveComponent-provided id attribute (seen on the image above, in the top red rectangle), vs.
B) my own, explicitly set id attribute (on the same HTML element, the top-level <div>)

In case it helps anyone else, I was having the same types of errors (Uncaught (in promise) TypeError: i is not a function) and finally resolved them by wrapping a data update from within a $watch in $nextTick.

Simplified example:

<div
  x-data="{open: false, filter: 'all'}"
  x-init="$watch('filter', val => {
    $nextTick(() => open = false) // <-- $nextTick wrapper added
  })"
>

I ran into a similar problem; adding an id attribute to an element with the x-data attribute did not solve my problem.

After some trial and error, I found the following workaround:

First, remove window.Alpine.initTree(to) from onBeforeElUpdated.

Then, add the phx-hook="AlpineRoot" attribute to the element with the x-data attribute or one of its ancestor elements.

Finally, define the following hook in your app.js:

Hooks.AlpineRoot = {
  update() {
    window.Alpine.initTree(this.el)
  }
}

I hope my experience will be useful to someone.

1 Like

I’m a big fan of Alpine.js and it’s good to see others getting it to work with LV.

Have you seen this other thread on the topic? There’s some snippets in there that are also useful on working around some of the issues with Alpine:

1 Like