phoenixframework / phoenix_live_view

Rich, real-time user experiences with server-rendered HTML
https://hex.pm/packages/phoenix_live_view
MIT License
6.01k stars 905 forks source link

Alpine.js stopped working from LiveView Component on state change. #809

Closed psagan closed 4 years ago

psagan commented 4 years ago

Environment

Actual behavior

Alpine.js (https://github.com/alpinejs/alpine) tags do not work anymore when rendered from LiveView Component (they were working in 0.10.0).

# this is returned from LiveView Component
<%= if @state == "OPEN" do %>
<div x-data="{ open: false }">
    <button @click="open = true">Open Dropdown</button>

    <ul
        x-show="open"
        @click.away="open = false"
    >
        Dropdown Body
    </ul>
</div>
<% else %>
  <div>test</div>
<% end %>

In example above let say that state is "CLOSED" by default. After changing state to be "OPEN" LiveView Component returns non-working (with alpine.js) html (it was working on 0.10.0)

Alpine.js works properly when rendered from normal liveview or from LiveView Component without state change, when I change state like above - it returns non-working part of html.

Expected behavior

Alpine.js tags work properly from LiveView Component on state change (like it was in 0.10.0).

josevalim commented 4 years ago

Can you please provide an application that reproduces the error?

Also note that LiveView and Alpine.js will most likely conflict with each other. That's why you should mark the container of whatever contains Alpine.js with phx-update="ignore", so you tell LV to not touch it. Anything else outside phx-update="ignore" will work by chance, and it will most likely break whenever Alpine.js or LV change.

psagan commented 4 years ago

Hi @josevalim , thank you for response. The motivation to use alpinejs is that it's used for UI animation effects (mostly with transitions) what is easy to achieve using it. Inspiration fur such usage arrived from two places:

josevalim commented 4 years ago

I understand. If you can digest one of those articles into an application we can quickly try, then we can take a look at it. Thanks!

chrismccord commented 4 years ago

Alpine and LV appear to be incompatible. They both compete to control the entire DOM update process, and with no setup/teardown API, there is nothing you can try to do phx-hook wise to re-initialize alpine. For example, alpine uses a MutationObserver on the document.body so it will see and react to every change that LV performs as it's walking and patching the DOM with morphdom. This will happen outside of our phx-hook js integration so I don't think there's anything for us to handle here. Thanks!

psagan commented 4 years ago

Hi @josevalim , I've prepared this app https://github.com/psagan/test_live_view so can be tested by the way introduced in readme. Thank you

psagan commented 4 years ago

Hi @chrismccord thank you for explanation and fast response you got me both. I raised this issue because it stopped working lastly, but as you said both alpinejs and LV can conflict in DOM patching/manipulation now and in the future.

sfusato commented 4 years ago

I've been able to get your code working in 0.12.1 as it was working in 0.10.0. Do this:

  1. In modal_component.html.leex add this wrapper to the whole component. Don't forget to remove the phx-hook from the the other div where it was previously:
+ <div id="<%= @id %>" phx-hook="initModal">
...
+ </div>
  1. Then, in app.js, duplicate the mounted logic of initModal in updated as well. Without the duplicated logic in updated you can only open&close the modal 2 times after which it won't work anymore.
psagan commented 4 years ago

Hi @sfusato , great thank you for help :) it looks that LV behavior changed since 0.10.0 that requireds additonal things to hack for alpine.js. Possible it will change in the future much more - so I will look for other transitions solutions not based on framework like alpinejs which competes with LV to control DOM. But many thanks for check and explanations! :)

gugahoa commented 4 years ago

Using phx-update="ignore" seems to work for me. I don't know if here would be the proper place to talk about this issue, since it seems to be a incompatibility between alpine.js and LiveView, but I'm pretty much interested in solving this from either side. Any ideas on how to approach this?

scorsi commented 4 years ago

@psagan @gugahoa You can go look here : https://github.com/alpinejs/alpine/issues/282#issuecomment-626653704. I've gave a clean working solution.

calebporzio commented 4 years ago

Hey @chrismccord (Creator of Alpine here),

I created a LiveView-esque framework for PHP/Laravel called Laravel/Livewire and found the need for a simple, lightweight JS framework to handle small interactions that don't require a round-trip to the server.

AlpineJS was born.

I kept the projects completely separate because lots of people can benefit from Alpine without ever using Livewire (or something like it).

But in reality, it was created FOR Livewire, and by extension LiveView.


Livewire uses morphdom for the dom-diff/patch under the hood just like LiveView so the code could probably be copy and pasted actually.

