Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -2060,6 +2060,7 @@ var htmx = (() => {
let placeholder = document.createElement(newChild.tagName);
oldParent.insertBefore(placeholder, insertionPoint);
this.__morphNode(placeholder, newChild, ctx);
this.process(placeholder);
insertionPoint = placeholder.nextSibling;
} else {
oldParent.insertBefore(newChild, insertionPoint);
Expand Down Expand Up @@ -2127,10 +2128,6 @@ var htmx = (() => {
}
// Script tags must be identical to match - never patch a script with different content
if (oldNode.tagName === 'SCRIPT' && !oldNode.isEqualNode(newNode)) return false;
// If both have Alpine reactive ID bindings, ignore ID mismatch
if (oldNode._x_bindings?.id && newNode.matches?.('[\\:id], [x-bind\\:id]')) {
return true;
}
return !oldNode.id || oldNode.id === newNode.id;
}

Expand Down
31 changes: 31 additions & 0 deletions test/tests/unit/morph.js
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,37 @@ describe('Morph Swap Styles Tests', function() {
assert.equal(container.querySelector('#result').textContent, 'Clicked!', 'htmx actions on new div should work');
});

it('processes hx-* on tag-changing outerMorph when new root has a persistent child id (issue #3882)', async function() {
// The bug: when outerMorph changes the root tag (div→form) AND the new root
// contains a child with a persistent id, __morphChildren creates a placeholder
// element instead of reusing the fragment node. newContent then holds the
// detached fragment node, so process() bails on !isConnected and hx-* attrs
// on the new root are never initialized.
mockResponse('GET', '/test',
'<form id="target" hx-post="/submit"><input id="same-child-id" name="q"></form>');
const container = createProcessedHTML(
'<div><div id="target"><input id="same-child-id" name="q"></div><div id="result"></div></div>');
const childInput = container.querySelector('#same-child-id');

await htmx.ajax('GET', '/test', {target: '#target', swap: 'outerMorph'});

const newForm = container.querySelector('#target');
assert.isNotNull(newForm, 'new form should be in the DOM');
assert.equal(newForm.tagName, 'FORM', 'root tag should have changed to FORM');
assert.equal(container.querySelector('#same-child-id'), childInput,
'persistent child should be the same node');
assert.equal(newForm.getAttribute('data-htmx-powered'), 'true',
'new form root must be processed so hx-post works');

mockResponse('POST', '/submit', 'Submitted!');
newForm.dispatchEvent(new Event('submit', {bubbles: true}));
await waitForEvent('htmx:after:swap', 100);

assert.equal(container.querySelector('#result') ??
container.querySelector('#target'), container.querySelector('#result') ??
newForm, 'swap target should be reachable');
});

it('does not stack hx-on:click handlers across innerMorph', async function() {
window._calls = 0;
mockResponse('GET', '/test', '<button id="btn" hx-on:click="window._calls++">v</button>');
Expand Down
Loading