alpine: x-open not working for dynamically injected html

I ran into an issue with Alpine and Phoenix’s LiveView.

LiveView will dynamically render & replace html in a page. The issue I ran into is that a simple dropdown menu (from TailwindUI) would be in the ‘closed’ state, but the menu was still displaying. Clicking the menu toggle once would keep it open, and clicking it again would finally close it.

So alpine seemed to be initialized to the correct state, but x-show="open" did not have an effect initially.

My workaround was to manually set the dropdown to display: none; so that it would not initially be displayed.

Any ideas on a good way to handle this? Is there a way to tell alpine to ‘initialize’ a component again?

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Comments: 24 (4 by maintainers)

Most upvoted comments

Ran into the same thing today. Quick hack was to initialize components after inserting.

Alpine.discoverUninitializedComponents(function(el){ Alpine.initializeComponent(el) })

Seems like the listenForNewUninitializedComponentsAtRunTime isn’t picking them up correctly somewhere. I don’t know enough about MutationObservers to say why.

Hey all you LiveViewers, I just posted a pretty big response on the subject of making LiveView integrate with Alpine here: https://github.com/phoenixframework/phoenix_live_view/issues/809#issuecomment-632366710

Take a look!

Looking at the source code help me found the best solution.

Forget everything about what I wrote before. Here the solution fixing all issues about x-ref/$refs, input loosing focus, …

The HTML part, keep it simple and near of Alpine-original:

<div phx-hook="AlpineComponentsHook" x-data="{ tab: 'chat' }">

The JS part (without any lib):

let hooks = {
    AlpineComponentsHook: {
        actualizeAlpineComponent(el, force_render = false) {
            if (el.__x === undefined) {
                Alpine.initializeComponent(el)
            } else {
                const {$refs, $el, $nextTick, $watch, ...x_data} = el.__x.unobservedData;
                if (force_render || x_data !== el.getAttribute("x-data")) {
                    el.__x = undefined;
                    el.setAttribute("x-data", JSON.stringify(x_data));
                    Alpine.initializeComponent(el);
                }
            }
        },
        mounted() {
            this.actualizeAlpineComponent(this.el);
        },
        updated() {
            this.actualizeAlpineComponent(this.el);
        },
    }
};

If you are experiencing issues, put force_render to true.

Note: I’m still playing with the xcloak attribute

Hope that helps ! 😃

I’ve got another issue which I think might be related. I load data in a list after user input and have alpine sprinkled in the list items to expand collapse additional details as well as controls for deleting subitems. (So the double render “should” not be a problem) On delete of the sub item the whole list is rerendered (intentionally) however suddenly all items in the list have their subitems expanded. I’ll investigate some more and report back

I’m seeing the same thing in a Phoenix Liveview. Tried to add the Alpine.discoverUninitializedComponents(function(el){ Alpine.initializeComponent(el) }) in a Liveview update hook but to no avail.

Based on multiple occurrences, I’m going to say this is a bug and needs looking into. Not sure who’s got time for this, @SimoTod @HugoDF @calebporzio .

I might get a chance to look into this, this week.

For anyone trying the suggested fix in https://github.com/phoenixframework/phoenix_live_view/issues/809#issuecomment-637671803

let liveSocket = new LiveSocket("/live", Socket, {
  dom: {
    onBeforeElUpdated(from, to){
      if(from.__x){ window.Alpine.clone(from.__x, to) }
    }
  },
  params: {_csrf_token: csrfToken}
})

and wondering why it isn’t working, it’s because dom option is added in v0.1.13 and it hasn’t been released yet.

Hey 👋

Sometime, the DOM inside a Alpine Component is rerendered by dynamic injected html without being detected by Alpine and cause Alpine to stop incorrectly or loose state like for x-show. Tweaking with id="<%= if connected?(@socket), do: "connected-id", else: "not-connected-id" %>" doesn’t always works or doesn’t work at all for some cases.

Here is my solution for Phoenix Live View. But it can be used with all dynamically injected template I guess (of course, it so shall be adapted).

First attempt

First, I was trying to use phoenix live view hooks to tell Alpine to rerender the actual component. I had so:

