-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Bugfix: swap="outerHTML" on <div> with style attribute leaves htmx-swapping class behind #3341
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
… it failes, implemented initial fix in htmx.js that makes (all) test(s) run and pass.
…sts were passing and I know why, though. This means I miss one more regression test for the bug in cloneAttributes.
…butes after I noted they could as well be removed by mergeTo.setAttribute in the second forEach loop.
I accidentally forked the Fortunately, it was just the line 1992 fix that needed to be moved inside the |
Yeah I recently did a deep dive of the cloneAttributes function when looking into an issue #412 where class attributes can be updated by third party libraries during the default settle delay which htmx then reverts breaking things. But I couldn't find a good solution to that problem. But I did find some of the same things you found about cloneAttributes. It does indeed remove all htmx related classes during this phase and I found some of these were later checked for and removed if they were still there. However I came to the conclusion that the after settle clone attributes call is meant to set the class to its final state which includes removing all the classes so later explicit removals are normally not needed (unless you disable class attribute settling). I don't think their is really a requirement that classes are kept on the elements at this stage as this is during the final stage of the settle and they are no longer needed here. But iterating on live collection is a really good catch as this can be a real pain. I think we can simplify your fix down to just: function cloneAttributes(mergeTo, mergeFrom) {
forEach(Array.from(mergeTo.attributes), function(attr) {
if (!mergeFrom.hasAttribute(attr.name) && shouldSettleAttribute(attr.name)) {
mergeTo.removeAttribute(attr.name)
}
})
forEach(mergeFrom.attributes, function(attr) {
if (shouldSettleAttribute(attr.name)) {
mergeTo.setAttribute(attr.name, attr.value)
}
})
} Array.from() converts them to a stable array for us to iterate on safely and this passes the tests fine for me. The mergeFrom.attributes I found is actually stable here so it does not actually need to be copied to an array like the mergeTo does above. With this one line change applied I was now unable to reproduce the issue and tests all passed fine. Also did some manual testing and tried with attributesToSettle config set to not process class attribute which could cause issues but this worked fine as well and had no issue with retained htmx classes. I was unable to now reproduce the issue where swapping class was still on the new outerHTML replaced target that you added the extra remove class for all settle elements so I don't think we need this extra fix line you added at 1992 now either. Anyway if you can update this PR I think this will be a great fix thanks. |
Sorry for responding so late, busy days. Actually, I had the chance to read your response on saturday but I wasn't able to sit down and do the thing until now. I see what you're saying, I just applied the changes and rerun the tests myself just to be sure. Funny how it boils down to a one liner :) |
Description
I stumbled upon an unexpected behavior a couple days ago while using HTMX version 1.9.10, but I found it happens in 1.9.12 and 2.0.4 as well.
Explanation by example
To explain it with an extremely stripped-down example, let's suppose you have a body like this:
Notice I'm trying to swap the
div#test-div
with anouterHTML
swap strategy, and thediv#test-div
has astyle
attribute. All it takes is the attribute - there is no need for a string with actual style in it to be there.Let's suppose the response from
/test
is this:We should expect the resulting body to look like this:
But actually, it looks like this:
An
htmx-swapping
class is left behind.The first regression test I added covers this case, and it fails if no changes are made to htmx.js (which is expected).
Root cause
I digged down in the source code using the debugger and found what seemed to be the root cause of the problem:
htmx/src/htmx.js
Line 1972 in d1d0cd9
This line of code removes the
htmx-swapping
class from thetarget
element, which is the original element in the DOM we're swapping. This works forinnerHTML
swaps because the node is still the same, but won't affect the new node in case of anouterHTML
swap.This is easily fixed by removing the
htmx-swapping
class from the new nodes too, which are insettleInfo.elts
. This justifies the line 1992 I added. I'm pretty positive this is correct, but since I'm not an expert about the HTMX codebase, despite all tests are passing, I'm asking the maintainers to review this and confirm it won't have any unexpected side effects.Why does the swap work when there's no
style
attribute on the original node, though?Assume, from now on, I didn't implemented the line 1992 fix yet, because we need the code in the original state to keep debugging.
I removed
style
from the original node and scanned the code with the debugger to find wherehtmx-swapping
was being removed. For the sake of clarity, I mean that we're now starting with this body:Notice no
style
attribute is on thediv
. Assume the endpoint response remains the same as before.Turns out, the
htmx-swapping
class is removed during the secondcloneAttributes
call. This is the originalcloneAttributes
implementation:I included the
forEach
implementation to make it easier to read.cloneAttributes
is called during the swap to make the new node (temporarily) look like the original one (meaning thestyle
,class
,width
andheight
attributes of the original node are copied on the new node, or removed entirely if the original one doesn't have them).Then it is called again (as a
settleInfo.tasks
element) during the settling phase, to reset the new node to its original appearance. To do this, it leveragesnewAttributes
, a clone of the new node that has been created before modifying it. This last call is wherehtmx-swapping
is wiped away when there is nostyle
attribute.This removal of
htmx-swapping
is due to the fact that the new node, originally, did not have any class on it. In practice,newAttributes
node is exactly how/test
responded:So, when
cloneAttributes
is called onnewNode
andnewAttributes
, thennewNode
loses theclass
attribute, since there is noclass
attribute onnewAttributes
.This has a side-effect: the
htmx-added
and thehtmx-settling
classes are removed as well. This looks like a bug to me, since there are a couple function calls further down the line that explicitly remove these two classes. I'll come back to this later.How
cloneAttributes
behaves when there is thestyle
attributeI then added back the
style
attribute on the original node and debugged step by step the secondcloneAttributes
execution, to see why the wiping wasn't happening in this case.What happens is that, being
mergeTo.attributes
andmergeFrom.attributes
NamedNodeMaps, which (as per MDN docs) are live collections, whenmergeTo.removeAttribute(attr.name)
is called forstyle
, themergeTo.attributes
array suddenly becomes one element shorter. On the next iteration inside theforEach
function, the check on the length fails and thefor
loop is prematurely exited - the removal of the classes does not happen.As I mentioned before,
htmx-added
andhtmx-swapping
are explicitly removed down the line, so this explains why onlyhtmx-swapping
is left behind on the new node.I changed the
cloneAttributes
function to first create copies ofmergeTo.attributes
andmergeFrom.attributes
(lines from 1415 to 1423 plus lines 1432 and 1437) so that thefor
loop is untouched by the changes that are being made to theNameNodeMap
s. To be fair, onlymergeTo.attributes
required such a treatment, sincemergeFrom
is not being mutated. But I feel this way it is more future-proof and the performance impact should be negligible.But now it's wiping all three
htmx-*
classesIt seems to me that
cloneAttributes
should be aware of the fact thathtmx-*
classes should be preserved: they're added by HTMX for a reason, and it's up to other parts of HTMX to get rid of them when it's done using them. So I added the lines from 1425 to 1430 and from 1443 to 1445.I initially implemented something that preserved only
htmx.config.addedClass
,htmx.config.settlingClass
andhtmx.config.swappingClass
(which arehtmx-added
,htmx-settling
andhtmx-swapping
respectively). Also, this first version of mine only preserved the classes in case they were wiped out by the firstforEach
loop, but I soon realized the secondforEach
could wipe them as well. We just need to have a/test
response that looks like this:In this case, the second
forEach
would causesetAttribute('class', 'my-class')
to be called, effectively obliterating all thehtmx-*
classes.The code I ended up writing is both more concise and more conceptually sound, in my opinion. I'd like to know if maintainers think the same.
How to test the
cloneAttributes
fix?As it stands before the fix,
cloneAttributes
does not remove theclass
attribute if it is preceded by astyle
(orwidth
, orheight
) attribute.So, even after the line 1992 fix, but before the
cloneAttributes
fix, we can still have the buggy behavior if the original node in the body looks like this:After the response from
/test
we would find the following div in the body:instead of the expected:
The regression test for the
cloneAttributes
fix covers exactly this scenario.Corresponding issue
No corresponding issue to this PR. The guidelines said that, if I already have a solution, a PR is better suited, so here I am :)
Testing
I wrote the "swap=outerHTML clears htmx-swapping class when old node has a style attribute and no class" test first, which did not pass before the fix and passes after.
I realized the fix at line 1992 is all it takes to make this test pass, so we still missed a regression test for the fix in
cloneAttributes
I added the "swap=outerHTML won't carry over user-defined classes when old node has a style attribute before the class attribute" test, which did not pass after the line 1992 fix alone, but passes after the
cloneAttributes
fix.I manually checked (with the debugger) that
htmx-added
andhtmx-settling
now survive the secondcloneAttributes
call and are removed, respectively, here:htmx/src/htmx.js
Line 1572 in d1d0cd9
and here:
htmx/src/htmx.js
Line 1995 in d1d0cd9
I checked this in both the possible scenarios:
forEach
is the cause, i.e. when the original node does not have aclass
attribute;forEach
is the cause, i.e. when the original node has aclass
attribute with a user-defined class.Checklist
master
for website changes,dev
forsource changes)
approved via an issue
npm run test
) and verified that it succeeded