Issue with Hotwire, Apline JS and Tailwind

Im trying to identify the issue I’m having and hopefully someone here will be able to shed some light. I’m in the process of implementing Hotwire into an existing app I’m building. I have a admin page that has tabs who’s content is populated via turbo_frame_tag.
My admin.html.erb page lazy loads the first tabs content like so

<%= turbo_frame_tag "tab_content", src: tab_1_content_path %>

Within the tab content, I have a modal and a dropdown that Im using tailwind and alpinejs to style, etc.
The problem I am running into is that when I click a link to navigate to my admin page, The modal and dropdown that are suppose to be hidden by alpinejs are showing and will not function.
If I refresh the page, they start to preform correctly.

It seems like I’m missing something in regards to the lazy load with the turbo_frame.
On a separate page, I am also using turbo_frame_tag in the same way except I don’t lazy load the content because I am rendering a partial within my initial turbo_frame_tag

<%= turbo_frame_tag "tab_content" do %>
  <%= render 'accounts/dashboard', account: @account %>
<% end %>

My partial also has a dropdown and a modal within, yet I don’t have the same issue.

Thanks for any suggestions :slight_smile:

I had a similar issue and solved it by using the following package:

Thank you, I have been using the similar alpine-hotwire-turbo-adapter package. I tried switching to the one you posted, but I’m still having the same problem. The dropdowns etc I have function properly as long as they are not lazy loaded in, via turbo_frame_tag "tab_content", src: tab_1_content_path
I can’t edit my original post, so Ill try and lay out what I have better here.

admin.html.erb

<h1>Admin</h1>
<%= turbo_frame_tag "tab_content", src: tab_1_content_path %>

tab_1_content.html.erb

<%= turbo_frame_tag "tab_content" do %>
<div class="dropdown">
  Alpine controlled drop down
</div>

<div class="modal">
  Alpine controlled modal
</div>
<% end %>

When navigating to the admin page, the dropdown and modal are shown when they are suppose to be hidden and do not function correctly until I refresh the page. Contrast that to this…

show.html.erb

<h1>User</h1>
<%= turbo_frame_tag "tab_content" do %>
  <%= render 'user_content' %>
<% end %>

_user_content.html.erb

<div class="dropdown">
  Alpine controlled drop down
</div>

<div class="modal">
  Alpine controlled modal
</div>

This works correctly and has the dropdown and modal hidden and functioning when I navigate to the show page

I’m experiencing the same issues as well.

After lots of trial and error, the problem seems to be that, when using lazy-loaded frames, Alpine doesn’t initialize the newly rendered components.

General problem

The reason is that Alpine needs to run Alpine.initializeComponent(componentDOMElement) in order to activate it. (if you have properly installed it) Alpine already does this to all components that are initially in the document, upon first load of the page.

The problem seems to be that: upon first page load with lazy-loaded turbo-frames, all Alpine sees is:

...
<turbo-frame src="..." id="...">

</turbo-frame>
...

The frame hasn’t (yet) loaded the html where your Alpine components are, because they just aren’t present upon first page load.

Your (@BryTai ) project-specific problem
When a user goes to the admin dashboard (admin.html.erb), after the HTML is downloaded and Alpine tries to mount the components, the only thing it sees is something like:

<h1>Admin</h1>
<turbo-frame id="tab_content" src=" the tab_1_content_path ">
</turbo-frame>

(You can verfy this by checking the response to the initial page request)

Alpine can’t mount any component: because there are none in the HTML (yet!).

But because the turbo-frame has a src: it inmediately downloads the contents of tab_1_content.html.erb. Loading its HTML and showing the broken dropdown and modal.

This translates to “When navigating to the admin page, the dropdown and modal are shown when they are suppose to be hidden and do not function correctly until I refresh the page”.

Now, in contrast, in the user page you render the components upon first page load. So the html received by the browser (and Alpine) upon page load is something like:

<h1>User</h1>
<div class="dropdown">
  Alpine controlled drop down
</div>

<div class="modal">
  Alpine controlled modal
</div>

Alpine is then able to mount the components because they have been rendered.

Hotfix solution (?)
(At least this works for me)
The basic solution that I have found does the following:

Upon first page load: set a MutationObserver that will listen to changes inside of all <turbo-frames> present in the first page load. When the interior of those turbo-frames (aka: its child-nodes) change: look for all uninitialized Alpine components, and initialize them.

In case it can help you out: my (working) code is the following. This script (that first goes through webpack) is loaded only once in the <head>. The thing I like the least is the setTimeout, but I’ve checked and without it the code doesn’t work (but might be something specific to my project tho, I suggest you to try it without it).

import 'alpine-turbo-drive-adapter'
import Alpine from 'alpinejs'

let turboFramesObserver
document.addEventListener('turbo:load', () => {
  if (turboFramesObserver && turboFramesObserver.disconnect)
    turboFramesObserver.disconnect()
  // The setTimout gives a little bit of time to Vue or other agents to render properly the newly injected HTML
  setTimeout(() => {
    // This has been greatly inspired by https://www.smashingmagazine.com/2019/04/mutationobserver-api-guide/
    turboFramesObserver = new MutationObserver(mutations => {
      for (let mutation of mutations) {
        if (mutation.type === 'childList') {
          Alpine.discoverUninitializedComponents(function(el) {
            Alpine.initializeComponent(el)
          })
        }
      }
    })
    
    // Gets all <turbo-frames> present in the page. Before they lazy-load their content.
    let turboFrames = document.querySelectorAll('turbo-frame')
    for (let turboFrame of turboFrames) {
      // Observes each frame, firing the MutationObserver callback upon any "childList" mutation
      turboFramesObserver.observe(turboFrame, {
        attributes: false,
        childList: true,
      })
    }
  }, 500)
})

Ideal solution
In order to avoid the MutationObserver object/API, the best (in my humbe opinion) would be if Turbo fires an event called something like turbo:frame-loaded every time a frame finishes its rendering after receiving a new request from the server.

I’ve seen this talked in the Github discussions and in a couple of pull requests, so I (really) hope it will be implemented soon.

3 Likes

Wow, thank you for your write up!
I’ll give your solution a try and I agree with your “Ideal Solution” going forward.

Was this issue fixed?