<div phx-hook="AlpineComponentHook" x-data="{ tab: 'chat' }">
  <button :class="{ 'active': tab === 'chat' }" @click="tab = 'chat'">Chat</button>
  <button :class="{ 'active': tab === 'edit' }" @click="tab = 'edit'">Edit</button>

  <div x-show="tab === 'chat'">
    <%= live_component @socket, ChatComponent, id: :chat_component, room: @room %>
  </div>
  <div x-show="tab === 'edit'" style="display: none;">
    <%= live_component @socket, EditComponent, id: :edit_component, room: @room %>
  </div>
</div>

And the js file with the Phoenix hooks:

let hooks = {
    AlpineComponentHook: {
        actualizeAlpineComponent(e) {
            Alpine.initializeComponent(e);
        },
        mounted() {
            this.actualizeAlpineComponent(this.el);
        },
        updated() {
            this.actualizeAlpineComponent(this.el);
        },
    }
};

// other code

let liveSocket = new LiveSocket("/live", Socket, {hooks: hooks});

I added the style="display: none;" attribute to the second tab because it wasn’t correctly initialized / it blinks at page load. But the Alpine.initializeComponent didn’t work.

Second attempt

If the Alpine.initializeComponent is not working it’s because the id is the same.

I achieve to make something pretty nice with generating random id and tell Alpine to initialize the “new” component.

function guidGenerator() {
    var S4 = function() {
       return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
    };
    return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4());
}
let hooks = {
    AlpineComponentHook: {
        actualizeAlpineComponent(e) {
            e.id = guidGenerator();
            Alpine.initializeComponent(e);
        },
        mounted() {
            this.actualizeAlpineComponent(this.el);
        },
        updated() {
            this.actualizeAlpineComponent(this.el);
        },
    }
};

Note: Maybe the guidGenerator is too high-cpu consumption (you could find an alternative to generate random ids like the name + timestamp). Also, maybe Alpine keep track of old ids for old components, if Alpine didn’t detect the removed component, there will be a high memory leak. Doing Alpine.start(); instead of Alpine.initializeComponent() may be a better approach ? To be confirmed But I’m still losing selected tab, at each page rerender I’m falling to the default ‘chat’ tab.

Final and working attempt

To cheat the Alpine system, I moved the x-data value in the js window object. And with x-init Alpine attribute, I watch for updates and copy it to the window x-data attribute.

<div phx-hook="AlpineComponentHook" x-init="$watch('tab', value => AlpineComponents.TabRoom.tab = value);">
  ...
</div>
window.AlpineComponents = {
    TabRoom: {
        tab: 'chat'
    }
}

let hooks = {
    AlpineComponentHook: {
        actualizeAlpineComponent(e) {
            e.id = guidGenerator();
            e.setAttribute("x-data", JSON.stringify(AlpineComponents.TabRoom))
            Alpine.initializeComponent(e);
        },
        mounted() {
            this.actualizeAlpineComponent(this.el);
        },
        updated() {
            this.actualizeAlpineComponent(this.el);
        },
    }
};

Note that I removed the x-data attribute in the HTML file so Alpine will not initialize the component at the page load, it still be injected by js during the mounted hook. Also, I keep the style="display: none;" to the second tab because the second tab blinks cause of the multiple Alpine render (because Live View is rerender twice before and after the livesocket connexion).

There still have issues. If an input is in the component, we loose the focus, but it can be fixed with JS (again…).

Hope that can help someone !

EDIT: Instead of playing with style="display: none;" we can play with x-cloak attribute.

EDIT 2: index.js#L40 and index.js#L79 can answer my question about memory leak. If I understand, components aren’t registered in some unknown variable, it’s directly integrated to the HTML DOM element so, if we re-initialize a component, it will override that variable. I may be wrong. But by looking at the source code, I don’t understand why changing the id can re-initialize the component.

EDIT 3: To make my code more generic, I added a x-name attribute referencing the window.AlpineComponents tab index to get x-data from.

<div phx-hook="AlpineComponentsHook" x-name="TabRoom" x-init="$watch('tab', value => AlpineComponents.TabRoom.tab = value);">
e.setAttribute("x-data", JSON.stringify(AlpineComponents[e.getAttribute("x-name")]))

EDIT 4: I’m still running into issues with x-ref inside re-rendered Alpine components… They’ren’t detected, got: $refs.my_ref is undefined.