diff --git a/src/htmx.js b/src/htmx.js index a8d36b64c..93e040e3b 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -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); @@ -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; } diff --git a/test/tests/unit/morph.js b/test/tests/unit/morph.js index 142e232ef..0a8dc7dcb 100644 --- a/test/tests/unit/morph.js +++ b/test/tests/unit/morph.js @@ -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', + '
'); + const container = createProcessedHTML( + '
'); + 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', '');