describe('Morph Swap Styles Tests', function() {
beforeEach(function() {
setupTest();
});
afterEach(function() {
cleanupTest();
});
describe('innerMorph', function() {
it('morphs children while preserving element with matching id', async function() {
mockResponse('GET', '/test', '
updated
new
');
const div = createProcessedHTML('');
const child1 = div.querySelector('#child1');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('#child1'), child1, 'Element with id should be preserved');
assert.equal(child1.textContent, 'updated');
assert.isNotNull(div.querySelector('#child2'));
});
it('morphs text content', async function() {
mockResponse('GET', '/test', 'new text
');
const div = createProcessedHTML('');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.include(div.innerHTML, 'new text');
assert.notInclude(div.innerHTML, 'old text');
});
it('morphs attributes', async function() {
mockResponse('GET', '/test', 'content
');
const div = createProcessedHTML('');
const child = div.querySelector('#child');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(child.className, 'new-class');
assert.equal(child.getAttribute('data-value'), '123');
});
it('removes old elements not in new content', async function() {
mockResponse('GET', '/test', 'kept
');
const div = createProcessedHTML('');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.isNotNull(div.querySelector('#keep'));
assert.isNull(div.querySelector('#remove'));
});
it('adds new elements', async function() {
mockResponse('GET', '/test', 'old
new
');
const div = createProcessedHTML('');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.isNotNull(div.querySelector('#old'));
assert.isNotNull(div.querySelector('#new'));
});
it('preserves element references with matching ids', async function() {
mockResponse('GET', '/test', ' ');
const div = createProcessedHTML('
');
const input1 = div.querySelector('#input1');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('#input1'), input1, 'Input element should be same reference');
assert.equal(input1.value, 'new');
assert.isNotNull(div.querySelector('#input2'));
});
it('handles nested elements with ids', async function() {
mockResponse('GET', '/test', '');
const div = createProcessedHTML('');
const outer = div.querySelector('#outer');
const inner = div.querySelector('#inner');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('#outer'), outer);
assert.equal(div.querySelector('#inner'), inner);
assert.equal(inner.textContent, 'updated');
});
it('morphs without ids using tag matching', async function() {
mockResponse('GET', '/test', 'new paragraph
new span ');
const div = createProcessedHTML('');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.include(div.innerHTML, 'new paragraph');
assert.include(div.innerHTML, 'new span');
});
});
describe('outerMorph', function() {
it('morphs the target element itself', async function() {
mockResponse('GET', '/test', 'updated
');
const container = createProcessedHTML('');
const target = container.querySelector('#target');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'outerMorph'});
const newTarget = container.querySelector('#target');
assert.equal(newTarget, target, 'Target element should be morphed, not replaced');
assert.equal(newTarget.className, 'new-class');
assert.equal(newTarget.textContent, 'updated');
});
it('morphs target attributes', async function() {
mockResponse('GET', '/test', 'content
');
const container = createProcessedHTML('');
const target = container.querySelector('#target');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'outerMorph'});
assert.equal(container.querySelector('#target'), target);
assert.equal(target.className, 'morphed');
assert.equal(target.getAttribute('data-value'), '123');
});
it('morphs target children', async function() {
mockResponse('GET', '/test', 'new child
');
const container = createProcessedHTML('');
const target = container.querySelector('#target');
const child = target.querySelector('#child');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'outerMorph'});
assert.equal(container.querySelector('#target'), target);
assert.equal(target.querySelector('#child'), child);
assert.equal(child.textContent, 'new child');
});
it('preserves element identity during morph', async function() {
mockResponse('GET', '/test', 'Click Me ');
const container = createProcessedHTML('Click
');
const btn = container.querySelector('#btn');
let clicked = false;
btn.addEventListener('click', () => clicked = true);
await htmx.ajax('GET', '/test', {target: '#btn', swap: 'outerMorph'});
const newBtn = container.querySelector('#btn');
assert.equal(newBtn, btn, 'Button should be same element');
newBtn.click();
assert.isTrue(clicked, 'Event listener should still work');
});
});
describe('innerMorph vs innerHTML comparison', function() {
it('innerMorph preserves elements, innerHTML replaces', async function() {
mockResponse('GET', '/test', ' ');
// Test innerHTML
const div1 = createProcessedHTML('
');
const input1 = div1.querySelector('#field');
await htmx.ajax('GET', '/test', {target: '#target1', swap: 'innerHTML'});
const newInput1 = div1.querySelector('#field');
assert.notEqual(newInput1, input1, 'innerHTML should create new element');
// Test innerMorph
mockResponse('GET', '/test', ' ');
const div2 = createProcessedHTML('
');
const input2 = div2.querySelector('#field');
await htmx.ajax('GET', '/test', {target: '#target2', swap: 'innerMorph'});
const newInput2 = div2.querySelector('#field');
assert.equal(newInput2, input2, 'innerMorph should preserve element');
});
});
describe('outerMorph vs outerHTML comparison', function() {
it('outerMorph preserves target, outerHTML replaces', async function() {
mockResponse('GET', '/test', 'updated
');
// Test outerHTML
const container1 = createProcessedHTML('');
const target1 = container1.querySelector('#target');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'outerHTML'});
const newTarget1 = container1.querySelector('#target');
assert.notEqual(newTarget1, target1, 'outerHTML should create new element');
// Test outerMorph
mockResponse('GET', '/test', 'updated
');
const container2 = createProcessedHTML('');
const target2 = container2.querySelector('#target');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'outerMorph'});
const newTarget2 = container2.querySelector('#target');
assert.equal(newTarget2, target2, 'outerMorph should preserve element');
});
});
describe('edge cases', function() {
it('handles empty content', async function() {
mockResponse('GET', '/test', '');
const div = createProcessedHTML('');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.innerHTML, '');
});
it('handles complex nested structures', async function() {
mockResponse('GET', '/test',
'');
const div = createProcessedHTML(
'');
const a = div.querySelector('#a');
const b = div.querySelector('#b');
const c = div.querySelector('#c');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('#a'), a);
assert.equal(div.querySelector('#b'), b);
assert.equal(div.querySelector('#c'), c);
assert.equal(c.textContent, 'updated');
});
it('handles mixed content with and without ids', async function() {
mockResponse('GET', '/test',
'has id
no id
another ');
const div = createProcessedHTML(
'');
const withId = div.querySelector('#with-id');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('#with-id'), withId);
assert.equal(withId.textContent, 'has id');
assert.isNotNull(div.querySelector('#another'));
});
it('handles numeric ids', async function() {
mockResponse('GET', '/test', '
');
const div = createProcessedHTML('
');
const hr = div.querySelector('#\\31');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('#\\31'), hr);
});
});
describe('input value preservation', function() {
it('preserves input value when attribute unchanged', async function() {
mockResponse('GET', '/test', ' ');
const div = createProcessedHTML('
');
const input = div.querySelector('#field');
input.value = 'user-typed';
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(input.value, 'user-typed', 'Preserves user input when value attribute unchanged');
assert.equal(input.className, 'updated');
});
it('updates input value when attribute changes', async function() {
mockResponse('GET', '/test', ' ');
const div = createProcessedHTML('
');
const input = div.querySelector('#field');
input.value = 'user-typed';
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(input.value, 'new', 'Updates value when attribute changes');
});
it('preserves textarea value when content unchanged', async function() {
mockResponse('GET', '/test', '');
const div = createProcessedHTML('
');
const textarea = div.querySelector('#field');
textarea.value = 'user-typed';
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(textarea.value, 'user-typed', 'Preserves user input when content unchanged');
assert.equal(textarea.className, 'updated');
});
it('updates textarea value when content changes', async function() {
mockResponse('GET', '/test', '');
const div = createProcessedHTML('
');
const textarea = div.querySelector('#field');
textarea.value = 'user-typed';
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(textarea.value, 'new', 'Updates value when content changes');
});
it('preserves checkbox state when attribute unchanged', async function() {
mockResponse('GET', '/test', ' ');
const div = createProcessedHTML('
');
const checkbox = div.querySelector('#cb');
checkbox.checked = false;
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(checkbox.checked, false, 'Preserves user state when checked attribute unchanged');
assert.equal(checkbox.className, 'updated');
});
});
describe('element reordering with ids', function() {
it('reorders elements with ids correctly', async function() {
mockResponse('GET', '/test', 'C
B
A
');
const div = createProcessedHTML('');
const a = div.querySelector('#a');
const b = div.querySelector('#b');
const c = div.querySelector('#c');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('#a'), a, 'Element A should be preserved');
assert.equal(div.querySelector('#b'), b, 'Element B should be preserved');
assert.equal(div.querySelector('#c'), c, 'Element C should be preserved');
assert.equal(div.children[0].id, 'c');
assert.equal(div.children[1].id, 'b');
assert.equal(div.children[2].id, 'a');
});
it('moves id node into nested div correctly', async function() {
mockResponse('GET', '/test', '
');
const div = createProcessedHTML('
');
const input = div.querySelector('#first');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('#first'), input);
assert.equal(input.parentElement.tagName, 'DIV');
});
it('handles complex id reordering with nesting', async function() {
mockResponse('GET', '/test', ' ');
const div = createProcessedHTML('');
const a = div.querySelector('#a');
const b = div.querySelector('#b');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('#a'), a);
assert.equal(div.querySelector('#b'), b);
assert.equal(b.parentElement, a, 'b should be inside a');
});
});
describe('attribute morphing edge cases', function() {
it('morphs multiple attributes correctly', async function() {
mockResponse('GET', '/test',
'');
const container = createProcessedHTML('
');
const section = container.querySelector('#s');
await htmx.ajax('GET', '/test', {target: '#s', swap: 'outerMorph'});
assert.equal(section.className, 'thing');
assert.equal(section.getAttribute('data-one'), '1');
assert.equal(section.getAttribute('data-four'), '4');
assert.equal(section.getAttribute('fizz'), 'buzz');
assert.equal(section.textContent, 'B');
});
it('removes attributes correctly', async function() {
mockResponse('GET', '/test', '');
const container = createProcessedHTML(
'
');
const section = container.querySelector('#s');
await htmx.ajax('GET', '/test', {target: '#s', swap: 'outerMorph'});
assert.equal(section.className, 'child');
assert.isNull(section.getAttribute('data-one'));
assert.isNull(section.getAttribute('fizz'));
assert.equal(section.textContent, 'A');
});
it('handles fieldset disabled property correctly', async function() {
mockResponse('GET', '/test', 'hello ');
const container = createProcessedHTML('
');
const fieldset = container.querySelector('#fs');
await htmx.ajax('GET', '/test', {target: '#fs', swap: 'outerMorph'});
assert.equal(fieldset.innerHTML, 'hello');
assert.equal(fieldset.classList.length, 0);
assert.equal(fieldset.disabled, false);
});
});
describe('htmx processing during morph', function() {
it('processes new htmx attributes added during innerMorph', async function() {
mockResponse('GET', '/test', 'Updated
');
const div = createProcessedHTML('');
const btn = div.querySelector('#btn');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(btn.getAttribute('data-htmx-powered'), 'true', 'New htmx attributes should be processed');
mockResponse('GET', '/click', 'Clicked!');
btn.click();
await htmx.forEvent('htmx:after:swap', 100);
assert.equal(div.querySelector('#result').textContent, 'Clicked!', 'New htmx functionality should work');
});
it('processes new htmx attributes added during outerMorph', async function() {
mockResponse('GET', '/test', 'Updated ');
const container = createProcessedHTML('');
const btn = container.querySelector('#btn');
await htmx.ajax('GET', '/test', {target: '#btn', swap: 'outerMorph'});
assert.equal(btn.getAttribute('data-htmx-powered'), 'true', 'New htmx attributes should be processed');
mockResponse('GET', '/click', 'Clicked!');
btn.click();
await htmx.forEvent('htmx:after:swap', 100);
assert.equal(container.querySelector('#result').textContent, 'Clicked!', 'New htmx functionality should work');
});
it('processes new htmx attributes on inserted elements during innerMorph', async function() {
mockResponse('GET', '/test', 'Existing New Button ');
const div = createProcessedHTML('Existing
');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
const newBtn = div.querySelector('#new');
assert.isNotNull(newBtn);
assert.equal(newBtn.getAttribute('data-htmx-powered'), 'true', 'New inserted element should be processed');
});
it('processes new htmx attributes on inserted elements during outerMorph', async function() {
mockResponse('GET', '/test', 'Existing New Button
');
const container = createProcessedHTML('');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'outerMorph'});
const newBtn = container.querySelector('#new');
assert.isNotNull(newBtn);
assert.equal(newBtn.getAttribute('data-htmx-powered'), 'true', 'New inserted element should be processed');
});
});
describe('htmx integration', function() {
it('preserves data-htmx-powered attribute during innerMorph', async function() {
mockResponse('GET', '/test', 'Updated ');
const div = createProcessedHTML('Original
');
const btn = div.querySelector('#btn');
htmx.process(btn);
assert.equal(btn.getAttribute('data-htmx-powered'), 'true');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(btn.getAttribute('data-htmx-powered'), 'true', 'data-htmx-powered should be preserved');
assert.equal(btn.textContent, 'Updated');
});
it('preserves data-htmx-powered attribute during outerMorph', async function() {
mockResponse('GET', '/test', 'Updated ');
const container = createProcessedHTML('Original
');
const btn = container.querySelector('#btn');
htmx.process(btn);
assert.equal(btn.getAttribute('data-htmx-powered'), 'true');
await htmx.ajax('GET', '/test', {target: '#btn', swap: 'outerMorph'});
const newBtn = container.querySelector('#btn');
assert.equal(newBtn, btn, 'Button should be same element');
assert.equal(newBtn.getAttribute('data-htmx-powered'), 'true', 'data-htmx-powered should be preserved');
assert.equal(newBtn.className, 'new');
});
it('preserves htmx event listeners during morph', async function() {
mockResponse('GET', '/click', 'Clicked!');
mockResponse('GET', '/test', 'Updated
');
const div = createProcessedHTML('');
const btn = div.querySelector('#btn');
const result = div.querySelector('#result');
htmx.process(btn);
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
btn.click();
await htmx.forEvent('htmx:after:swap', 100);
assert.equal(result.textContent, 'Clicked!', 'htmx functionality should still work');
});
});
describe('morphSkip config', function() {
afterEach(function() {
htmx.config.morphSkip = null;
});
it('skips morphing elements matching selector', async function() {
htmx.config.morphSkip = '.no-morph';
mockResponse('GET', '/test', 'new content
');
const div = createProcessedHTML('');
const noMorph = div.querySelector('.no-morph');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(noMorph.getAttribute('data-value'), 'old', 'Attributes should not be updated');
assert.equal(noMorph.textContent, 'old content', 'Content should not be updated');
});
it('skips morphing custom elements', async function() {
htmx.config.morphSkip = 'custom-element';
mockResponse('GET', '/test', 'new ');
const div = createProcessedHTML('old
');
const ce = div.querySelector('custom-element');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(ce.getAttribute('data-value'), 'old');
assert.equal(ce.querySelector('span').textContent, 'old');
});
it('morphs other elements when some are skipped', async function() {
htmx.config.morphSkip = '.skip';
mockResponse('GET', '/test', 'skip
morph
');
const div = createProcessedHTML('');
const skip = div.querySelector('.skip');
const morph = div.querySelector('.morph');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(skip.getAttribute('data-value'), 'old');
assert.equal(morph.getAttribute('data-value'), 'new');
});
});
describe('text node handling', function() {
it('removes text nodes during morph without error', async function() {
mockResponse('GET', '/test', 'content
');
const div = createProcessedHTML('');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.isNotNull(div.querySelector('#child'));
assert.equal(div.querySelector('#child').textContent, 'content');
});
it('handles mixed text nodes and elements', async function() {
mockResponse('GET', '/test', 'new ');
const div = createProcessedHTML('text1old text2
');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.isNotNull(div.querySelector('span'));
assert.equal(div.querySelector('span').textContent, 'new');
});
it('replaces text nodes with elements', async function() {
mockResponse('GET', '/test', 'element
');
const div = createProcessedHTML('just text
');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.isNotNull(div.querySelector('#new'));
assert.equal(div.querySelector('#new').textContent, 'element');
});
});
describe('morphSkipChildren config', function() {
afterEach(function() {
htmx.config.morphSkipChildren = null;
});
it('updates attributes but skips children morphing', async function() {
htmx.config.morphSkipChildren = '.skip-children';
mockResponse('GET', '/test', 'new child
');
const div = createProcessedHTML('');
const skipChildren = div.querySelector('.skip-children');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(skipChildren.getAttribute('data-value'), 'new', 'Attributes should be updated');
assert.equal(skipChildren.querySelector('span').textContent, 'old child', 'Children should not be morphed');
});
it('preserves Light DOM children in custom elements', async function() {
htmx.config.morphSkipChildren = 'lit-component';
mockResponse('GET', '/test', 'new
');
const div = createProcessedHTML('');
const lc = div.querySelector('lit-component');
const internal = lc.querySelector('.internal');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(lc.getAttribute('value'), 'new', 'Attributes should update');
assert.equal(internal.textContent, 'old', 'Light DOM children should be preserved');
});
it('works with multiple selectors', async function() {
htmx.config.morphSkipChildren = '.skip1, .skip2';
mockResponse('GET', '/test', 'new1
new2
');
const div = createProcessedHTML('');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('.skip1').getAttribute('data-value'), 'new');
assert.equal(div.querySelector('.skip1 span').textContent, 'old1');
assert.equal(div.querySelector('.skip2').getAttribute('data-value'), 'new');
assert.equal(div.querySelector('.skip2 span').textContent, 'old2');
});
it('allows normal morphing for non-matching elements', async function() {
htmx.config.morphSkipChildren = '.skip-children';
mockResponse('GET', '/test', 'new
new
');
const div = createProcessedHTML('');
await htmx.ajax('GET', '/test', {target: '#target', swap: 'innerMorph'});
assert.equal(div.querySelector('.normal span').textContent, 'new', 'Normal elements should morph children');
assert.equal(div.querySelector('.skip-children span').textContent, 'old', 'Skip elements should preserve children');
});
});
});