The exact code to make Livewire work beautifully with Alpine (and would likely be the exact same for LiveView) is so simple, I'm just going to copy and paste it here.

There are 2 things that LiveView would have to be aware of to work with Alpine:

1) LiveView will need to initialize before Alpine does. Fortunately, I added a hook in Alpine to make this easy.

2) LiveView will need to re-initialize Alpine components in the HTML BEFORE morphdom performs an update so that both the current DOM AND the new (about to be patched DOM) are "Alpine-aware" - this is much easier than it sounds.

Code for 1) (https://github.com/livewire/livewire/blob/1ab663443f70ceb3d342761e1c7fc5caab42708f/src/LivewireManager.php#L247)

/* Make Alpine wait until Livewire is finished rendering to do its thing. */
window.deferLoadingAlpine = function (callback) {
    window.addEventListener('livewire:load', function () {
        callback();
    });
};

The above snippet would be included by LiveView, and instead you would listen for some event fired from LiveView to tell Alpine it can now initialize. (replace livewire:load with something like liveview:load - I don't know LiveView's API at all)

Code for 2) (https://github.com/livewire/livewire/blob/e10b7fd2f31292881c842b37334ea7053ff4b64d/js/component/index.js#L311)

onBeforeElUpdated: (from, to) => {
    // ...

    if (from.__x) {
        // Then temporarily clone it (with it's data) to the "to" element.
        // This should simulate backend Livewire being aware of Alpine changes.
        window.Alpine.clone(from.__x, to)
    }
},

The above snippet is a morphdom hook that I'm sure LiveView already is using for other things. If you add this snippet, you are checking if the element about to be updated is an Alpine component, if so, use the component's run-time data to "seed" the new HTML about to be patched. It works beautifully.

That's it. Those two snippets are the ONLY code that has anything to do with Alpine in Livewire and the integration works fantastically.


A question you might have that I'd like to address:

LiveView can't work with Alpine because both are trying to control the DOM


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.

It's also possible that your advanced "HTML-static/dynamic-stitching" behavior is happening BEFORE the morphdom step, and in that case, it's totally possible.


I think a framework like LiveView has a ton to gain from something like Alpine.

Lots of small interactions don't warrant a server-roundtrip and there are lots of other times you simply need to use JS and Alpine is the JS-swiss-army-knife for the job.

I'm always down to help you with the integration, hop on a call, or pair program.

I hope the two ecosystems can benefit from each other.

Thanks for reading this @chrismccord

calebporzio commented 4 years ago

After scrolling up, I'm seeing people saying you should use phx-ignore on an Alpine component.

Let me say, this used to be the case for Livewire. (wire:ignore), but with the code I pasted above, it is no longer necessary - you can mix dynamic Alpine AND LiveView and it works great. No need to skip DOM diffing dynamic sections of HTML.

scorsi commented 4 years ago

Hey @calebporzio ! Sounds very interesting. Thanks for the big explanation of how LiveWire and Alpine works together so nicely. I will try to figure out if the quite same implementation is possible here for LiveView. I already made a fix which works pretty well available in the last message but it is not a good fix in fact so I’m still looking for the best.

chrismccord commented 4 years ago

@calebporzio awesome! I wasn't aware of these extension points so it didn't seem like we could play nicely, but I should have dug further :) I think the only thing missing from a true happy path is an extension on the LiveView side that would allow folks to hook into the onBeforeElUpdated process as you showed, but this is trickier than it sounds because we have to have tight control of what happens before update. I think it should be possible if we provide a restricted before update API, but I need to investigate. Thanks for the thorough outline! I'll bump this with my findings

calebporzio commented 4 years ago

Awesome! Glad this helps

chrismccord commented 4 years ago

Supported via new dom option. The only thing that needs to be done is import/script/etc alpine.js and add this to your LiveSocket constructor:

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

Works beautifully... I can follow the onBeforeElUpdated logic in the new dom option. Learned a lot about morphdom in the process.

But how does Alpine know how to initialize only after Liveview finished loading (mounting)? This is "Thing 1" Caleb had outlined. I can't find any reference in the code in either project.

Would love to understand as I'm trying to build a small set of helpers myself.

mosiac05 commented 1 year ago

If you still face this issue, try adding a unique ID attribute to the element you want to use x-data on.

I did that, and everything worked fine for me.

guess commented 1 year ago

If you still face this issue, try adding a unique ID attribute to the element you want to use x-data on.

I did that, and everything worked fine for me.

I already had the app.js fixed in and couldn't figure out why I still experienced this issue. This fixed the issue for me. Thank you!!