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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Fixed:
* Fix error when morphing elements with numeric ids (@botandrose, @ksbrooksjr)
* Fix issue with outerHTML morphing an IDed node that gets moved (@botandrose, @MichaelWest22)
* Fix incorrect return value when root element gets moved or replaced in an outerHTML morph (@botandrose, @MichaelWest22)

## [0.7.2] - 2025-02-20

Expand Down
114 changes: 75 additions & 39 deletions src/idiomorph.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,6 @@ var Idiomorph = (function () {
*/
function morphOuterHTML(ctx, oldNode, newNode) {
const oldParent = normalizeParent(oldNode);

// basis for calulating which nodes were morphed
// since there may be unmorphed sibling nodes
let childNodes = Array.from(oldParent.childNodes);
const index = childNodes.indexOf(oldNode);
// how many elements are to the right of the oldNode
const rightMargin = childNodes.length - (index + 1);

morphChildren(
ctx,
oldParent,
Expand All @@ -203,10 +195,8 @@ var Idiomorph = (function () {
oldNode, // start point for iteration
oldNode.nextSibling, // end point for iteration
);

// return just the morphed nodes
childNodes = Array.from(oldParent.childNodes);
return childNodes.slice(index, childNodes.length - rightMargin);
// this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed.
return Array.from(oldParent.childNodes);
}

/**
Expand Down Expand Up @@ -1199,8 +1189,9 @@ var Idiomorph = (function () {
if (newContent.parentNode) {
// we can't use the parent directly because newContent may have siblings
// that we don't want in the morph, and reparenting might be expensive (TODO is it?),
// so we create a duck-typed parent node instead.
return createDuckTypedParent(newContent);
// so instead we create a fake parent node that only sees a slice of its children.
/** @type {Element} */
return /** @type {any} */ (new SlicedParentNode(newContent));
} else {
// a single node is added as a child to a dummy parent
const dummyParent = document.createElement("div");
Expand All @@ -1219,33 +1210,78 @@ var Idiomorph = (function () {
}

/**
* Creates a fake duck-typed parent element to wrap a single node, without actually reparenting it.
* A fake duck-typed parent element to wrap a single node, without actually reparenting it.
* This is useful because the node may have siblings that we don't want in the morph, and it may also be moved
* or replaced with one or more elements during the morph. This class effectively allows us a window into
* a slice of a node's children.
* "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916)
*
* @param {Node} newContent
* @returns {Element}
*/
function createDuckTypedParent(newContent) {
return /** @type {Element} */ (
/** @type {unknown} */ ({
childNodes: [newContent],
/** @ts-ignore - cover your eyes for a minute, tsc */
querySelectorAll: (s) => {
/** @ts-ignore */
const elements = newContent.querySelectorAll(s);
/** @ts-ignore */
return newContent.matches(s) ? [newContent, ...elements] : elements;
},
/** @ts-ignore */
insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r),
/** @ts-ignore */
moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r),
// for later use with populateIdMapWithTree to halt upwards iteration
get __idiomorphRoot() {
return newContent;
},
})
);
class SlicedParentNode {
/** @param {Node} node */
constructor(node) {
this.originalNode = node;
this.realParentNode = /** @type {Element} */ (node.parentNode);
this.previousSibling = node.previousSibling;
this.nextSibling = node.nextSibling;
}

/** @returns {Node[]} */
get childNodes() {
// return slice of realParent's current childNodes, based on previousSibling and nextSibling
const nodes = [];
let cursor = this.previousSibling
? this.previousSibling.nextSibling
: this.realParentNode.firstChild;
while (cursor && cursor != this.nextSibling) {
nodes.push(cursor);
cursor = cursor.nextSibling;
}
return nodes;
}

/**
* @param {string} selector
* @returns {Element[]}
*/
querySelectorAll(selector) {
return this.childNodes.reduce((results, node) => {
if (node instanceof Element) {
if (node.matches(selector)) results.push(node);
const nodeList = node.querySelectorAll(selector);
for (let i = 0; i < nodeList.length; i++) {
results.push(nodeList[i]);
}
}
return results;
}, /** @type {Element[]} */ ([]));
}

/**
* @param {Node} node
* @param {Node} referenceNode
* @returns {Node}
*/
insertBefore(node, referenceNode) {
return this.realParentNode.insertBefore(node, referenceNode);
}

/**
* @param {Node} node
* @param {Node} referenceNode
* @returns {Node}
*/
moveBefore(node, referenceNode) {
// @ts-ignore - use new moveBefore feature
return this.realParentNode.moveBefore(node, referenceNode);
}

/**
* for later use with populateIdMapWithTree to halt upwards iteration
* @returns {Node}
*/
get __idiomorphRoot() {
return this.originalNode;
}
}

/**
Expand Down
36 changes: 9 additions & 27 deletions test/fidelity.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
describe("Tests to ensure that idiomorph merges properly", function () {
setup();

function expectFidelity(actual, expected) {
if (actual.outerHTML !== expected) {
console.log("HTML after morph: " + actual.outerHTML);
console.log("Expected: " + expected);
}
actual.outerHTML.should.equal(expected);
}

function testFidelity(start, end) {
let initial = make(start);
let final = make(end);
Idiomorph.morph(initial, final);

expectFidelity(initial, end);
getWorkArea().innerHTML = start;
let startElement = getWorkArea().firstElementChild;
let ret = Idiomorph.morph(startElement, end);
getWorkArea().innerHTML.should.equal(end);
ret.map((e) => e.outerHTML).should.eql([end]);
}

// bootstrap test
Expand All @@ -37,10 +29,10 @@ describe("Tests to ensure that idiomorph merges properly", function () {
const initial = make(a);

Idiomorph.morph(initial, expectedB);
expectFidelity(initial, b);
initial.outerHTML.should.equal(b);

Idiomorph.morph(initial, expectedA);
expectFidelity(initial, a);
initial.outerHTML.should.equal(a);
});

it("morphs children", function () {
Expand Down Expand Up @@ -78,21 +70,11 @@ describe("Tests to ensure that idiomorph merges properly", function () {
});

it("should wrap an IDed node", function () {
getWorkArea().innerHTML = `<hr id="a">`;
let initial = getWorkArea().firstElementChild;
let finalSrc = `<div><hr id="a"></div>`;
let ret = Idiomorph.morph(initial, finalSrc);
getWorkArea().innerHTML.should.equal(finalSrc);
// ret.map(e=>e.outerHTML).should.eql([finalSrc]);
testFidelity(`<hr id="a">`, `<div><hr id="a"></div>`);
});

it("should wrap an anonymous node", function () {
getWorkArea().innerHTML = `<hr>`;
let initial = getWorkArea().firstElementChild;
let finalSrc = `<div><hr></div>`;
let ret = Idiomorph.morph(initial, finalSrc);
getWorkArea().innerHTML.should.equal(finalSrc);
// ret.map(e=>e.outerHTML).should.eql([finalSrc]);
testFidelity(`<hr>`, `<div><hr></div>`);
});

it("should append a node", function () {
Expand Down
29 changes: 29 additions & 0 deletions test/htmx-integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,33 @@ describe("Tests for the htmx integration", function () {
initialBtn.innerHTML.should.equal("Bar");
});
*/

it("keeps the element live in an outer morph when node type changes", function () {
this.server.respondWith(
"GET",
"/test",
"<div id='b1' hx-swap='morph' hx-get='/test2' class='bar'>Foo</div>",
);
this.server.respondWith(
"GET",
"/test2",
"<button id='b1' hx-swap='morph' hx-get='/test3' class='doh'>Foo</button>",
);
let initialBtn = makeForHtmxTest(
"<button id='b1' hx-swap='morph' hx-get='/test'>Foo</button>",
);

initialBtn.click();
this.server.respond();
let newDiv = document.getElementById("b1");

newDiv.classList.contains("bar").should.equal(true);
newDiv.classList.contains("doh").should.equal(false);

newDiv.click();
this.server.respond();
let newBtn = document.getElementById("b1");
newBtn.classList.contains("bar").should.equal(false);
newBtn.classList.contains("doh").should.equal(true);
});
});