bigskysoftware / htmx

</> htmx - high power tools for HTML
https://htmx.org
Other
38.19k stars 1.3k forks source link

HTMX "steals" classes added during settle #412

Open lngr opened 3 years ago

lngr commented 3 years ago

The transition behaviour described here has an unintended side effect, which breaks our application. The same use case worked with Intercooler.js, but for other reasons we must migrate to HTMX. In particular HTMX removes classes on replaced DOM elements that have been added to the swapped DOM element during the settle period.

Assume you have a target as follows:

<div id="target">
    <div id="replaced" class="irrelevant"></div>
</div>

We are going to replace #target's innerHTML via hx-post/hx-swap with the following HTML returned by the server:

<div id="replaced" class="first"></div>

Now assume after the #replaced element has been swapped into the DOM but before the DOM is settled (before htmx-settling is removed), #replaced has gained another class second. Then, after the settle delay, HTMX sets the class attribute to the value originally obtained from the server (here: "first") and therefore effectively removes the second class from #replaced.

If would therefore be great if there was a way to disable the settling mechanism during specific swaps.

1cg commented 3 years ago

wow, that's an interesting case...

We would want to check the element just before settle and see if any new classes were added... :thinking:

1cg commented 3 years ago

Can you give me a more concrete use case to help me think about it? What's that "second" class doing and how did it get there?

lngr commented 3 years ago

Yes, the example in the bug report was a bit condensed for the sake of demonstration.

We actually replace a custom element, which happens to be a Web Component (e.g., <my-dialog id="replaced" open>). Once the web component is connected to the DOM (connectedCallback is run, which happens in or immediately after the swap), it runs custom initialization code and adds visual style classes to itself. That's where the second class comes from.

However, I think the bug may also happen with non-web components. For example, it would argue it's rather common to run some specificy Javascript code once the element has been swapped in. In particular, we rely heavily on the eventing mechanism with HX-Trigger:, and adding custom classes from Javascript triggered by HX-Trigger is quite common.


Note that in our case, I already was able to implement a workaround: I'm not adding custom classes (second), but a specific custom attribute, which we can then target from CSS via my-dialog[my-attribute]. However, that's not always possible due to the way CSS selectors work, and the web component might be a third-party component, where such changes are impossible.

1cg commented 3 years ago

This is the logic where the attribute cloning happens:

https://github.com/bigskysoftware/htmx/blob/3352d5c3e95dad01470e8fc725c61dee1d476593/src/htmx.js#L442

thar be dragons... :cold_sweat:

lngr commented 3 years ago

I'll take a look

digaph commented 2 years ago

We're running against this same issue. Perhaps a more concrete usecase is where we want to replace content of a sidepanel with tabs. The tabs from different htmx requests have the same id's. We use AlpineJS to keep the active tab state and we use a bind on the class property to set the right tab as active.

So this is a dumbed down example of what that looks like:

<div id="side-content"
     x-data="{
        activeTab: null
     }"
>
    <div class="tab-content">
        <div id="tab1" class="tab-pane" :class="{ 'active': activeTab === $el.id }" title="Tab 1"></div>
        <div id="tab2" class="tab-pane" :class="{ 'active': activeTab === $el.id }" title="Tab 2"></div>
    </div>
</div>

Now I left out the meganism to switch the activeTab value, but imagine we've loaded this side-content once and a tab was activated. We then use htmx to retrieve the sidecontent of another item which uses the same layout and same tab id's.

The same tab that was active before immidiatly gets the active state again, but after settle that active class is removed and AlpineJS is not aware of that change as it already assumed the class bind to be up to date.

jens commented 11 months ago

Running into this as well when using web components.

Any classes added in connectedCallback will be overwritten after settling.

StabbarN commented 11 months ago

I believe htmx also "steals" style. Alpine'sx-show doesn't work and here is a demo of it at https://jsfiddle.net/mange85/u3q76z5o/7/

Removing id="someId" makes the problem go away.

We are aware of https://htmx.org/extensions/alpine-morph/ but we don't want to retain Alpine's state.

rnza0u commented 11 months ago

I can confirm that this is indeed happening.

When some third party code asynchronously applies changes on specific attributes before the HTMX settling is done, they will be reverted by HTMX when the settling process starts. In most cases, that means 20ms after the element is created.

It happens for the following attributes:

There are several fixes for now:

shimikano commented 7 months ago

I came across this issue in the context of alpinejs as well.

There's a corresponding discussion on the alpinejs repository, so it seems like there are at least some people confused by this behavior.

Here's a Codepen demonstrating the issue and some of the mentioned workarounds: https://codepen.io/shimikano/pen/MWREmPO

andryyy commented 7 months ago

Funny enough hx-on::after-settle="window.Alpine.start()" fixes it.

So Alpine may be confused?

shimikano commented 7 months ago

@andryyy I like that approach, however, as documented, Alpine.start() should not be called more than once per page.

I tried your workaround and on the surface, the result looked good. However, a warning is displayed in the console:

Alpine Warning: Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems.
andryyy commented 7 months ago

@shimikano Oh, missed that.

A second DOM update fixes the issue.f too.

hx-on::after-swap="event.detail.elt.innerHTML=event.detail.elt.innerHTML"

Well, don’t know if that’s a fix, but a funny workaround.

medihack commented 2 months ago

I think I have an associated issue, too.

I have this custom Alpine directive (for an auto-growing text field):

document.addEventListener("alpine:init", () => {
  Alpine.directive("grow", (el) => {
    el.style.resize = "none";
    el.style.overflow = "hidden";
    el.style.height = "auto";
    el.addEventListener("input", () => {
      el.style.height = "auto";
      el.style.overflow = "hidden";
      el.style.height = el.scrollHeight + "px";
    });
  });
});

It works fine with a textarea that is initially rendered on the page, but the style assignments are not respected when the textarea is swapped into the page later on using HTMX. When I use a little timeout the styles are applied correctly.

ljos commented 3 weeks ago

I believe I have the same issue. I implemented a hack with alpine to solve this

<textarea id="txt" @textarea-updated.window="$el.style.height = $el.scrollHeight + 'px'"></textarea>
response.headers['HX-Trigger-After-Settle'] =  "textarea-updated"
return textarea

I trigger an event after settle that resizes the area when I replace it. For my case I also have to set the cursor, but that is fine to do in the alpine x-init.